diff --git a/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md b/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md index f6baa25367..cb61ef9122 100644 --- a/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md +++ b/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md @@ -15,14 +15,16 @@ description: Use when a user says "minimize this ty ecosystem change", "reproduc Start each investigation from fresh artifacts. Do not trust retained memories, previous minimizations, current upstream project state, or the helper script's default lockfile. +Prefix every direct or indirect `gh` invocation with `GH_TELEMETRY=false`; each Codex tool call may start a new shell. + ## Collect Exact-Run Metadata -If a primary agent supplied an existing run-metadata manifest, verify that its run ID and attempt match the frozen report and that it contains each assigned project. Reuse the manifest without modifying it. +If the primary agent supplied an immutable `TY_ECOSYSTEM_RUN_METADATA` manifest, verify that its run ID and attempt match the frozen report and that it contains each assigned project. All subagents reuse the same read-only manifest; never modify it or generate another shared manifest. -Otherwise, run the bundled helper with the Actions run ID or URL, matching attempt, and every affected mypy-primer project name: +Otherwise, run the bundled helper once with the Actions run ID or URL, matching attempt, and every affected mypy-primer project name: ```bash -scripts/collect_ty_ecosystem_run_metadata.py \ +GH_TELEMETRY=false uv run --script scripts/collect_ty_ecosystem_run_metadata.py \ ... \ --attempt \ --output target/ty-ecosystem-run.json @@ -34,9 +36,9 @@ The current workflow splits compilation into `Build ty (base)` and `Build ty (pr ## Prepare ty -If a primary agent supplied freshly copied base and PR profiling binaries plus the PR ecosystem config, preserve their absolute paths as `TY_ECOSYSTEM_BASE_BINARY` and `TY_ECOSYSTEM_PR_BINARY`, verify they exist, and reuse them. Do not rebuild those binaries, switch shared Ruff refs, or overwrite the shared artifacts. An agent may build an exact-revision debug binary on demand to identify an ambiguous internal type, using an isolated worktree if necessary; the profiling binaries remain the behavioral oracle. +If a primary agent supplied freshly copied base and PR profiling binaries plus the PR ecosystem config, preserve their absolute paths as `TY_ECOSYSTEM_BASE_BINARY` and `TY_ECOSYSTEM_PR_BINARY`, verify they exist, and reuse them. Do not rebuild those binaries, switch shared Ruff refs, or overwrite the shared artifacts. If an exact-revision debug binary is needed to identify an ambiguous internal type, request it from the primary agent; the profiling binaries remain the behavioral oracle. -Otherwise, require a clean working tree, copy `.github/ty-ecosystem.toml` from the PR revision, and build ty on the manifest's merge base and PR revision: +Otherwise, require a clean working tree, remember its original ref, and build both exact revisions before assigning any subagent work. Reuse the checkout's existing Cargo target directory, copy the profiling binaries and PR ecosystem config to `target/ty-ecosystem-bins`, and restore the original ref when finished: Fetch the PR revision explicitly because pull-request runs usually use a synthetic GitHub merge commit that a normal clone does not contain: @@ -44,26 +46,33 @@ Fetch the PR revision explicitly because pull-request runs usually use a synthet set -euo pipefail test -z "$(git status --short)" || { git status --short; exit 1; } -git fetch origin +original_ref="$(git symbolic-ref --quiet --short HEAD || git rev-parse HEAD)" +GH_TELEMETRY=false git fetch https://github.com/astral-sh/ruff.git mkdir -p target/ty-ecosystem-bins +trap 'git checkout "$original_ref"' EXIT + +artifact_dir="$PWD/target/ty-ecosystem-bins" +build_target_dir="${CARGO_TARGET_DIR:-target}" export CARGO_PROFILE_PROFILING_DEBUG=line-tables-only -git checkout +git checkout --detach cargo build --package ty --profile profiling -cp target/profiling/ty target/ty-ecosystem-bins/ty-base +cp "$build_target_dir/profiling/ty" "$artifact_dir/ty-base" -git checkout -cp .github/ty-ecosystem.toml target/ty-ecosystem-bins/ty-ecosystem.toml +git checkout --detach +cp .github/ty-ecosystem.toml "$artifact_dir/ty-ecosystem.toml" cargo build --package ty --profile profiling -cp target/profiling/ty target/ty-ecosystem-bins/ty-pr +cp "$build_target_dir/profiling/ty" "$artifact_dir/ty-pr" ``` +After restoring the original ref, inspect vendored definitions and Rust implementations with `git -C show :`, selecting the merge-base or PR revision from the immutable manifest. Never assume working-tree files match either analyzed binary or switch the shared checkout's ref. + ## Reproduce -Create a unique temporary directory for each project and use its absolute path. Read its Python version and the pinned mypy-primer revision from the manifest. Obtain the project revision from the `/blob//` component of the original diagnostic's source permalink, and check that links for the same project agree. If no diagnostic permalink exists, inspect the matching diagnostics shard or Actions logs; if the exact revision cannot be recovered, explicitly report that limitation. Then bypass the adjacent script lockfile: +Create a unique temporary directory for each project and use its absolute path. Read its Python version and the pinned mypy-primer revision from the shared manifest. Obtain the project revision from the `/blob//` component of the original diagnostic's source permalink, and check that links for the same project agree. If no diagnostic permalink exists, inspect the matching diagnostics shard or Actions logs; if the exact revision cannot be recovered, explicitly report that limitation. Then bypass the adjacent script lockfile: ```bash -uv run \ +GH_TELEMETRY=false uv run \ --python \ --with "mypy-primer @ git+https://github.com/hauntsaninja/mypy_primer@" \ --no-project \ @@ -73,7 +82,7 @@ uv run \ --exclude-newer ``` -Use the ecosystem config as user-level configuration, matching CI without replacing project-level config discovery, and re-export `XDG_CONFIG_HOME` in each new shell. If a primary agent supplied `TY_ECOSYSTEM_CONFIG_HOME`, reuse its installed config without modifying it; otherwise, install the copied config locally. Read the project's `strict` or `non-strict` label from the frozen detailed report, or its `strict_settings` value from the matching diagnostics shard. Preserve that mode when running either binary: +Use the ecosystem config as user-level configuration, matching CI without replacing project-level config discovery, and re-export `XDG_CONFIG_HOME` and `RUST_BACKTRACE=1` in each new shell. If a primary agent supplied `TY_ECOSYSTEM_CONFIG_HOME`, reuse its installed config without modifying it; otherwise, install the copied config locally. Read the project's `strict` or `non-strict` label from the frozen detailed report, or its `strict_settings` value from the matching diagnostics shard. Preserve that mode when running either binary: ```bash if [[ -n "${TY_ECOSYSTEM_CONFIG_HOME:-}" ]]; then @@ -85,6 +94,7 @@ else cp "$PWD/target/ty-ecosystem-bins/ty-ecosystem.toml" "$XDG_CONFIG_HOME/ty/ty.toml" fi unset TY_CONFIG_FILE +export RUST_BACKTRACE=1 project_dir="" ty_base="${TY_ECOSYSTEM_BASE_BINARY:-$PWD/target/ty-ecosystem-bins/ty-base}" @@ -116,16 +126,18 @@ pr_exit_status=0 run_ecosystem_ty || pr_exit_status=$? ``` -Confirm the detailed report's difference exactly, including duplicate diagnostics and both exit statuses. Ordinary diagnostics can produce exit status 1; do not mistake that for a failed reproduction. +Confirm the detailed report's difference exactly, including duplicate diagnostics and both exit statuses. When reproducing an intermittent severe failure, repeat each side using its reported run count. Ordinary diagnostics can produce exit status 1; do not mistake that for a failed reproduction. For panics, identify the stable fingerprint by comparing the Rust panic site or decisive causal frame and panic payload; ignore checked Python-file paths and incidental backtrace differences. ## Minimize -Reduce the reproduced project toward a self-contained single-file reproducer with minimal code and dependencies. A reduction is trivial only when the difference already occurs in one self-contained file and can be preserved solely by deleting obviously unrelated code. Multiple files, imports or dependencies, inlining, replacing language constructs, ambiguous types such as `@Todo`, or an uncertain cause make a reduction nontrivial. Before attempting any nontrivial reduction, read and follow [references/advanced-minimization.md](references/advanced-minimization.md). If in doubt, treat the reduction as nontrivial. +The target is a fully minimized, provenance-preserving reproducer: preferably one self-contained file, with no avoidable third-party or standard-library imports and no unnecessary definitions, annotations, branches, or advanced language features. Retain a third-party import only if identified ty behavior depends on that library's identity or third-party search-path classification. + +Before minimizing any ecosystem change, read and follow [references/advanced-minimization.md](references/advanced-minimization.md). Exhaust its complete reduction loop, including third-party dependency and standard-library inlining, and retain an import only after verifying that neither removing it nor inlining its definitions preserves the underlying behavior. -Matching diagnostics or displayed types do not establish a shared cause. When the output is ambiguous, identify the original and minimized triggers using exact-revision debug output, a targeted `reveal_type`, or the producing Rust call site. +Matching diagnostics or displayed types do not establish a shared cause. When the output is ambiguous, identify and compare the original and minimized triggers using exact-revision debug output, a targeted `reveal_type`, or the producing Rust call site from the matching analyzed revision. -Record the original source permalink, accepted reductions, both binaries' results, and any causal fingerprint. If source provenance or a matching cause cannot be established, return the original project excerpt explicitly marked as unminimized. +A minimization is complete only when a verified reduction chain connects the reproducer to the original ecosystem entry and an exhaustive pass finds no further reduction. If a genuine external blocker prevents completion, report the blocker and identify the minimization as incomplete; an original source excerpt is not a successfully minimized result. ## Return -Provide the original permalinked report entry, exact base and PR behavior, minimal code, full diagnostic messages and error codes, and the manifest/commands needed to reproduce it. When called from the summary workflow, return import-audit and reduction notes separately from report-ready Markdown. +Provide the original permalinked report entry, exact base and PR behavior, minimal code, full diagnostic messages and error codes or the panic fingerprint, and the manifest/commands needed to reproduce it. When called from the summary workflow, return import-audit and reduction notes separately from report-ready Markdown. diff --git a/.agents/skills/minimizing-ty-ecosystem-changes/references/advanced-minimization.md b/.agents/skills/minimizing-ty-ecosystem-changes/references/advanced-minimization.md index 056b8f0c45..b374f35171 100644 --- a/.agents/skills/minimizing-ty-ecosystem-changes/references/advanced-minimization.md +++ b/.agents/skills/minimizing-ty-ecosystem-changes/references/advanced-minimization.md @@ -4,7 +4,7 @@ Use this reference after the reported difference reproduces against the copied b ## Target -Prefer a single-file reproducer with no third-party imports, few definitions, and the least complex typing or language features that still demonstrate the difference. Keep special modules such as `typing`, `abc`, `enum`, `types`, and `typing_extensions` only when removing them changes the behavior. +Prefer a single-file reproducer with no avoidable third-party imports, few definitions, and the least complex typing or language features that still demonstrate the difference. Keep special modules such as `typing`, `abc`, `enum`, `types`, and `typing_extensions` only when neither removing them nor inlining their definitions preserves the behavior; retain a third-party import only after identifying ty behavior that depends on that library's identity or third-party search-path classification. ## Reduction Loop @@ -13,16 +13,16 @@ Work systematically from the reproduced project. NEVER skip ahead to an explanat 1. Delete unrelated files. 2. Remove imports, definitions, decorators, annotations, statements, and branches. 3. Inline first-party definitions into the reproducer. -4. For each required third-party dependency, copy the entire installed dependency into the source tree as first-party code, including every package directory and module it provides. Do this before attempting to minimize any part of the dependency. Adjust imports, verify that the difference still reproduces with the complete copy, and only then begin deleting files or definitions from it. Never start by copying only apparently relevant files or definitions. If cloning a dependency is unavoidable, use the exact installed revision or version and copy the complete dependency into the source tree before reducing it. -5. Inline the relevant standard-library definitions from `crates/ty_vendored`, which is ty's source of truth for stdlib types. +4. For each required third-party dependency, copy the entire installed dependency into the source tree as first-party code, including every package directory and module it provides. Do this before attempting to minimize any part of the dependency. Adjust imports, verify that the difference still reproduces with the complete copy, and only then begin deleting files or definitions from it. If the complete copy changes the behavior because ty special-cases that library or distinguishes first-party from third-party search paths, identify the relevant ty implementation at the matching analyzed Ruff revision before retaining the original import. Never start by copying only apparently relevant files or definitions. If cloning a dependency is unavoidable, use the exact installed revision or version and copy the complete dependency into the source tree before reducing it. +5. Inline the relevant standard-library definitions from the analyzed revision of `crates/ty_vendored`, using `git -C show :crates/ty_vendored/`; compare the merge-base and PR definitions when they differ. 6. Replace complex constructs with simpler equivalents, such as removing a walrus expression or replacing a protocol when the difference survives. Repeat the full loop until an exhaustive pass through every stage finds no further reduction that preserves the difference. Do not stop merely because the likely cause is understood or the reproducer is already small. ## Final Audit -Attempt to remove every remaining import and inline every remaining third-party definition. Record why any surviving import is essential. Keep these notes as working evidence; the caller decides whether they belong in its final artifact. +Attempt to remove every remaining import and inline its definitions, including remaining third-party and standard-library definitions. Retain an import only after verifying that neither removal nor inlining preserves the underlying behavior. For a third-party import, additionally verify that its module identity or third-party search-path classification is essential and identify the relevant ty implementation. Convenience, familiar APIs, matching class names, or preserving the diagnostic's module spelling do not justify keeping an import. Record why any surviving import is essential and, for a third-party import, where ty implements the relevant behavior. Keep these notes as working evidence; the caller decides whether they belong in its final artifact. -Verify that the recorded reduction chain connects the final reproducer to the original ecosystem entry and, when diagnostic output is ambiguous, preserves the original causal fingerprint. If either check fails, return the original project excerpt as unminimized instead of substituting an unrelated example. +Verify that the recorded reduction chain connects the final reproducer to the original ecosystem entry and, when diagnostic output is ambiguous, preserves the original causal fingerprint. If a required check fails, continue investigating; if a genuine external blocker prevents completion, report the blocker and mark the minimization as incomplete instead of presenting an unrelated example or original excerpt as a minimized result. Delete transient project and dependency copies after the investigation. diff --git a/.agents/skills/summarise-ecosystem-results/SKILL.md b/.agents/skills/summarise-ecosystem-results/SKILL.md index 1b258a151b..a6e52e3b2b 100644 --- a/.agents/skills/summarise-ecosystem-results/SKILL.md +++ b/.agents/skills/summarise-ecosystem-results/SKILL.md @@ -7,9 +7,15 @@ description: Use when a user says "summarise ecosystem results", "summarize this ## Priorities -1. Reproduce every retained behavior with the exact environment used by the Actions run. -2. Lead the report with new or changed project failures, then cover meaningful flaky behavior, diagnostic changes, and clear minimized examples. -3. Keep execution, audit, and traceability bookkeeping out of the report. +1. Reproduce every retained source-attributable behavior with the exact environment used by the Actions run. +2. For every distinct source-attributable behavior change, produce the smallest provenance-preserving reproducer obtainable through the complete advanced-minimization workflow. +3. Eliminate every third-party import unless identified ty behavior depends on that library's identity or third-party search-path classification, and eliminate every unnecessary standard-library import. Retain an import only after verifying that neither removing it nor inlining its definitions preserves the underlying behavior. +4. Lead the report with new or meaningfully changed project failures, including intermittent severe failures, then cover stable diagnostic changes and fully minimized examples. +5. Keep execution, audit, and traceability bookkeeping out of the report. + +## GitHub CLI Telemetry + +Prefix every direct or indirect `gh` invocation with `GH_TELEMETRY=false`, including `GH_TELEMETRY=false uv run --script scripts/collect_ty_ecosystem_run_metadata.py ...`. Require the same of subagents. Codex tool calls may start separate shells, so an `export` in an earlier call is insufficient. ## Deliverable @@ -19,13 +25,30 @@ Use the template's structure and omissions as the report contract. Remove all pl If summarising an ecosystem report is the only thing you're asked to do in a Codex App thread, you should rename that thread to "PR ecosystem summary". +## Reporting Policy + +- Focus on new or meaningfully changed behavior relative to the merge base. Evaluate individual diagnostics and failure outcomes, not a project's overall flaky or persistent status. +- Omit flaky diagnostic changes, unchanged failures, and frequency fluctuations that leave the observed outcomes unchanged. +- Report new, fixed, or meaningfully changed panics, crashes, overflows, and timeouts, including merge-base and PR run frequencies when intermittent behavior is involved. + ## Workflow -1. **Freeze the evidence.** Preserve any report URL or ecosystem-results comment explicitly supplied by the user before identifying the PR. For PR-only input, find its ecosystem-results comment and linked detailed report. Capture the matching Actions run and attempt as described in [references/evidence-acquisition.md](references/evidence-acquisition.md); never replace a supplied report with the PR's current report. Ignore later comment edits, PR updates, and workflow runs. Use the frozen detailed report as the authoritative change list and the comment for orientation when available. -2. **Identify changed outcomes.** Check the detailed report for new, fixed, or changed project failures, panics, timeouts, abnormal exits, and meaningful flaky diagnostic or exit-status changes. Omit unchanged persistent failures. If neither project outcomes nor diagnostics changed, say explicitly that the run had no ecosystem impact and omit project-specific sections and reproduction details. -3. **Reproduce from scratch.** Ignore retained memories and previous local artifacts. Load the `minimizing-ty-ecosystem-changes` skill, use its metadata helper and exact-run workflow, and reproduce each report entry before explaining or minimizing it. Reproduce flaky behavior with the reported run counts. -4. **Minimize with provenance.** Include a standalone reproducer only when a verified reduction chain connects it to a cited ecosystem entry and preserves the same underlying trigger. If either cannot be verified, retain the original source excerpt and identify it as unminimized. +1. **Freeze the evidence.** Preserve any report URL or ecosystem-results comment explicitly supplied by the user before identifying the PR. For PR-only input, find its ecosystem-results comment and linked detailed report. Capture the matching Actions run and attempt as described in [references/evidence-acquisition.md](references/evidence-acquisition.md); never replace a supplied report with the PR's current report. Recover exact-run metadata promptly, then prepare both exact-revision profiling binaries and the shared configuration before assigning subagent work. Ignore later comment edits, PR updates, and workflow runs. Prefer the selected attempt's validated `full-report/diff.json` as the authoritative structured change inventory, retain its matching frozen HTML report, and use the comment for orientation when available. Fall back to the frozen HTML report if the JSON report is unavailable. +2. **Identify changed outcomes.** Inspect the structured diff for added, removed, and modified projects; stable diagnostic additions, removals, and rewrites; project failures; and intermittent exit-status changes. Preserve diagnostic levels, duplicate occurrences, source permalinks, project strictness, panic evidence, and observed run frequencies. Exclude flaky diagnostics and frequency-only noise without excluding stable diagnostics or changed severe failures from flaky projects. Use the matching HTML report for visual context, or as the primary evidence when structured JSON cannot be obtained safely. +3. **Reproduce from scratch.** Ignore retained memories and previous local artifacts. Load the `minimizing-ty-ecosystem-changes` skill, collect exact-run metadata once, and reproduce every retained, source-attributable diagnostic or panic before explaining or minimizing it. Reproduce intermittent severe failure changes with the reported merge-base and PR run counts. Verify retained outcomes without recoverable source against their captured statuses, stderr, panic evidence, and run frequencies. +4. **Minimize to completion with provenance.** For each distinct source-attributable behavior change, follow the complete advanced-minimization workflow until an exhaustive pass finds no further reduction. Derive the reproducer from a cited ecosystem entry through a verified reduction chain; never replace that entry with an independently invented example demonstrating superficially similar behavior. Before accepting a reproducer, attempt to remove every import, inline every third-party definition, and inline relevant standard-library definitions. Retain a third-party import only when identified ty behavior depends on that library's identity or third-party search-path classification and neither removing the import nor inlining its definitions preserves the underlying behavior. If a genuine external blocker prevents completion, report that blocker to the user and identify the task as incomplete. Do not silently substitute an unminimized excerpt or present a partially minimized report as finished. 5. **Group by cause.** Group entries only when the same base-to-PR behavior, underlying trigger, explanation, and reproducer account for every entry. Identical diagnostic text or displayed `@Todo` types do not establish equivalence. -6. **Write and verify.** Fill the report template, record each affected project's strict or non-strict analysis mode, and include both strict-analysis flags in the comparison method when applicable. Check every link, diagnostic, reproducer's source provenance, and causal fingerprint when required, then run `uv run --only-group dev --locked prek run --files PR__ECOSYSTEM_SUMMARY.md`. Present the Markdown file as the finished product. +6. **Find existing ty issues.** When a diagnostic change exposes a pre-existing shortcoming in ty, search the `astral-sh/ty` issue tracker for the precise underlying behavior. Link matching issues directly from the relevant report section; do not mistake incorrect or incomplete third-party annotations for ty shortcomings. +7. **Write and verify.** Fill the report template and verify that every source-attributable behavior change has a fully minimized, provenance-preserving reproducer. Check every change number, link, diagnostic, retained import, reproducer's source provenance, and causal fingerprint when required. Verify that every retained third-party import is essential to identified ty behavior that depends on that library's identity or third-party search-path classification, that no avoidable standard-library import remains, and that no source-attributable section contains an unminimized excerpt. Then run `GH_TELEMETRY=false uv run --only-group dev --locked prek run --files PR__ECOSYSTEM_SUMMARY.md`. Present the Markdown file as the finished product only after these checks pass. + +## Parallel execution + +This skill explicitly requests subagents when the report contains multiple affected projects or independently investigable entries. + +Once the exact-run metadata, both profiling binaries, and shared configuration are ready, spawn as many subagents as the available concurrency budget and independent work allow, reserving one slot for the primary agent. Keep available slots occupied by assigning further work as subagents finish. + +Assign disjoint projects or explicit report entries. Apparent similarity may guide scheduling, but does not establish causal equivalence. Follow all existing requirements for exhaustive reproduction, verified reduction chains, exhaustive minimization, and grouping by verified cause. + +The primary agent owns the frozen evidence, shared profiling binaries, configuration, coordination, and final report. Follow [references/subagent-handoff.md](references/subagent-handoff.md) for handoff and shared-artifact requirements. -When parallelizing reproduction or minimization, read [references/subagent-handoff.md](references/subagent-handoff.md). Otherwise, keep batches small and work through them sequentially. +If multiple independent assignments exist but no subagents are spawned, record the specific reason. diff --git a/.agents/skills/summarise-ecosystem-results/assets/report-template.md b/.agents/skills/summarise-ecosystem-results/assets/report-template.md index c77654181a..991a7c5bb7 100644 --- a/.agents/skills/summarise-ecosystem-results/assets/report-template.md +++ b/.agents/skills/summarise-ecosystem-results/assets/report-template.md @@ -1,22 +1,38 @@ - + # [PR #](https://github.com/astral-sh/ruff/pull/) ecosystem summary - + - + -## +## Project failures + +### 1. **Affected projects:** - [](): merge base: ``; PR: ``. - + + + + +## Intermittent severe failures + +### 1. + +**Affected projects:** - +- [](): merge base: ``; PR: ``. -## + + + + +## Affected projects + +### 1. **Report entries:** @@ -26,6 +42,10 @@ + + +**Existing ty issues:** [ty#](https://github.com/astral-sh/ty/issues/) + \n"; const COMMAND_HELP_END_PRAGMA: &str = ""; @@ -99,7 +98,7 @@ pub(super) fn main(args: &Args) -> Result<()> { if existing == new { println!("up-to-date: {filename}"); } else { - let comparison = StrComparison::new(&existing, &new); + let comparison = generated_file_diff(&existing, &new); bail!("{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}"); } } diff --git a/crates/ruff_dev/src/generate_default_rules.rs b/crates/ruff_dev/src/generate_default_rules.rs index 4b2c4f0ce0..28c437c524 100644 --- a/crates/ruff_dev/src/generate_default_rules.rs +++ b/crates/ruff_dev/src/generate_default_rules.rs @@ -35,7 +35,7 @@ pub(crate) fn generate() -> String { output.push_str(" select = [\n"); for (_, rules) in &linters { for rule in rules { - let _ = writeln!(output, " \"{}\",", rule.noqa_code()); + let _ = writeln!(output, " \"{}\",", rule.noqa_code().unwrap()); } } output.push_str(" ]\n"); @@ -56,7 +56,7 @@ pub(crate) fn generate() -> String { for rule in rules { let name = rule.name(); - let code = rule.noqa_code(); + let code = rule.noqa_code().unwrap(); let _ = writeln!(output, "- [`{name}`](rules/{name}.md) (`{code}`)"); } output.push('\n'); diff --git a/crates/ruff_dev/src/generate_docs.rs b/crates/ruff_dev/src/generate_docs.rs index fe60abdba6..39e9fd9f20 100644 --- a/crates/ruff_dev/src/generate_docs.rs +++ b/crates/ruff_dev/src/generate_docs.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use anyhow::Result; use itertools::Itertools; use regex::{Captures, Regex}; -use ruff_linter::codes::RuleGroup; +use ruff_linter::codes::RuleStatus; use strum::IntoEnumIterator; use ruff_linter::FixAvailability; @@ -30,57 +30,71 @@ pub(crate) fn main(args: &Args) -> Result<()> { if let Some(explanation) = rule.explanation() { let mut output = String::new(); - let _ = writeln!(&mut output, "# {} ({})", rule.name(), rule.noqa_code()); + let _ = writeln!(&mut output, "# {}", rule.name_and_code()); - let (linter, _) = Linter::parse_code(&rule.noqa_code().to_string()).unwrap(); // a basedpython-specific rule ships in basedpython's releases and lives // in its repository, so every link about it belongs there - let repository = if linter == Linter::Basedpython { + let repository = if rule + .noqa_code() + .and_then(|code| Linter::parse_code(&code.to_string()).map(|(linter, _)| linter)) + == Some(Linter::Basedpython) + { "https://github.com/KotlinIsland/basedpython" } else { "https://github.com/astral-sh/ruff" }; - let status_text = match rule.group() { - RuleGroup::Stable { since } => { + let status_text = match rule.status() { + RuleStatus::Stable { since } => { format!(r#"Added in {since}"#) } - RuleGroup::Preview { since } => { + RuleStatus::Preview { since } => { format!( r#"Preview (since {since})"# ) } - RuleGroup::Deprecated { since } => { + RuleStatus::Deprecated { since } => { format!( r#"Deprecated (since {since})"# ) } - RuleGroup::Removed { since } => { + RuleStatus::Removed { since } => { format!( r#"Removed (since {since})"# ) } }; + let issue_search = format!( + "(%27{encoded_name}%27{code})", + encoded_name = + url::form_urlencoded::byte_serialize(rule.name().as_str().as_bytes()) + .collect::(), + code = rule + .noqa_code() + .map(|code| format!("%20OR%20{code}")) + .unwrap_or_default(), + ); + let _ = writeln!( &mut output, r#" {status_text} · -Related issues · +Related issues · View source "#, - encoded_name = - url::form_urlencoded::byte_serialize(rule.name().as_str().as_bytes()) - .collect::(), - rule_code = rule.noqa_code(), file = url::form_urlencoded::byte_serialize(rule.file().replace('\\', "/").as_bytes()) .collect::(), line = rule.line(), ); - if linter.url().is_some() { + if let Some(linter) = rule + .noqa_code() + .and_then(|code| Linter::parse_code(&code.to_string()).map(|(linter, _)| linter)) + .filter(|linter| linter.url().is_some()) + { let common_prefix: String = match linter.common_prefix() { "" => linter .upstream_categories() @@ -140,11 +154,7 @@ pub(crate) fn main(args: &Args) -> Result<()> { output.push('\n'); } - process_documentation( - explanation.trim(), - &mut output, - &rule.noqa_code().to_string(), - ); + process_documentation(explanation.trim(), &mut output, rule.name().as_str()); let filename = PathBuf::from(ROOT_DIR) .join("docs") diff --git a/crates/ruff_dev/src/generate_json_schema.rs b/crates/ruff_dev/src/generate_json_schema.rs index 239f675828..2368d32b64 100644 --- a/crates/ruff_dev/src/generate_json_schema.rs +++ b/crates/ruff_dev/src/generate_json_schema.rs @@ -2,11 +2,10 @@ use std::fs; use std::path::PathBuf; use anyhow::{Result, bail}; -use pretty_assertions::StrComparison; use schemars::generate::SchemaSettings; use crate::ROOT_DIR; -use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; +use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND, generated_file_diff}; use ruff_workspace::options::Options; #[derive(clap::Args)] @@ -33,7 +32,7 @@ pub(crate) fn main(args: &Args) -> Result<()> { if current == schema_string { println!("Up-to-date: {filename}"); } else { - let comparison = StrComparison::new(¤t, &schema_string); + let comparison = generated_file_diff(¤t, &schema_string); bail!("{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}"); } } diff --git a/crates/ruff_dev/src/generate_rules_table.rs b/crates/ruff_dev/src/generate_rules_table.rs index 82b6458f85..0cd936c082 100644 --- a/crates/ruff_dev/src/generate_rules_table.rs +++ b/crates/ruff_dev/src/generate_rules_table.rs @@ -3,51 +3,65 @@ //! Used for . use itertools::Itertools; -use ruff_linter::codes::RuleGroup; +use ruff_linter::codes::RuleStatus; use std::borrow::Cow; use std::fmt::Write; use strum::IntoEnumIterator; use ruff_linter::FixAvailability; use ruff_linter::registry::{Linter, Rule, RuleNamespace}; +use ruff_linter::settings::LinterSettings; +use ruff_linter::settings::rule_table::RuleTable; use ruff_linter::upstream_categories::UpstreamCategoryAndPrefix; use ruff_options_metadata::OptionsMetadata; use ruff_workspace::options::Options; +const DEFAULT_SYMBOL: &str = "✅"; const FIX_SYMBOL: &str = "🛠️"; const PREVIEW_SYMBOL: &str = "🧪"; const REMOVED_SYMBOL: &str = "❌"; const WARNING_SYMBOL: &str = "⚠️"; const SPACER: &str = "    "; -/// Style for the rule's fixability and status icons. +/// Style for the rule's default selection, fixability, and status icons. const SYMBOL_STYLE: &str = "style='width: 1em; display: inline-block;'"; -/// Style for the container wrapping the fixability and status icons. +/// Style for the container wrapping the default selection, fixability, and status icons. const SYMBOLS_CONTAINER: &str = "style='display: flex; gap: 0.5rem; justify-content: end;'"; -fn generate_table(table_out: &mut String, rules: impl IntoIterator, linter: &Linter) { - table_out.push_str("| Code { scope='col' } | Name { scope='col' } | Message { scope='col' } | Fix/Status { scope='col' .sr-only } |"); +fn generate_table( + table_out: &mut String, + rules: impl IntoIterator, + linter: Option<&Linter>, + default_rules: &RuleTable, +) { + if linter.is_some() { + table_out.push_str("| Code { scope='col' } "); + } + table_out.push_str("| Name { scope='col' } | Message { scope='col' } | Status/Fix/Default { scope='col' .sr-only } |"); table_out.push('\n'); - table_out.push_str("| ---- | ---- | ------- | -: |"); + if linter.is_some() { + table_out.push_str("| ---- "); + } + table_out.push_str("| ---- | ------- | -: |"); table_out.push('\n'); for rule in rules { - let status_token = match rule.group() { - RuleGroup::Removed { since } => { + let status_token = match rule.status() { + RuleStatus::Removed { since } => { format!( "Rule was removed in {since}" ) } - RuleGroup::Deprecated { since } => { + RuleStatus::Deprecated { since } => { format!( "Rule has been deprecated since {since}" ) } - RuleGroup::Preview { since } => { + RuleStatus::Preview { since } => { format!( "Rule has been in preview since {since}" ) } - RuleGroup::Stable { since } => { + RuleStatus::Stable { since } => { format!( "Rule has been stable since {since}" ) @@ -63,6 +77,14 @@ fn generate_table(table_out: &mut String, rules: impl IntoIterator, FixAvailability::None => format!(""), }; + let default_token = if default_rules.enabled(rule) { + format!( + "Enabled by default" + ) + } else { + format!("") + }; + let rule_name = rule.name(); // If the message ends in a bracketed expression (like: "Use {replacement}"), escape the @@ -86,12 +108,19 @@ fn generate_table(table_out: &mut String, rules: impl IntoIterator, se = ""; } + if let Some(linter) = linter { + let _ = write!( + table_out, + "| {ss}{prefix}{code}{se} {{ #{prefix}{code} }} ", + prefix = linter.common_prefix(), + code = linter.code_for_rule(rule).unwrap(), + ); + } + #[expect(clippy::or_fun_call)] let _ = write!( table_out, - "| {ss}{prefix}{code}{se} {{ #{prefix}{code} }} | {ss}{explanation}{se} | {ss}{message}{se} |
{status_token}{fix_token}
|", - prefix = linter.common_prefix(), - code = linter.code_for_rule(rule).unwrap(), + "| {ss}{explanation}{se} | {ss}{message}{se} |
{status_token}{fix_token}{default_token}
|", explanation = rule .explanation() .is_some() @@ -132,10 +161,17 @@ pub(crate) fn generate() -> String { &mut table_out, "{SPACER}{FIX_SYMBOL}{SPACER} The rule is automatically fixable by the `--fix` command-line option." ); + table_out.push_str("
"); + + let _ = write!( + &mut table_out, + "{SPACER}{DEFAULT_SYMBOL}{SPACER} The rule is enabled by default." + ); table_out.push_str("\n\n"); table_out.push_str("All rules not marked as preview, deprecated or removed are stable."); table_out.push('\n'); + let default_rules = LinterSettings::default().rules; for linter in Linter::iter() { let codes_csv: String = match linter.common_prefix() { "" => linter @@ -222,12 +258,25 @@ pub(crate) fn generate() -> String { } table_out.push('\n'); table_out.push('\n'); - generate_table(&mut table_out, rules.clone(), &linter); + generate_table(&mut table_out, rules.clone(), Some(&linter), &default_rules); } } else { - generate_table(&mut table_out, linter.all_rules(), &linter); + generate_table( + &mut table_out, + linter.all_rules(), + Some(&linter), + &default_rules, + ); } } + let mut codeless_rules = Rule::iter() + .filter(|rule| rule.noqa_code().is_none()) + .peekable(); + if codeless_rules.peek().is_some() { + table_out.push_str("### Rules without codes\n\n"); + generate_table(&mut table_out, codeless_rules, None, &default_rules); + } + table_out } diff --git a/crates/ruff_dev/src/generate_ty_cli_reference.rs b/crates/ruff_dev/src/generate_ty_cli_reference.rs index cc6e7cc2e0..1ca8277426 100644 --- a/crates/ruff_dev/src/generate_ty_cli_reference.rs +++ b/crates/ruff_dev/src/generate_ty_cli_reference.rs @@ -5,10 +5,9 @@ use std::path::PathBuf; use anyhow::{Result, bail}; use clap::{Command, CommandFactory}; use itertools::Itertools; -use pretty_assertions::StrComparison; use crate::ROOT_DIR; -use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; +use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND, generated_file_diff}; use ty::Cli; @@ -34,7 +33,7 @@ pub(crate) fn main(args: &Args) -> Result<()> { if current == reference_string { println!("Up-to-date: {filename}"); } else { - let comparison = StrComparison::new(¤t, &reference_string); + let comparison = generated_file_diff(¤t, &reference_string); bail!( "{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}" ); diff --git a/crates/ruff_dev/src/generate_ty_env_vars_reference.rs b/crates/ruff_dev/src/generate_ty_env_vars_reference.rs index 8b2127df66..a9a474fe6f 100644 --- a/crates/ruff_dev/src/generate_ty_env_vars_reference.rs +++ b/crates/ruff_dev/src/generate_ty_env_vars_reference.rs @@ -5,11 +5,10 @@ use std::fs; use std::path::PathBuf; use anyhow::bail; -use pretty_assertions::StrComparison; use ty_static::EnvVars; -use crate::generate_all::Mode; +use crate::generate_all::{Mode, generated_file_diff}; #[derive(clap::Args)] pub(crate) struct Args { @@ -39,7 +38,7 @@ pub(crate) fn main(args: &Args) -> anyhow::Result<()> { if current == reference_string { println!("Up-to-date: {filename}"); } else { - let comparison = StrComparison::new(¤t, &reference_string); + let comparison = generated_file_diff(¤t, &reference_string); bail!( "{filename} changed, please run `cargo dev generate-ty-env-vars-reference`:\n{comparison}" ); diff --git a/crates/ruff_dev/src/generate_ty_options.rs b/crates/ruff_dev/src/generate_ty_options.rs index 06d74319e2..f9954657c0 100644 --- a/crates/ruff_dev/src/generate_ty_options.rs +++ b/crates/ruff_dev/src/generate_ty_options.rs @@ -5,14 +5,13 @@ use std::{fmt::Write, path::PathBuf}; use anyhow::bail; use itertools::Itertools; -use pretty_assertions::StrComparison; use ruff_options_metadata::{OptionField, OptionSet, OptionsMetadata, Visit}; use ruff_python_trivia::textwrap; use ty_project::metadata::Options; use crate::{ ROOT_DIR, - generate_all::{Mode, REGENERATE_ALL_COMMAND}, + generate_all::{Mode, REGENERATE_ALL_COMMAND, generated_file_diff}, }; #[derive(clap::Args)] @@ -46,7 +45,7 @@ pub(crate) fn main(args: &Args) -> anyhow::Result<()> { if output == current { println!("Up-to-date: {file_name}"); } else { - let comparison = StrComparison::new(¤t, &output); + let comparison = generated_file_diff(¤t, &output); bail!("{file_name} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}"); } } diff --git a/crates/ruff_dev/src/generate_ty_rules.rs b/crates/ruff_dev/src/generate_ty_rules.rs index dba6aead87..cf46c087d8 100644 --- a/crates/ruff_dev/src/generate_ty_rules.rs +++ b/crates/ruff_dev/src/generate_ty_rules.rs @@ -7,11 +7,10 @@ use std::path::PathBuf; use anyhow::{Result, bail}; use itertools::Itertools as _; -use pretty_assertions::StrComparison; use regex::{Captures, Regex}; use crate::ROOT_DIR; -use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; +use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND, generated_file_diff}; #[derive(clap::Args)] pub(crate) struct Args { @@ -34,7 +33,7 @@ pub(crate) fn main(args: &Args) -> Result<()> { if current == markdown { println!("Up-to-date: {filename}"); } else { - let comparison = StrComparison::new(¤t, &markdown); + let comparison = generated_file_diff(¤t, &markdown); bail!("{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}"); } } @@ -105,6 +104,11 @@ fn generate_markdown() -> String { .join("\n"); let status_text = match lint.status() { + ty_python_semantic::lint::LintStatus::Preview { since } => { + format!( + r#"Preview (since {since})"# + ) + } ty_python_semantic::lint::LintStatus::Stable { since } => { format!( r#"Added in {since}"# diff --git a/crates/ruff_dev/src/generate_ty_schema.rs b/crates/ruff_dev/src/generate_ty_schema.rs index e819e91d10..fc13c6f159 100644 --- a/crates/ruff_dev/src/generate_ty_schema.rs +++ b/crates/ruff_dev/src/generate_ty_schema.rs @@ -2,11 +2,10 @@ use std::fs; use std::path::PathBuf; use anyhow::{Result, bail}; -use pretty_assertions::StrComparison; use schemars::generate::SchemaSettings; use crate::ROOT_DIR; -use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; +use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND, generated_file_diff}; use ty_project::metadata::options::Options; #[derive(clap::Args)] @@ -33,7 +32,7 @@ pub(crate) fn main(args: &Args) -> Result<()> { if current == schema_string { println!("Up-to-date: {filename}"); } else { - let comparison = StrComparison::new(¤t, &schema_string); + let comparison = generated_file_diff(¤t, &schema_string); bail!("{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}"); } } diff --git a/crates/ruff_diagnostics/Cargo.toml b/crates/ruff_diagnostics/Cargo.toml index 5d1e392155..02dd10690f 100644 --- a/crates/ruff_diagnostics/Cargo.toml +++ b/crates/ruff_diagnostics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_diagnostics" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_diagnostics/README.md b/crates/ruff_diagnostics/README.md index ecec0c72c0..f2ba09d572 100644 --- a/crates/ruff_diagnostics/README.md +++ b/crates/ruff_diagnostics/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_diagnostics). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_diagnostics). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_formatter/Cargo.toml b/crates/ruff_formatter/Cargo.toml index f6a9b7cce8..952c24cfa1 100644 --- a/crates/ruff_formatter/Cargo.toml +++ b/crates/ruff_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_formatter" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_formatter/README.md b/crates/ruff_formatter/README.md index f75009dcac..c463e4bcdb 100644 --- a/crates/ruff_formatter/README.md +++ b/crates/ruff_formatter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_formatter). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_graph/Cargo.toml b/crates/ruff_graph/Cargo.toml index aa1bcc33d0..48ed365946 100644 --- a/crates/ruff_graph/Cargo.toml +++ b/crates/ruff_graph/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_graph" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ruff_graph/README.md b/crates/ruff_graph/README.md index ff4feff470..82019b85ab 100644 --- a/crates/ruff_graph/README.md +++ b/crates/ruff_graph/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_graph). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_graph). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_index/Cargo.toml b/crates/ruff_index/Cargo.toml index 1f62f61968..5be2cca7d4 100644 --- a/crates/ruff_index/Cargo.toml +++ b/crates/ruff_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_index" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_index/README.md b/crates/ruff_index/README.md index faa6312359..89becad58a 100644 --- a/crates/ruff_index/README.md +++ b/crates/ruff_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_index). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index 94aebb3b7b..94e5a9234f 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.16.2" +version = "0.16.6" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } @@ -20,6 +20,7 @@ ruff_macros = { workspace = true } ruff_notebook = { workspace = true } ruff_python_ast = { workspace = true, features = ["serde", "cache"] } ruff_python_codegen = { workspace = true } +ruff_python_edits = { workspace = true } ruff_python_importer = { workspace = true } ruff_python_index = { workspace = true } ruff_python_literal = { workspace = true } @@ -40,7 +41,6 @@ compact_str = { workspace = true } fern = { workspace = true } glob = { workspace = true } globset = { workspace = true } -hashbrown = { workspace = true } imperative = { workspace = true } is-macro = { workspace = true } itertools = { workspace = true } diff --git a/crates/ruff_linter/README.md b/crates/ruff_linter/README.md index 124078abe7..9b75d04679 100644 --- a/crates/ruff_linter/README.md +++ b/crates/ruff_linter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.16.2) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_linter). +This version (0.16.6) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_linter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/resources/mdtest/configuration/rule-selector-precedence.md b/crates/ruff_linter/resources/mdtest/configuration/rule-selector-precedence.md new file mode 100644 index 0000000000..92d42deb8b --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/configuration/rule-selector-precedence.md @@ -0,0 +1,107 @@ +# Rule selector precedence + +Categories (e.g. `correctness`, `suspicious`), linter groups (e.g. `RUF`, `UP`), linter prefixes +(e.g. `RUF1`), and rules (e.g. `F401`, `unused-import`) can be combined in the same configuration. +In general, more specific selectors take precedence over broader selectors. Although all categories +aren't strictly "broader" than all linter groups, the general trend still applies. When selectors +have the same specificity, `ignore` takes precedence over `select`. In short, the current precedence +relationship is: + +```ignore +ALL < category < linter group < linter prefix < rule +``` + +## Categories and linter groups can be combined + +Select all `F` (`unused-import`) and `restriction` (`assert`) rules. + +```toml +[lint] +preview = true +select = ["F", "restriction"] +``` + +```py +import os # error: [unused-import] +assert True # error: [assert] +``` + +## Categories take precedence over `ALL` + +`unused-import` (`F401`) is a `suspicious` rule: + +```toml +[lint] +preview = true +select = ["suspicious"] +ignore = ["ALL"] +``` + +```py +import os # error: [unused-import] +``` + +## Linter group selection takes precedence over category ignores + +```toml +[lint] +preview = true +select = ["F"] +ignore = ["suspicious"] +``` + +```py +import os # error: [unused-import] +``` + +## Linter group ignores take precedence over category selectors + +```toml +[lint] +preview = true +select = ["suspicious"] +ignore = ["F"] +``` + +```py +import os +``` + +## Linter prefixes take precedence over categories + +```toml +[lint] +preview = true +select = ["F4"] +ignore = ["suspicious"] +``` + +```py +import os # error: [unused-import] +``` + +## Rule codes take precedence over categories + +```toml +[lint] +preview = true +select = ["F401"] +ignore = ["suspicious"] +``` + +```py +import os # error: [unused-import] +``` + +## Rule names take precedence over categories + +```toml +[lint] +preview = true +select = ["unused-import"] +ignore = ["suspicious"] +``` + +```py +import os # error: [unused-import] +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-async/blocking-http-call-in-async-function.md b/crates/ruff_linter/resources/mdtest/flake8-async/blocking-http-call-in-async-function.md new file mode 100644 index 0000000000..1cbdcbf01a --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-async/blocking-http-call-in-async-function.md @@ -0,0 +1,65 @@ +# `blocking-http-call-in-async-function` (`ASYNC210`) + +```toml +lint.select = ["ASYNC210"] +``` + +## Generic request functions + +The generic `requests.request` and `httpx.request` functions block just like the method-specific +helpers such as `get`. Imported aliases have the same behavior. + +```py +import httpx +import requests +from httpx import request as httpx_request +from requests import request as requests_request + +async def fetch(url): + requests.get(url) # error: [blocking-http-call-in-async-function] + requests.request("GET", url) # error: [blocking-http-call-in-async-function] + requests_request("GET", url) # error: [blocking-http-call-in-async-function] + httpx.get(url) # error: [blocking-http-call-in-async-function] + httpx.request("GET", url) # error: [blocking-http-call-in-async-function] + httpx_request("GET", url) # error: [blocking-http-call-in-async-function] +``` + +## Synchronous functions + +The rule only checks calls in async contexts. + +```py +import httpx +import requests + +def fetch(url): + requests.request("GET", url) + httpx.request("GET", url) +``` + +## Shadowed module names + +Parameters named `requests` and `httpx` do not refer to the imported libraries. + +```py +import httpx +import requests + +async def custom_client(requests, httpx, url): + requests.request("GET", url) + httpx.request("GET", url) +``` + +## Requests dispatched to a worker thread + +Passing the request functions to `asyncio.to_thread` does not block the event loop. + +```py +import asyncio +import httpx +import requests + +async def fetch_in_thread(url): + await asyncio.to_thread(requests.request, "GET", url) + await asyncio.to_thread(httpx.request, "GET", url) +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-async/blocking-open-call-in-async-function.md b/crates/ruff_linter/resources/mdtest/flake8-async/blocking-open-call-in-async-function.md new file mode 100644 index 0000000000..fc4256a926 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-async/blocking-open-call-in-async-function.md @@ -0,0 +1,42 @@ +# `blocking-open-call-in-async-function` (`ASYNC230`) + +```toml +lint.select = ["ASYNC230"] +``` + +## Builtin imports + +Opening a file blocks an async function whether `open` is referenced directly, through `builtins`, +or through an imported alias. `io.open` is also a blocking call. + +```py +import builtins +import io +from builtins import open as builtin_open + +async def read_file(): + open("data.txt") # error: [blocking-open-call-in-async-function] + builtins.open("data.txt") # error: [blocking-open-call-in-async-function] + builtin_open("data.txt") # error: [blocking-open-call-in-async-function] + io.open("data.txt") # error: [blocking-open-call-in-async-function] +``` + +## Synchronous functions + +The rule only checks calls in async contexts. + +```py +import builtins + +def read_file(): + builtins.open("data.txt") +``` + +## Shadowed names + +A parameter named `open` does not refer to the builtin. + +```py +async def custom_open(open): + open("data.txt") +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-bandit/rules.md b/crates/ruff_linter/resources/mdtest/flake8-bandit/rules.md new file mode 100644 index 0000000000..eeb02b6f45 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-bandit/rules.md @@ -0,0 +1,77 @@ +# `flake8-bandit` + +Regression tests for . Keyword command arguments are +checked, as well as positional arguments. `*args` is also treated as untrusted. + +## `subprocess-popen-with-shell-equals-true` (`S602`) + +```toml +[lint] +select = ["S602"] +``` + +```py +from subprocess import Popen, call, check_call, check_output, run + +Popen(args="true", shell=True) # error: [subprocess-popen-with-shell-equals-true] +call(args="true", shell=True) # error: [subprocess-popen-with-shell-equals-true] +check_call(args="true", shell=True) # error: [subprocess-popen-with-shell-equals-true] +check_output(args="true", shell=True) # error: [subprocess-popen-with-shell-equals-true] +run(args="true", shell=True) # error: [subprocess-popen-with-shell-equals-true] + +var_string = "true" +Popen(args=var_string, shell=True) # error: [subprocess-popen-with-shell-equals-true] + +cmd = input() +Popen(*cmd, shell=True) # error: [subprocess-popen-with-shell-equals-true] +``` + +## `subprocess-without-shell-equals-true` (`S603`) + +```toml +[lint] +select = ["S603"] +``` + +```py +from subprocess import Popen, call, check_call, check_output, run + +a = input() + +Popen(args=a, shell=False) # error: [subprocess-without-shell-equals-true] +call(args=a, shell=False) # error: [subprocess-without-shell-equals-true] +check_call(args=a, shell=False) # error: [subprocess-without-shell-equals-true] +check_output(args=a, shell=False) # error: [subprocess-without-shell-equals-true] +run(args=a, shell=False) # error: [subprocess-without-shell-equals-true] +check_output(args=[a], shell=False) # error: [subprocess-without-shell-equals-true] +run(*a) # error: [subprocess-without-shell-equals-true] +run(args=["true"]) +``` + +## `start-process-with-partial-path` (`S607`) + +```toml +[lint] +select = ["S607"] +``` + +```py +import os +import subprocess + +os.spawnv(mode=os.P_WAIT, file="/bin/ls", args=["ls"]) +subprocess.run(args="git status") # error: [start-process-with-partial-path] +``` + +## `unix-command-wildcard-injection` (`S609`) + +```toml +[lint] +select = ["S609"] +``` + +```py +import subprocess + +subprocess.Popen(args="chmod -R 777 *", shell=True) # error: [unix-command-wildcard-injection] +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-datetimez/call-datetime-strptime-without-zone.md b/crates/ruff_linter/resources/mdtest/flake8-datetimez/call-datetime-strptime-without-zone.md new file mode 100644 index 0000000000..ac951544de --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-datetimez/call-datetime-strptime-without-zone.md @@ -0,0 +1,39 @@ +# `call-datetime-strptime-without-zone` (`DTZ007`) + +```toml +lint.select = ["DTZ007"] +``` + +## Replacing fields before converting the timezone + +Calling `astimezone` produces an aware datetime even when one or more `replace` calls precede it. + +```py +from datetime import datetime, timezone + +datetime.strptime("2026", "%Y").replace(microsecond=0).astimezone() +datetime.strptime("2026", "%Y").replace(hour=12).replace(minute=30).astimezone(timezone.utc) +datetime.strptime("2026", "%Y").replace(tzinfo=None).astimezone() +``` + +## Replacements that leave a naive datetime + +A replacement without a timezone conversion still produces a naive datetime. + +```py +from datetime import datetime + +datetime.strptime("2026", "%Y").replace(microsecond=0) # error: [call-datetime-strptime-without-zone] +datetime.strptime("2026", "%Y").replace(tzinfo=None) # error: [call-datetime-strptime-without-zone] +``` + +## Passing a method to another call + +Passing the `replace` method to another object's method does not convert the parsed datetime. + +```py +from datetime import datetime + +def convert(converter): + converter.replace(datetime.strptime("2026", "%Y").replace).astimezone() # error: [call-datetime-strptime-without-zone] +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-datetimez/datetime-min-max.md b/crates/ruff_linter/resources/mdtest/flake8-datetimez/datetime-min-max.md new file mode 100644 index 0000000000..74395e4200 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-datetimez/datetime-min-max.md @@ -0,0 +1,27 @@ +# `datetime-min-max` (`DTZ901`) + +```toml +lint.select = ["DTZ901"] +``` + +## Replacing the timezone with None + +Passing `tzinfo=None` to `replace` leaves `datetime.min` and `datetime.max` naive. + +```py +from datetime import datetime + +datetime.min.replace(tzinfo=None) # error: [datetime-min-max] +datetime.max.replace(tzinfo=None) # error: [datetime-min-max] +``` + +## Replacing the timezone with a timezone object + +Passing a timezone object produces an aware datetime. + +```py +from datetime import datetime, timezone + +datetime.min.replace(tzinfo=timezone.utc) +datetime.max.replace(tzinfo=timezone.utc) +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md new file mode 100644 index 0000000000..6214a05b8d --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md @@ -0,0 +1,60 @@ +# `lazy-import-immediately-resolved` (`TID255`) + +```toml +target-version = "py315" + +[lint] +preview = true +select = ["TID254", "TID255"] +flake8-tidy-imports.require-lazy = "all" +``` + +## Required lazy imports + +`TID255` ignores imports that are required to be lazy to avoid a conflict with `TID254`, even if the +import is resolved immediately. + +```py +import foo # snapshot: lazy-import-mismatch + +class Bar(foo.Base): ... +``` + +```snapshot +error[TID254]: Use a `lazy` import instead of an eager import + --> src/mdtest_snippet.py:1:8 + | +1 | import foo # snapshot: lazy-import-mismatch + | ^^^ +help: Convert to a lazy import + | + - import foo # snapshot: lazy-import-mismatch +1 + lazy import foo # snapshot: lazy-import-mismatch +2 | + | +note: This is an unsafe fix and may change runtime behavior +``` + +## Partially required lazy imports + +`TID255` still reports immediately resolved names outside `require-lazy`, even when another name in +the same import is required to be lazy. + +```toml +target-version = "py315" + +[lint] +preview = true +select = ["TID254", "TID255"] +flake8-tidy-imports.require-lazy = ["foo", "pkg.Base"] +``` + +```py +lazy import foo as required, bar +lazy from pkg import Base as RequiredBase, OtherBase + +required.value +RequiredBase() +bar.value # error: [lazy-import-immediately-resolved] +OtherBase() # error: [lazy-import-immediately-resolved] +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-use-pathlib/os-stat.md b/crates/ruff_linter/resources/mdtest/flake8-use-pathlib/os-stat.md new file mode 100644 index 0000000000..2c897d48ef --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-use-pathlib/os-stat.md @@ -0,0 +1,172 @@ +# `os-stat` (`PTH116`) + +## Python 3.9 + +```toml +preview = true +target-version = "py39" +lint.select = ["PTH116"] +``` + +`Path.stat` doesn't support the `follow_symlinks` keyword argument before 3.10, so the suggested +fixes have to use either `stat` or `lstat` depending on its value, when it's present. + +### `follow_symlinks=True` uses `stat` + +```py +import os + +os.stat("foo", follow_symlinks=True) # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:3:1 + | +3 | os.stat("foo", follow_symlinks=True) # snapshot: os-stat + | ^^^^^^^ +help: Replace with `Path(...).stat()` + | +1 | import os +2 + import pathlib +3 | + - os.stat("foo", follow_symlinks=True) # snapshot: os-stat +4 + pathlib.Path("foo").stat() # snapshot: os-stat + | +note: This is an unsafe fix and may change runtime behavior +``` + +### No `follow_symlinks` also uses `stat` + +The default value is `True`, as above: + +```py +import os + +os.stat("foo") # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:3:1 + | +3 | os.stat("foo") # snapshot: os-stat + | ^^^^^^^ +help: Replace with `Path(...).stat()` + | +1 | import os +2 + import pathlib +3 | + - os.stat("foo") # snapshot: os-stat +4 + pathlib.Path("foo").stat() # snapshot: os-stat + | +note: This is an unsafe fix and may change runtime behavior +``` + +### `follow_symlinks=False` uses `lstat` + +```py +import os + +os.stat("foo", follow_symlinks=False) # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:3:1 + | +3 | os.stat("foo", follow_symlinks=False) # snapshot: os-stat + | ^^^^^^^ +help: Replace with `Path(...).lstat()` + | +1 | import os +2 + import pathlib +3 | + - os.stat("foo", follow_symlinks=False) # snapshot: os-stat +4 + pathlib.Path("foo").lstat() # snapshot: os-stat + | +note: This is an unsafe fix and may change runtime behavior +``` + +### Dynamic `follow_symlinks` suppresses the fix + +If we can't resolve the value of `follow_symlinks`, we still emit a diagnostic but can't reliably +suggest one of the `stat` methods in a fix. + +```py +import os + +follow = True + +os.stat("foo", follow_symlinks=follow) # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:5:1 + | +5 | os.stat("foo", follow_symlinks=follow) # snapshot: os-stat + | ^^^^^^^ +``` + +## Python 3.10+ + +```toml +preview = true +target-version = "py310" +lint.select = ["PTH116"] +``` + +After 3.10, the fixes can always use `stat` and pass along the `follow_symlinks` argument. + +```py +import os + +os.stat("foo", follow_symlinks=False) # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:3:1 + | +3 | os.stat("foo", follow_symlinks=False) # snapshot: os-stat + | ^^^^^^^ +help: Replace with `Path(...).stat()` + | +1 | import os +2 + import pathlib +3 | + - os.stat("foo", follow_symlinks=False) # snapshot: os-stat +4 + pathlib.Path("foo").stat(follow_symlinks=False) # snapshot: os-stat +5 | follow = True + | +note: This is an unsafe fix and may change runtime behavior +``` + +This is also the case for dynamic values: + +```py +follow = True + +os.stat("foo", follow_symlinks=follow) # snapshot: os-stat +``` + +```snapshot +error[PTH116]: `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` + --> src/mdtest_snippet.py:6:1 + | +6 | os.stat("foo", follow_symlinks=follow) # snapshot: os-stat + | ^^^^^^^ +help: Replace with `Path(...).stat()` + | +1 | import os +2 + import pathlib +3 | +4 | os.stat("foo", follow_symlinks=False) # snapshot: os-stat +5 | follow = True +6 | + - os.stat("foo", follow_symlinks=follow) # snapshot: os-stat +7 + pathlib.Path("foo").stat(follow_symlinks=follow) # snapshot: os-stat + | +note: This is an unsafe fix and may change runtime behavior +``` diff --git a/crates/ruff_linter/resources/mdtest/pyflakes/forward-annotation-syntax-error.md b/crates/ruff_linter/resources/mdtest/pyflakes/forward-annotation-syntax-error.md new file mode 100644 index 0000000000..7c325f1477 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyflakes/forward-annotation-syntax-error.md @@ -0,0 +1,51 @@ +# `forward-annotation-syntax-error` (`F722`) + +```toml +target-version = "py312" + +[lint] +select = ["F722"] +``` + +## Parse errors + +Quoted annotations must parse as Python expressions. + +```py +# error: [forward-annotation-syntax-error] "Expected an expression" +invalid: "/" +``` + +## Semantic syntax errors + +An expression can parse successfully but still contain a semantic syntax error. + +```py +# error: [forward-annotation-syntax-error] "Duplicate parameter" +invalid: "(lambda x, x: 0)" +``` + +## Semantic syntax errors currently mapped to disabled lint rules + +`F722` reports semantic syntax errors even when their overlapping lint rules are disabled, in this +case `yield-outside-function` (`F704`). + +```py +# error: [forward-annotation-syntax-error] "`yield` statement outside of a function" +invalid: "(yield 1)" +``` + +## Semantic syntax errors currently mapped to enabled lint rules + +Disabling `F722` suppresses the semantic syntax error even when `F704` remains enabled. + +```toml +target-version = "py312" + +[lint] +select = ["F704"] +``` + +```py +invalid: "(yield 1)" +``` diff --git a/crates/ruff_linter/resources/mdtest/pylint/unspecified-encoding.md b/crates/ruff_linter/resources/mdtest/pylint/unspecified-encoding.md new file mode 100644 index 0000000000..40d7f94ee3 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pylint/unspecified-encoding.md @@ -0,0 +1,48 @@ +# `unspecified-encoding` (`PLW1514`) + +```toml +preview = true +lint.select = ["PLW1514"] +``` + +## Builtin imports + +Text files need an explicit encoding even when `open` is imported from `builtins`. + +```py +import builtins +from builtins import open as builtin_open + +builtins.open("data.txt") # snapshot: unspecified-encoding +builtin_open("data.txt") # error: [unspecified-encoding] +``` + +```snapshot +error[PLW1514]: `builtins.open` in text mode without explicit `encoding` argument + --> src/mdtest_snippet.py:4:1 + | +4 | builtins.open("data.txt") # snapshot: unspecified-encoding + | ^^^^^^^^^^^^^ +help: Add explicit `encoding` argument + | +3 | + - builtins.open("data.txt") # snapshot: unspecified-encoding +4 + builtins.open("data.txt", encoding="utf-8") # snapshot: unspecified-encoding +5 | builtin_open("data.txt") # error: [unspecified-encoding] + | +note: This is an unsafe fix and may change runtime behavior +``` + +## Explicit encodings and binary mode + +An explicit encoding or binary mode makes the call valid, whether passed by position or by keyword. + +```py +import builtins +from builtins import open as builtin_open + +builtins.open("data.txt", encoding="utf-8") +builtin_open("data.txt", "r", -1, "utf-8") +builtins.open("data.bin", "rb") +builtin_open("data.bin", mode="wb") +``` diff --git a/crates/ruff_linter/resources/mdtest/pyupgrade/while-one.md b/crates/ruff_linter/resources/mdtest/pyupgrade/while-one.md new file mode 100644 index 0000000000..a5555711fb --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyupgrade/while-one.md @@ -0,0 +1,126 @@ +# `while-one` (`UP048`) + +```toml +lint.preview = true +lint.select = ["UP048"] +``` + +## Basic replacement + +`while 1:` is a Python 2 idiom for an infinite loop, from when `True` was a rebindable global rather +than a keyword. + +```py +while 1: # snapshot: while-one + print("Hello, world!") +``` + +```snapshot +error[UP048]: Use `while True:` instead of `while 1:` + --> src/mdtest_snippet.py:1:7 + | +1 | while 1: # snapshot: while-one + | ^ +help: Replace with `True` + | + - while 1: # snapshot: while-one +1 + while True: # snapshot: while-one +2 | print("Hello, world!") + | +``` + +## Other spellings of one + +Any integer literal equal to one is flagged, whatever its base, and each is fixed to `True`. + +```py +while 0x1: # snapshot: while-one + ... + +while 0b1: # error: [while-one] + ... + +while 0o1: # error: [while-one] + ... + +while 1_0: # ten, not one, so this is left alone + ... +``` + +```snapshot +error[UP048]: Use `while True:` instead of `while 1:` + --> src/mdtest_snippet.py:1:7 + | +1 | while 0x1: # snapshot: while-one + | ^^^ +help: Replace with `True` + | + - while 0x1: # snapshot: while-one +1 + while True: # snapshot: while-one +2 | ... + | +``` + +## Parentheses and comments are preserved + +Only the literal itself is rewritten, so surrounding trivia survives the fix. + +```py +while ( + # keep me + 1 # snapshot: while-one +): + ... +``` + +```snapshot +error[UP048]: Use `while True:` instead of `while 1:` + --> src/mdtest_snippet.py:3:5 + | +3 | 1 # snapshot: while-one + | ^ +help: Replace with `True` + | +2 | # keep me + - 1 # snapshot: while-one +3 + True # snapshot: while-one +4 | ): + | +``` + +## Other conditions are left alone + +`while 0:` is unreachable rather than infinite, and rewriting it would change behavior. Non-literal +conditions are out of scope even when they are always truthy, because flagging them would collide +with rules that catch accidentally-constant conditions. + +```py +while 0: + ... + +while True: + ... + +while 1.0: + ... + +while "always": + ... + +while [1]: + ... + +while 2: + ... + +while -1: + ... +``` + +The rule targets the loop condition only, not integer literals elsewhere in a `while` statement. + +```py +x = 1 +while x == 1: + x = 1 +``` diff --git a/crates/ruff_linter/resources/mdtest/ruff/pytest-fixture-autouse.md b/crates/ruff_linter/resources/mdtest/ruff/pytest-fixture-autouse.md index bc601585bd..704eb446a2 100644 --- a/crates/ruff_linter/resources/mdtest/ruff/pytest-fixture-autouse.md +++ b/crates/ruff_linter/resources/mdtest/ruff/pytest-fixture-autouse.md @@ -1,8 +1,8 @@ -# `pytest-fixture-autouse` (`RUF076`) +# `pytest-fixture-autouse` ```toml lint.preview = true -# lint.select = ["RUF076"] +lint.select = ["pytest-fixture-autouse"] ``` ## Basic errors @@ -11,12 +11,12 @@ lint.preview = true import pytest -@pytest.fixture(autouse=True) # TODO: snapshot: pytest-fixture-autouse +@pytest.fixture(autouse=True) # error: [pytest-fixture-autouse] def my_autouse_fixture(): pass -@pytest.fixture(scope="module", autouse=True) # TODO: error: [pytest-fixture-autouse] +@pytest.fixture(scope="module", autouse=True) # error: [pytest-fixture-autouse] def my_scoped_autouse_fixture(): pass ``` @@ -46,3 +46,21 @@ def decorator_no_arguments(): def not_a_fixture(autouse=True): pass ``` + +## Inline suppressions + +A rule without a legacy code can be suppressed by name or by a blanket `noqa` comment. + +```py +import pytest + + +@pytest.fixture(autouse=True) # ruff: ignore[pytest-fixture-autouse] +def ignored_by_name(): + pass + + +@pytest.fixture(autouse=True) # noqa +def ignored_by_blanket_noqa(): + pass +``` diff --git a/crates/ruff_linter/resources/mdtest/suppression/ignore.md b/crates/ruff_linter/resources/mdtest/suppression/ignore.md index dca3a68ddf..f0b6b0504e 100644 --- a/crates/ruff_linter/resources/mdtest/suppression/ignore.md +++ b/crates/ruff_linter/resources/mdtest/suppression/ignore.md @@ -368,7 +368,6 @@ error[RUF102]: Invalid rule code in suppression: unknown-rule, unused-import 9 | import sys 10 | # ruff:enable[unused-import, unknown-rule] | ------------------------------------------ -help: Add non-Ruff rule codes to the `lint.external` configuration option help: Enable `lint.preview` to use rule names help: Remove the suppression comment | @@ -481,7 +480,6 @@ error[RUF102]: Invalid rule code in suppression: not-a-rule | 2 | # ruff:ignore[unused-import, not-a-rule] | ^^^^^^^^^^ -help: Add non-Ruff rule codes to the `lint.external` configuration option help: Remove the rule code `not-a-rule` | 1 | # snapshot: invalid-rule-code @@ -836,7 +834,6 @@ error[RUF102]: Invalid rule code in suppression: XYZ | 3 | # ruff:ignore[XYZ] # ruff:file-ignore[F821] | ^^^ -help: Add non-Ruff rule codes to the `lint.external` configuration option help: Remove the suppression comment | 2 | # error: [invalid-suppression-comment] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B031.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B031.py index dfdafc6116..e268bb4758 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B031.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B031.py @@ -225,6 +225,35 @@ def foo(): collect_shop_items(shopper, section_items) collect_shop_items(shopper, section_items) # B031 +# https://github.com/astral-sh/ruff/issues/26624 +# Usage of the group inside a `match` subject shouldn't panic. +for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): + match list(section_items): + case []: + collect_shop_items(shopper, []) + case items_list: + collect_shop_items(shopper, items_list) + +for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): + match list(section_items): + case []: + collect_shop_items(shopper, section_items) # B031 + case _: + collect_shop_items(shopper, section_items) # B031 + +for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): + match (list(section_items), list(section_items)): # B031 + case _: + pass + +# The `if` test is evaluated unconditionally, so using the group there and +# again in one of the (mutually exclusive) branches is still a reuse. +for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): + if len(list(section_items)) > 1: + pass + else: + collect_shop_items(shopper, section_items) # B031 + # Let's redefine the `groupby` function to make sure we pick up the correct one. # NOTE: This should always be at the end of the file. def groupby(data, key=None): diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC003_docstring.py b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC003_docstring.py new file mode 100644 index 0000000000..d47c4d5c28 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC003_docstring.py @@ -0,0 +1,64 @@ +# Regression tests for https://github.com/astral-sh/ruff/issues/27979. + +# Module docstring position: fix is unsafe. +( + "docstring" + + "?" +) + +# Not a docstring position: not the first statement in the module body. +x = 1 +( + "not" + + " a docstring" +) + + +def function_docstring(): + # Function docstring position: fix is unsafe. + ( + "docstring" + + "?" + ) + return __doc__ + + +class ClassDocstring: + # Class docstring position: fix is unsafe. + ( + "docstring" + + "?" + ) + + def method_docstring(self): + # Method docstring position: fix is unsafe. + ( + "docstring" + + "?" + ) + return self.__doc__ + + +def f_string_in_docstring_position(): + # F-strings cannot be docstrings: fix is safe. + ( + f"not" + + " a docstring" + ) + + +def bytes_in_docstring_position(): + # Byte strings cannot be docstrings: fix is safe. + ( + b"not" + + b" a docstring" + ) + + +def nested_in_expression(): + # Not a docstring position: the concatenation is nested inside an + # expression. + print( + "not" + + " a docstring" + ) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT017.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT017.py index 307fe2a20c..99c80549de 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT017.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT017.py @@ -17,3 +17,10 @@ def test_error(): something() except Exception as e: assert e.message, "blah blah" + + +def test_error_with_multiple_exception_references(): + try: + something() + except Exception as e: + assert len(e.args) == 1, e.args diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020.py index c21abf6088..d648d8aa23 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020.py @@ -19,3 +19,28 @@ def error_without_parens(): @pytest.yield_fixture def error_with_parens(): return 0 + + +@pytest.yield_fixture(scope="module", name="my_fixture") +def error_with_arguments(): + return 0 + + +@pytest.yield_fixture() # comment +def error_with_comment(): + return 0 + + +class TestClass: + @pytest.yield_fixture() + def error_in_class(self): + return 0 + + +@( + pytest + # comment + .yield_fixture +) +def error_with_comment_in_reference(): + return 0 diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_1.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_1.py new file mode 100644 index 0000000000..17436d286c --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_1.py @@ -0,0 +1,12 @@ +from pytest import yield_fixture +from pytest import yield_fixture as aliased + + +@yield_fixture() +def error_member_import(): + return 0 + + +@aliased() +def error_aliased_member_import(): + return 0 diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_2.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_2.py new file mode 100644 index 0000000000..285f09d9fc --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT020_2.py @@ -0,0 +1,6 @@ +import pytest as other_name + + +@other_name.yield_fixture() +def error_aliased_module(): + return 0 diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_self/SLF001.py b/crates/ruff_linter/resources/test/fixtures/flake8_self/SLF001.py index 96e178cac2..389dd927da 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_self/SLF001.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_self/SLF001.py @@ -78,6 +78,10 @@ def __eq__(self, other): os._exit() +import os as operating_system + +operating_system._exit(1) + from enum import Enum diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM401.py b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM401.py index 0bfa2499a1..b7413bb47f 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM401.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM401.py @@ -153,3 +153,31 @@ def __iter__(self): # OK (default contains effect) var = a_dict[key] if key in a_dict else val1 + val2 + +### +# Lambda parameter defaults +### + +# SIM401: literal defaults have no side effects. +if key in a_dict: + var = a_dict[key] +else: + var = lambda x=0, /, y=1, *, z=2: (x, y, z) + +# OK: dict.get would evaluate the lambda's default even when the key exists. +if key in a_dict: + var = a_dict[key] +else: + var = lambda value=initialize(): value + +# OK: positional-only defaults are also evaluated when the lambda is created. +if key in a_dict: + var = a_dict[key] +else: + var = lambda value=initialize(), /: value + +# OK: keyword-only defaults can contain nested side effects. +if key in a_dict: + var = a_dict[key] +else: + var = lambda *, value=(initialize(),): value diff --git a/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_merged_pragma.py b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_merged_pragma.py new file mode 100644 index 0000000000..769e7cc701 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_merged_pragma.py @@ -0,0 +1,17 @@ +# Separate statement-level and alias-level comments that isort merges onto one line +# when collapsing. The merged comment token is 89 columns with the `# explain` prefix +# counted, so the import must not end up on an overlong single line. +from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import ( # explain + x # noqa: TID251 +) + +# A single mixed comment on an already-collapsed 102-column line. The code plus the +# non-pragma prefix is 86 columns. +from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # explain # noqa: TID251 + +# Separate comments with the pragma first: when isort collapses this import in preview, +# the merged comment token is pragma-prefixed, so E501 strips it entirely and the +# collapsed 104-column line is fine. +from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import ( # noqa: TID251 + x # explain +) diff --git a/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_mixed_pragma.py b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_mixed_pragma.py new file mode 100644 index 0000000000..d2b0744707 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_mixed_pragma.py @@ -0,0 +1,6 @@ +# The next import fits on one line once the trailing pragma is excluded from the width +# (the `# keep this` prefix still counts); in preview it should not be wrapped. +from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251 +# The next import exceeds the line length even without the trailing pragma +# (code plus the `# keep this` prefix is 89 columns); it must always be wrapped. +from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251 diff --git a/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_pragma.py b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_pragma.py new file mode 100644 index 0000000000..341bad469d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/fit_line_length_pragma.py @@ -0,0 +1,13 @@ +# The next import fits on one line once the pragma comment is excluded from the width; +# in preview it should not be wrapped (the `# noqa` must stay effective). +from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251 +# The next import exceeds the line length even without the pragma comment; +# it must still be wrapped. +from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251 + + +def f(): + # The next import fits on one line once the pragma comment is excluded from the + # width, so in preview it should not be wrapped. + from cccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccccccccc import bar # noqa: PLC0415 + bar() diff --git a/crates/ruff_linter/resources/test/fixtures/pylint/import_private_name/submodule/__main__.py b/crates/ruff_linter/resources/test/fixtures/pylint/import_private_name/submodule/__main__.py index 17e5cc7d3d..c26c5376aa 100644 --- a/crates/ruff_linter/resources/test/fixtures/pylint/import_private_name/submodule/__main__.py +++ b/crates/ruff_linter/resources/test/fixtures/pylint/import_private_name/submodule/__main__.py @@ -50,3 +50,10 @@ def generic[T: _nn](arg: T) -> T: return arg from foo. _bar import baz + +# PLC2701 exceptions: `os._exit` is considered public despite leading underscore. +from os import _exit +from os import _exit as process_exit +from another_module import _exit as another_exit +from os import _private_member +from os import _exit as os_exit, _other_private_member diff --git a/crates/ruff_linter/resources/test/fixtures/pylint/repeated_keyword_argument.py b/crates/ruff_linter/resources/test/fixtures/pylint/repeated_keyword_argument.py index b7bb0d7e54..9c97918bfb 100644 --- a/crates/ruff_linter/resources/test/fixtures/pylint/repeated_keyword_argument.py +++ b/crates/ruff_linter/resources/test/fixtures/pylint/repeated_keyword_argument.py @@ -18,3 +18,7 @@ def func(a=10, b=20, c=30): func(a=11, b=21, c=31, **{"b": 22, "c": 41, "a": 51}) func(a=11, b=21, **{"c": 31}, **{"c": 32}) func(a=11, b=21, **{"c": 31, "c": 32}) +func(**{"a": 11}, a=21) + +# Duplicate explicit keywords are syntax errors, not PLE1132 diagnostics. +func(a=11, a=21) diff --git a/crates/ruff_linter/resources/test/fixtures/refurb/FURB101_0.py b/crates/ruff_linter/resources/test/fixtures/refurb/FURB101_0.py index 9d971d2c77..7e8920ea13 100644 --- a/crates/ruff_linter/resources/test/fixtures/refurb/FURB101_0.py +++ b/crates/ruff_linter/resources/test/fixtures/refurb/FURB101_0.py @@ -141,3 +141,7 @@ def bar(x): with open("file1.txt", encoding="utf-8") as f: contents: str = process_contents(f.read()) + +# `open` accepts a file descriptor, but `Path` does not +with open(3) as f: + x = f.read() diff --git a/crates/ruff_linter/resources/test/fixtures/refurb/FURB103_0.py b/crates/ruff_linter/resources/test/fixtures/refurb/FURB103_0.py index c6a4196fe3..782dffacba 100644 --- a/crates/ruff_linter/resources/test/fixtures/refurb/FURB103_0.py +++ b/crates/ruff_linter/resources/test/fixtures/refurb/FURB103_0.py @@ -162,3 +162,8 @@ def bar(x): other = 1.234 """, )) + + +# `open` accepts a file descriptor, but `Path` does not +with open(3, "w") as f: + f.write("test") diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py index e5ec4d610b..af37af2333 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py @@ -150,3 +150,42 @@ class S(ctypes.Structure): ("propagation", ctypes.c_uint64), ("userns_fd", ctypes.c_uint64), ] + +class LES(ctypes.LittleEndianStructure): + test = [""] + _fields_ = [ + ("attr_set", ctypes.c_uint64), + ("attr_clr", ctypes.c_uint64), + ("propagation", ctypes.c_uint64), + ("userns_fd", ctypes.c_uint64), + ] + +class BES(ctypes.BigEndianStructure): + test = [""] + _fields_ = [ + ("attr_set", ctypes.c_uint64), + ("attr_clr", ctypes.c_uint64), + ("propagation", ctypes.c_uint64), + ("userns_fd", ctypes.c_uint64), + ] + +class U(ctypes.Union): + test = [""] + _fields_ = [ + ("a", LES), + ("b", BES), + ] + +class LEU(ctypes.LittleEndianUnion): + test = [""] + _fields_ = [ + ("a", LES), + ("b", BES), + ] + +class BEU(ctypes.BigEndianUnion): + test = [""] + _fields_ = [ + ("a", LES), + ("b", BES), + ] diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF046.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF046.py index 6fd21d779b..695528c494 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF046.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF046.py @@ -168,6 +168,10 @@ async def f(): int(round (1)) +# Attribute access within the callee can also rely on the outer parentheses. +int(math +.floor(1.5)) + int(round # a comment # and another comment (10) @@ -203,6 +207,15 @@ async def f(): int( round( 42 - ) + ) # unsafe fix because of this comment ) + +# Integer attribute access still requires parentheses. +int(1).real + +# Parentheses separate the replacement from adjacent keywords. +int(1)and True + +def parenthesized_callee(): + return(int)(1) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF057.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF057.py index bb43b6d1d4..731ee77097 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF057.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF057.py @@ -84,4 +84,21 @@ # See: https://github.com/astral-sh/ruff/issues/21209 print(round(125, **{"ndigits": -2})) -print(round(125, *[-2])) \ No newline at end of file +print(round(125, *[-2])) + +# Assignment expressions remain parenthesized after the call is removed. +round(value := 1) + +# Integer attribute access still requires parentheses. +round(1).real + +# Parentheses separate the replacement from adjacent keywords. +round(1)and True + +def parenthesized_callee(): + return(round)(1) + +# Preserve comments within explicit argument parentheses. +round(( # Keep the argument comment. + 1 +)) diff --git a/crates/ruff_linter/resources/test/fixtures/semantic_errors/nonlocal_parameter.py b/crates/ruff_linter/resources/test/fixtures/semantic_errors/nonlocal_parameter.py new file mode 100644 index 0000000000..b83f9ee608 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/semantic_errors/nonlocal_parameter.py @@ -0,0 +1,33 @@ +def f(a): + nonlocal a + +def g(a): + if True: + nonlocal a + +def h(a): + def inner(): + nonlocal a + +def i(a): + try: + nonlocal a + except Exception: + pass + +def f(a): + a = 1 + a = 2 + nonlocal a + +def f(a): + class Inner: + nonlocal a # ok + +def f(a): + def inner(a): + nonlocal a + +def f(a=1): + def inner(): + nonlocal a # ok \ No newline at end of file diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 0a34f232d9..308b647b39 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1127,7 +1127,6 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { flake8_simplify::rules::zip_dict_keys_and_values(checker, call); } if checker.any_rule_enabled(&[ - Rule::OsStat, Rule::OsPathJoin, Rule::OsPathSplitext, Rule::PyPath, @@ -1210,6 +1209,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.is_rule_enabled(Rule::OsMakedirs) { flake8_use_pathlib::rules::os_makedirs(checker, call, segments); } + if checker.is_rule_enabled(Rule::OsStat) { + flake8_use_pathlib::rules::os_stat(checker, call, segments); + } if checker.is_rule_enabled(Rule::OsSymlink) { flake8_use_pathlib::rules::os_symlink(checker, call, segments); } diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 59509ba3cc..c878237ec4 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -1278,6 +1278,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.is_rule_enabled(Rule::NeedlessElse) { ruff::rules::needless_else(checker, while_stmt.into()); } + if checker.is_rule_enabled(Rule::WhileOne) { + pyupgrade::rules::while_one(checker, while_stmt); + } } Stmt::For( for_stmt @ ast::StmtFor { diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index ebcaf04679..40a469e076 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -720,6 +720,19 @@ impl SemanticSyntaxContext for Checker<'_> { } fn report_semantic_error(&self, error: SemanticSyntaxError) { + // F722 + if self.semantic.in_string_type_definition() { + if self.is_rule_enabled(Rule::ForwardAnnotationSyntaxError) { + self.report_type_diagnostic( + pyflakes::rules::ForwardAnnotationSyntaxError { + parse_error: error.to_string(), + }, + error.range, + ); + } + return; + } + match error.kind { SemanticSyntaxErrorKind::LateFutureImport => { // F404 @@ -825,11 +838,13 @@ impl SemanticSyntaxContext for Checker<'_> { | SemanticSyntaxErrorKind::DifferentMatchPatternBindings | SemanticSyntaxErrorKind::InvalidExpression(..) | SemanticSyntaxErrorKind::GlobalParameter(_) + | SemanticSyntaxErrorKind::NonlocalParameter(_) | SemanticSyntaxErrorKind::DuplicateMatchKey(_) | SemanticSyntaxErrorKind::DuplicateMatchClassAttribute(_) | SemanticSyntaxErrorKind::InvalidStarExpression | SemanticSyntaxErrorKind::AsyncComprehensionInSyncComprehension(_) | SemanticSyntaxErrorKind::DuplicateParameter(_) + | SemanticSyntaxErrorKind::DuplicateKeywordArgument(_) | SemanticSyntaxErrorKind::NonlocalDeclarationAtModuleLevel | SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration { .. } | SemanticSyntaxErrorKind::NonlocalAndGlobal(_) diff --git a/crates/ruff_linter/src/checkers/noqa.rs b/crates/ruff_linter/src/checkers/noqa.rs index 8de18382a7..b3de9859a9 100644 --- a/crates/ruff_linter/src/checkers/noqa.rs +++ b/crates/ruff_linter/src/checkers/noqa.rs @@ -57,17 +57,17 @@ pub(crate) fn check_noqa( // Remove any ignored diagnostics. 'outer: for (index, diagnostic) in context.iter().enumerate() { - // Can't ignore syntax errors. - let Some(code) = diagnostic.secondary_code() else { + // Syntax errors and other non-lint diagnostics cannot be suppressed. + let Some(name) = diagnostic.id().as_lint() else { continue; }; - if *code == Rule::BlanketNOQA.noqa_code() { + if name == Rule::BlanketNOQA.name() { continue; } // Apply file-level suppressions first - if exemption.contains_secondary_code(code) { + if exemption.includes_name(name) { ignored_diagnostics.push(index); continue; } @@ -90,31 +90,15 @@ pub(crate) fn check_noqa( if let Some(directive_line) = noqa_directives.find_line_with_directive_mut(noqa_offset) { let suppressed = match &directive_line.directive { - Directive::All(_) => { - let Ok(rule) = Rule::from_code(code) else { - debug_assert!(false, "Invalid secondary code `{code}`"); - continue; - }; - directive_line.matches.push(rule); - ignored_diagnostics.push(index); - true - } - Directive::Codes(directive) => { - if directive.includes(code) { - let Ok(rule) = Rule::from_code(code) else { - debug_assert!(false, "Invalid secondary code `{code}`"); - continue; - }; - directive_line.matches.push(rule); - ignored_diagnostics.push(index); - true - } else { - false - } - } + Directive::All(_) => true, + Directive::Codes(directive) => diagnostic + .secondary_code() + .is_some_and(|code| directive.includes(code)), }; - if suppressed { + if suppressed && let Ok(rule) = Rule::from_name(name.as_str()) { + directive_line.matches.push(rule); + ignored_diagnostics.push(index); continue 'outer; } } @@ -176,7 +160,10 @@ pub(crate) fn check_noqa( for original_code in codes.iter().map(Code::as_str) { let code = get_redirect_target(original_code).unwrap_or(original_code); if seen_codes.insert(original_code) { - if Rule::UnusedNOQA.noqa_code() == code { + if Rule::UnusedNOQA + .noqa_code() + .is_some_and(|noqa_code| noqa_code == code) + { self_ignore = true; if context.is_rule_enabled(Rule::UnusedNOQA) { valid_codes.push(original_code); @@ -187,7 +174,9 @@ pub(crate) fn check_noqa( } if context.is_rule_enabled(Rule::NoqaComments) - && Rule::NoqaComments.noqa_code() == code + && Rule::NoqaComments + .noqa_code() + .is_some_and(|noqa_code| noqa_code == code) { suppress_noqa_comment = true; valid_codes.push(original_code); @@ -199,7 +188,9 @@ pub(crate) fn check_noqa( diag.secondary_code().is_some_and(|noqa| *noqa == code) }) } else { - matches.iter().any(|match_| match_.noqa_code() == code) + matches.iter().any(|rule| { + rule.noqa_code().is_some_and(|noqa_code| noqa_code == code) + }) } || settings .external .iter() diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 18aaa618f4..b3add49485 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1,12 +1,17 @@ /// In this module we generate [`Rule`], an enum of all rules, and [`RuleCodePrefix`], an enum of -/// all rules categories. A rule category is something like pyflakes or flake8-todos. Each rule -/// category contains all rules and their common prefixes, i.e. everything you can specify in -/// `--select`. For pylint this is e.g. C0414 and E0118 but also C and E01. +/// all linter groups. A linter group is something like `pyflakes` or `flake8-todos`. Each linter +/// group contains all rules and their common prefixes, i.e. everything you can specify in +/// `--select`. For `pylint` this is e.g. `C0414` and `E0118` but also `C` and `E01`. +/// +/// When [`crate::preview::is_rule_categories_enabled`] returns `true`, rules can also be selected by +/// their [`Category`]. use std::fmt::Formatter; +use std::sync::LazyLock; use ruff_db::diagnostic::SecondaryCode; use serde::Serialize; -use strum_macros::EnumIter; +use strum::{IntoEnumIterator, VariantArray as _}; +use strum_macros::{Display, EnumIter, EnumMessage, EnumString, IntoStaticStr, VariantArray}; use crate::registry::Linter; use crate::rules; @@ -78,8 +83,134 @@ impl serde::Serialize for NoqaCode { } } +/// The category assigned to a lint rule. +/// +/// These categories are similar to those found in [Clippy] and form much broader groupings than the +/// linter-based groups. Categories are intended to be our primary classification mechanism for +/// rules going forward, with the linter groups eventually being deprecated and removed, albeit in +/// the relatively distant future. The categorization of a rule determines two important properties: +/// - its default status, `style` and above are currently enabled by default +/// - its default severity, in a future where we have multiple diagnostic severities +/// +/// Assuming we continue to follow Clippy, `correctness` lints will have a severity of `error` by +/// default, while the other on-by-default categories will have a severity of `warn` by default. +/// +/// Secondary groups like the legacy linter groups are orthogonal selection mechanisms that have no +/// impact on severity or default status, and may, and usually do, include rules from multiple +/// categories. For example, many `F` rules are `correctness` lints, but `F` includes `suspicious` +/// and even `pedantic` rules too. At some point in the future, we may support additional secondary +/// groups that are not legacy linter groups as well. +/// +/// The precedence between categories, linter groups, linter prefixes, and rules is determined by +/// the [`crate::rule_selector::Specificity`] returned by +/// [`crate::rule_selector::RuleSelector::specificity`], and currently follows this ordering: +/// +/// ```text +/// ALL < category < linter group < linter prefix < rule +/// ``` +/// +/// The ordering of variants isn't currently used anywhere, but they should be kept in descending +/// order of severity, with error categories first, followed by warning, and then by off-by-default +/// categories. +/// +/// See our [rule categorization guidelines] for more information on assigning categories. +/// +/// [Clippy]: https://doc.rust-lang.org/clippy/lints.html +/// [rule categorization guidelines]: https://docs.astral.sh/ruff/rule-proposals/#rule-categorization-guidelines +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + Hash, + EnumIter, + EnumString, + IntoStaticStr, + Display, + Serialize, + EnumMessage, + VariantArray, +)] +#[strum(serialize_all = "kebab-case", const_into_str)] +#[serde(rename_all = "kebab-case")] +pub enum Category { + /// Rules that flag outright wrong code + Correctness, + + /// Rules that flag likely outright wrong code but that could be intentional + Suspicious, + + /// Rules that suggest rewriting code in a shorter and more readable way + Complexity, + + /// Rules that suggest rewriting code in a more efficient way + Performance, + + /// Rules that suggest rewriting code in a more idiomatic way + Style, + + /// Rules that flag potential security vulnerabilities but may be prone to false positives + Security, + + /// Rules that flag formatting issues that do not affect semantics + Formatting, + + /// Rules that are highly opinionated or prone to false positives + Pedantic, + + /// Rules that restrict the use of certain features + Restriction, + + /// Internal testing rules that shouldn't be exposed to users. + #[cfg(any(feature = "test-rules", test))] + #[strum(disabled)] + Testing, +} + +impl Category { + /// Return the description of the category, derived from its documentation. + #[cfg(any(feature = "clap", test))] + pub(crate) fn description(self) -> &'static str { + let Some(docs) = + strum::EnumMessage::get_documentation(&self).and_then(|docs| docs.lines().next()) + else { + panic!("Category `{self}` missing required documentation"); + }; + + docs + } + + /// Return the rules in this category. + pub(crate) fn rules(self) -> &'static [Rule] { + static RULES_BY_CATEGORY: LazyLock<[Box<[Rule]>; Category::VARIANTS.len()]> = + LazyLock::new(|| { + let mut rules = [const { Vec::new() }; Category::VARIANTS.len()]; + + for rule in Rule::iter() { + rules[rule.category() as usize].push(rule); + } + + rules.map(Vec::into_boxed_slice) + }); + + &RULES_BY_CATEGORY[self as usize] + } + + /// Return the categories that should be enabled by default. + pub const fn default_categories() -> [Category; 5] { + [ + Self::Correctness, + Self::Suspicious, + Self::Complexity, + Self::Performance, + Self::Style, + ] + } +} + #[derive(Debug, Copy, Clone, Serialize)] -pub enum RuleGroup { +pub enum RuleStatus { /// The rule is stable since the provided Ruff version. Stable { since: &'static str }, /// The rule has been unstable since the provided Ruff version, and preview mode must be enabled @@ -93,7 +224,7 @@ pub enum RuleGroup { } #[ruff_macros::map_codes] -pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { +pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleStatus, Rule)> { #[expect(clippy::enum_glob_use)] use Linter::*; @@ -588,6 +719,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Pyupgrade, "045") => rules::pyupgrade::rules::NonPEP604AnnotationOptional, (Pyupgrade, "046") => rules::pyupgrade::rules::NonPEP695GenericClass, (Pyupgrade, "047") => rules::pyupgrade::rules::NonPEP695GenericFunction, + (Pyupgrade, "048") => rules::pyupgrade::rules::WhileOne, (Pyupgrade, "049") => rules::pyupgrade::rules::PrivateTypeParameter, (Pyupgrade, "050") => rules::pyupgrade::rules::UselessClassMetaclassType, (Pyupgrade, "051") => rules::pyupgrade::rules::DeprecatedAbcDecorator, @@ -955,7 +1087,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8UsePathlib, "113") => rules::flake8_use_pathlib::rules::OsPathIsfile, (Flake8UsePathlib, "114") => rules::flake8_use_pathlib::rules::OsPathIslink, (Flake8UsePathlib, "115") => rules::flake8_use_pathlib::rules::OsReadlink, - (Flake8UsePathlib, "116") => rules::flake8_use_pathlib::violations::OsStat, + (Flake8UsePathlib, "116") => rules::flake8_use_pathlib::rules::OsStat, (Flake8UsePathlib, "117") => rules::flake8_use_pathlib::rules::OsPathIsabs, (Flake8UsePathlib, "118") => rules::flake8_use_pathlib::violations::OsPathJoin, (Flake8UsePathlib, "119") => rules::flake8_use_pathlib::rules::OsPathBasename, @@ -966,7 +1098,6 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8UsePathlib, "124") => rules::flake8_use_pathlib::violations::PyPath, (Flake8UsePathlib, "201") => rules::flake8_use_pathlib::rules::PathConstructorCurrentDirectory, (Flake8UsePathlib, "202") => rules::flake8_use_pathlib::rules::OsPathGetsize, - (Flake8UsePathlib, "202") => rules::flake8_use_pathlib::rules::OsPathGetsize, (Flake8UsePathlib, "203") => rules::flake8_use_pathlib::rules::OsPathGetatime, (Flake8UsePathlib, "204") => rules::flake8_use_pathlib::rules::OsPathGetmtime, (Flake8UsePathlib, "205") => rules::flake8_use_pathlib::rules::OsPathGetctime, @@ -1082,7 +1213,6 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "073") => rules::ruff::rules::FStringPercentFormat, (Ruff, "074") => rules::ruff::rules::IncorrectDecoratorOrder, (Ruff, "075") => rules::ruff::rules::FallibleContextManager, - (Ruff, "076") => rules::ruff::rules::PytestFixtureAutouse, (Ruff, "100") => rules::ruff::rules::UnusedNOQA, (Ruff, "101") => rules::ruff::rules::RedirectedNOQA, @@ -1243,6 +1373,9 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Logging, "014") => rules::flake8_logging::rules::ExcInfoOutsideExceptHandler, (Flake8Logging, "015") => rules::flake8_logging::rules::RootLoggerCall, + // Rules that belong only to categories, without linter groups or codes. + () => rules::ruff::rules::PytestFixtureAutouse, + _ => return None, }) } @@ -1258,3 +1391,45 @@ pub enum FromNameError { #[error("unknown rule name")] Unknown, } + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use itertools::Itertools; + use strum::IntoEnumIterator; + + use super::{Category, Rule}; + + #[test] + fn category_names_do_not_conflict_with_rule_names() { + for category in Category::iter() { + assert!( + Rule::from_name(category.into_str()).is_err(), + "category {category} conflicts with a rule name" + ); + } + } + + #[test] + fn category_descriptions() { + let snapshot = Category::iter().format_with("\n", |category, f| { + f(&format_args!( + "{name}: {description}", + name = category.into_str(), + description = category.description(), + )) + }); + + assert_snapshot!(snapshot, @" + correctness: Rules that flag outright wrong code + suspicious: Rules that flag likely outright wrong code but that could be intentional + complexity: Rules that suggest rewriting code in a shorter and more readable way + performance: Rules that suggest rewriting code in a more efficient way + style: Rules that suggest rewriting code in a more idiomatic way + security: Rules that flag potential security vulnerabilities but may be prone to false positives + formatting: Rules that flag formatting issues that do not affect semantics + pedantic: Rules that are highly opinionated or prone to false positives + restriction: Rules that restrict the use of certain features + "); + } +} diff --git a/crates/ruff_linter/src/fix/mod.rs b/crates/ruff_linter/src/fix/mod.rs index e2672f2f37..9309b745a8 100644 --- a/crates/ruff_linter/src/fix/mod.rs +++ b/crates/ruff_linter/src/fix/mod.rs @@ -62,10 +62,13 @@ fn apply_fixes<'a>( let mut fixed = FixTable::default(); let mut source_map = SourceMap::default(); - for (code, name, fix) in diagnostics - .filter_map(|msg| msg.secondary_code().map(|code| (code, msg.name(), msg))) - .filter_map(|(code, name, diagnostic)| diagnostic.fix().map(|fix| (code, name, fix))) - .sorted_by(|(_, name1, fix1), (_, name2, fix2)| cmp_fix(name1, name2, fix1, fix2)) + for (id, code, fix) in diagnostics + .filter_map(|diagnostic| { + diagnostic + .fix() + .map(|fix| (diagnostic.id(), diagnostic.secondary_code(), fix)) + }) + .sorted_by(|(id1, _, fix1), (id2, _, fix2)| cmp_fix(id1.as_str(), id2.as_str(), fix1, fix2)) { let mut edits = fix .edits() @@ -110,7 +113,7 @@ fn apply_fixes<'a>( } applied.extend(applied_edits.drain(..)); - *fixed.entry(code).or_default(name) += 1; + *fixed.entry(id).or_default(code) += 1; } // Add the remaining content. diff --git a/crates/ruff_linter/src/line_width.rs b/crates/ruff_linter/src/line_width.rs index f01390036c..a04360ef9d 100644 --- a/crates/ruff_linter/src/line_width.rs +++ b/crates/ruff_linter/src/line_width.rs @@ -9,7 +9,35 @@ use unicode_width::UnicodeWidthChar; use ruff_cache::{CacheKey, CacheKeyHasher}; use ruff_macros::CacheKey; -use ruff_python_trivia::tab_offset; +use ruff_python_trivia::{find_trailing_pragma_offset, is_pragma_comment, tab_offset}; + +use crate::preview::{ + is_pragma_excluded_from_import_width_enabled, is_trailing_pragma_in_line_length_enabled, +}; +use crate::settings::types::PreviewMode; + +/// Returns the offset within `comment` at which the pragma comment excluded from line-length +/// measurement begins, or `None` if the comment contains no such pragma. +/// +/// This is the shared policy for how pragma comments (e.g., `# noqa: F401` or `# type: ignore`) +/// are excluded when measuring line width, used by `line-too-long` (E501) and +/// `doc-line-too-long` (W505), and, in preview mode, by isort's (I001) decision of whether an +/// import fits on one line (see [`LineWidthBuilder::add_comment`]). The formatter applies the +/// equivalent policy when measuring comment widths. +/// +/// In stable mode, only comments that are pragmas in their entirety are excluded (the returned +/// offset is `0`). In preview mode, a trailing pragma within a mixed comment (e.g., +/// `# explanation # noqa: F401`) is also excluded, in which case the offset points at the `#` +/// that begins the pragma. +pub(crate) fn pragma_offset_for_line_length(comment: &str, preview: PreviewMode) -> Option { + if is_trailing_pragma_in_line_length_enabled(preview) { + find_trailing_pragma_offset(comment) + } else if is_pragma_comment(comment) { + Some(0) + } else { + None + } +} /// The length of a line of text that is considered too long. /// @@ -237,6 +265,41 @@ impl LineWidthBuilder { self.column += width; self } + + /// Adds the width of a trailing comment, including the standard two-space separator that + /// precedes it. In preview mode, any pragma comment is excluded per + /// [`pragma_offset_for_line_length`]. + /// + /// Pragma comments are excluded so that adding one to a line never affects whether the line + /// is considered to fit, consistent with how `line-too-long` (E501) measures lines. For + /// example, counting a `# noqa` comment towards an import's width could cause isort to wrap + /// an import that otherwise fits on one line, moving the pragma to a position where it no + /// longer applies to the import statement: + /// + /// ```python + /// from module import ( + /// member, # noqa: PLC0415 + /// ) + /// ``` + /// + /// Unlike E501, which has always stripped whole-pragma comments on stable, the exclusion + /// changes how imports are formatted, so it is preview-gated in its entirety: on stable, the + /// full comment width is counted. + #[must_use] + pub(crate) fn add_comment(self, comment: &str, preview: PreviewMode) -> Self { + if !is_pragma_excluded_from_import_width_enabled(preview) { + return self.add_width(2).add_str(comment); + } + let counted = match pragma_offset_for_line_length(comment, preview) { + Some(offset) => comment[..offset].trim_end(), + None => comment, + }; + if counted.is_empty() { + self + } else { + self.add_width(2).add_str(counted) + } + } } impl PartialEq for LineWidthBuilder { diff --git a/crates/ruff_linter/src/linter.rs b/crates/ruff_linter/src/linter.rs index 1dc4399a9a..86f0bb1678 100644 --- a/crates/ruff_linter/src/linter.rs +++ b/crates/ruff_linter/src/linter.rs @@ -1,13 +1,14 @@ use std::borrow::Cow; +use std::collections::hash_map::Entry; use std::path::Path; use anyhow::{Result, anyhow}; use colored::Colorize; use itertools::Itertools; use ruff_python_parser::semantic_errors::SemanticSyntaxError; -use rustc_hash::FxBuildHasher; +use rustc_hash::FxHashMap; -use ruff_db::diagnostic::{Diagnostic, SecondaryCode}; +use ruff_db::diagnostic::{Diagnostic, DiagnosticId, SecondaryCode}; use ruff_notebook::Notebook; use ruff_python_ast::{ModModule, PySourceType, PythonVersion}; use ruff_python_codegen::Stylist; @@ -57,31 +58,35 @@ impl LinterResult { #[derive(Debug, Default, PartialEq)] struct FixCount { - rule_name: &'static str, + code: Option, count: usize, } -/// A mapping from a noqa code to the corresponding lint name and a count of applied fixes. +/// A mapping from a diagnostic's identifier to its optional noqa code and fix count. #[derive(Debug, Default, PartialEq)] -pub struct FixTable(hashbrown::HashMap); +pub struct FixTable(FxHashMap); impl FixTable { pub fn counts(&self) -> impl Iterator { self.0.values().map(|fc| fc.count) } - pub fn entry<'a>(&'a mut self, code: &'a SecondaryCode) -> FixTableEntry<'a> { - FixTableEntry(self.0.entry_ref(code)) + pub fn entry(&mut self, id: DiagnosticId) -> FixTableEntry<'_> { + FixTableEntry(self.0.entry(id)) } - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator, usize)> { self.0 .iter() - .map(|(code, FixCount { rule_name, count })| (code, *rule_name, *count)) + .map(|(id, FixCount { code, count })| (*id, code.as_ref(), *count)) } - fn keys(&self) -> impl Iterator { - self.0.keys() + /// Iterate over secondary codes, falling back to rule names for rules without codes. + fn identifiers(&self) -> impl Iterator { + self.0.iter().map(|(id, FixCount { code, .. })| { + code.as_ref() + .map_or_else(|| id.as_str(), SecondaryCode::as_str) + }) } pub fn is_empty(&self) -> bool { @@ -89,16 +94,14 @@ impl FixTable { } } -pub struct FixTableEntry<'a>( - hashbrown::hash_map::EntryRef<'a, 'a, SecondaryCode, SecondaryCode, FixCount, FxBuildHasher>, -); +pub struct FixTableEntry<'a>(Entry<'a, DiagnosticId, FixCount>); impl<'a> FixTableEntry<'a> { - pub fn or_default(self, rule_name: &'static str) -> &'a mut usize { + pub fn or_default(self, code: Option<&SecondaryCode>) -> &'a mut usize { &mut (self .0 - .or_insert(FixCount { - rule_name, + .or_insert_with(|| FixCount { + code: code.cloned(), count: 0, }) .count) @@ -627,7 +630,12 @@ pub fn lint_fix<'a>( // syntax error. Return the original code. if has_valid_syntax && has_no_syntax_errors { if let Some(error) = parsed.errors().first() { - report_fix_syntax_error(path, transformed.source_code(), error, fixed.keys()); + report_fix_syntax_error( + path, + transformed.source_code(), + error, + fixed.identifiers(), + ); return Err(anyhow!("Fix introduced a syntax error")); } } @@ -642,8 +650,8 @@ pub fn lint_fix<'a>( { if iterations < MAX_ITERATIONS { // Count the number of fixed errors. - for (rule, name, count) in applied.iter() { - *fixed.entry(rule).or_default(name) += count; + for (id, code, count) in applied.iter() { + *fixed.entry(id).or_default(code) += count; } transformed = Cow::Owned(transformed.updated(fixed_contents, &source_map)); @@ -682,7 +690,7 @@ pub(crate) fn report_failed_to_converge_error( transformed: &str, diagnostics: &[Diagnostic], ) { - let codes = collect_rule_codes(diagnostics.iter().filter_map(Diagnostic::secondary_code)); + let codes = collect_rule_codes(diagnostics.iter().map(Diagnostic::secondary_code_or_id)); if cfg!(debug_assertions) { eprintln!( "{}{} Failed to converge after {} iterations in `{}` with rule codes {}:---\n{}\n---", @@ -718,7 +726,7 @@ fn report_fix_syntax_error<'a>( path: &Path, transformed: &str, error: &ParseError, - rules: impl IntoIterator, + rules: impl IntoIterator, ) { let codes = collect_rule_codes(rules); if cfg!(debug_assertions) { @@ -1032,6 +1040,7 @@ mod tests { #[test_case(Path::new("write_to_debug.py"), PythonVersion::PY310)] #[test_case(Path::new("invalid_expression.py"), PythonVersion::PY312)] #[test_case(Path::new("global_parameter.py"), PythonVersion::PY310)] + #[test_case(Path::new("nonlocal_parameter.py"), PythonVersion::PY310)] #[test_case(Path::new("annotated_global.py"), PythonVersion::PY314)] #[test_case(Path::new("lazy_future_import.py"), PythonVersion::PY315)] #[test_case(Path::new("method_modifier_outside_class.by"), PythonVersion::PY312)] @@ -1061,7 +1070,7 @@ mod tests { }, ); insta::with_settings!({filters => vec![(r"\\", "/")]}, { - assert_diagnostics!(format!("{snapshot}"), diagnostics); + assert_diagnostics!(snapshot, diagnostics); }); Ok(()) diff --git a/crates/ruff_linter/src/message/mod.rs b/crates/ruff_linter/src/message/mod.rs index 72e6a50ab2..55cb3e6ad4 100644 --- a/crates/ruff_linter/src/message/mod.rs +++ b/crates/ruff_linter/src/message/mod.rs @@ -125,7 +125,9 @@ where diagnostic.set_noqa_offset(noqa_offset); } - diagnostic.set_secondary_code(SecondaryCode::new(rule.noqa_code().to_string())); + if let Some(code) = rule.noqa_code() { + diagnostic.set_secondary_code(SecondaryCode::new(code.to_string())); + } diagnostic.set_documentation_url(rule.url()); diagnostic diff --git a/crates/ruff_linter/src/message/sarif.rs b/crates/ruff_linter/src/message/sarif.rs index 0af6e30548..0fe21bd996 100644 --- a/crates/ruff_linter/src/message/sarif.rs +++ b/crates/ruff_linter/src/message/sarif.rs @@ -136,7 +136,7 @@ impl<'a> From<(&'a str, SarifLevel)> for SarifRule<'a> { Some((linter, suffix)) => { let rule = linter .all_rules() - .find(|rule| rule.noqa_code().suffix() == suffix) + .find(|rule| rule.noqa_code().is_some_and(|code| code.suffix() == suffix)) .expect("Expected a valid noqa code corresponding to a rule"); (Some(linter.name()), rule) } diff --git a/crates/ruff_linter/src/noqa.rs b/crates/ruff_linter/src/noqa.rs index 9c8b973e4c..db246d56cd 100644 --- a/crates/ruff_linter/src/noqa.rs +++ b/crates/ruff_linter/src/noqa.rs @@ -9,7 +9,7 @@ use anyhow::Result; use itertools::Itertools; use log::warn; -use ruff_db::diagnostic::{Diagnostic, SecondaryCode}; +use ruff_db::diagnostic::{Diagnostic, LintName}; use ruff_python_trivia::PythonWhitespace; use ruff_python_trivia::{CommentRanges, Cursor, indentation_at_offset}; use ruff_source_file::{LineEnding, LineRanges}; @@ -24,6 +24,7 @@ use crate::registry::Rule; use crate::rule_redirects::get_redirect_target; use crate::settings::types::PreviewMode; use crate::suppression::{self, Suppressions}; +use crate::warn_user_once; /// Generates an array of edits that matches the length of `diagnostics`. /// Each potential edit in the array is paired, in order, with the associated diagnostic. @@ -169,7 +170,7 @@ pub(crate) fn rule_is_ignored( Ok(Some(NoqaLexerOutput { directive: Directive::Codes(codes), .. - })) => codes.includes(&code.noqa_code()), + })) => code.noqa_code().is_some_and(|code| codes.includes(&code)), _ => false, } } @@ -184,19 +185,19 @@ pub(crate) enum FileExemption { } impl FileExemption { - /// Returns `true` if the file is exempt from the given rule, as identified by its noqa code. - pub(crate) fn contains_secondary_code(&self, needle: &SecondaryCode) -> bool { + /// Returns `true` if the file is exempt from the given rule. + pub(crate) fn includes(&self, needle: Rule) -> bool { match self { FileExemption::All(_) => true, - FileExemption::Codes(codes) => codes.iter().any(|code| *needle == code.noqa_code()), + FileExemption::Codes(codes) => codes.contains(&needle), } } - /// Returns `true` if the file is exempt from the given rule. - pub(crate) fn includes(&self, needle: Rule) -> bool { + /// Returns `true` if the file is exempt from the rule with the given name. + pub(crate) fn includes_name(&self, needle: LintName) -> bool { match self { FileExemption::All(_) => true, - FileExemption::Codes(codes) => codes.contains(&needle), + FileExemption::Codes(rules) => rules.iter().any(|rule| rule.name() == needle), } } @@ -969,12 +970,12 @@ fn find_suppression_comments<'a>( // Mark any non-ignored diagnostics. for message in diagnostics { - let Some(code) = message.secondary_code() else { + let Some(name) = message.id().as_lint() else { comments_by_line.push(None); continue; }; - if exemption.contains_secondary_code(code) { + if exemption.includes_name(name) { comments_by_line.push(None); continue; } @@ -996,7 +997,10 @@ fn find_suppression_comments<'a>( continue; } Directive::Codes(codes) => { - if codes.includes(code) { + if message + .secondary_code() + .is_some_and(|code| codes.includes(code)) + { comments_by_line.push(None); continue; } @@ -1019,7 +1023,10 @@ fn find_suppression_comments<'a>( continue; } Directive::Codes(codes) => { - if codes.includes(code) { + if message + .secondary_code() + .is_some_and(|code| codes.includes(code)) + { comments_by_line.push(None); continue; } @@ -1039,9 +1046,18 @@ fn find_suppression_comments<'a>( }; let identifier = match suppression_kind { - SuppressionKind::Noqa => code.as_str(), SuppressionKind::Ignore if is_human_readable_names_enabled(preview) => message.name(), - SuppressionKind::Ignore => code.as_str(), + SuppressionKind::Ignore => message.secondary_code_or_id(), + SuppressionKind::Noqa => { + let Some(code) = message.secondary_code() else { + warn_user_once!( + "Cannot add `noqa` comments for rules without codes; use `--add-ignore` instead." + ); + comments_by_line.push(None); + continue; + }; + code.as_str() + } }; comments_by_line.push(Some(SuppressionComment { @@ -3350,7 +3366,7 @@ mod tests { PreviewMode::Disabled, ); assert_eq!(count, 0); - assert_eq!(output, format!("{contents}")); + assert_eq!(output, contents); let source_file = SourceFileBuilder::new(path.to_string_lossy(), contents).finish(); let messages = [UnusedVariable { diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 853063ec23..020b25b73f 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -190,6 +190,11 @@ pub(crate) const fn is_fix_os_makedirs_enabled(settings: &LinterSettings) -> boo settings.preview.is_enabled() } +// https://github.com/astral-sh/ruff/pull/26460 +pub(crate) const fn is_fix_os_stat_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} + // https://github.com/astral-sh/ruff/pull/20009 pub(crate) const fn is_fix_os_symlink_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() @@ -314,6 +319,11 @@ pub(crate) const fn is_e402_fix_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() } +// https://github.com/astral-sh/ruff/pull/27993 +pub(crate) const fn is_pt020_fix_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} + // https://github.com/astral-sh/ruff/pull/23260 pub(crate) const fn is_up006_future_annotations_fix_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() @@ -346,3 +356,13 @@ pub const fn is_human_readable_names_enabled(preview: PreviewMode) -> bool { pub const fn is_warn_on_unknown_selectors_enabled(preview: PreviewMode) -> bool { preview.is_enabled() } + +// https://github.com/astral-sh/ruff/pull/27313 +pub(crate) const fn is_pragma_excluded_from_import_width_enabled(preview: PreviewMode) -> bool { + preview.is_enabled() +} + +// https://github.com/astral-sh/ruff/pull/27666 +pub(crate) const fn is_rule_categories_enabled(preview: PreviewMode) -> bool { + preview.is_enabled() +} diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index dec68b2e80..a86fe28ea7 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -17,7 +17,10 @@ impl Rule { let (linter, code) = Linter::parse_code(code).ok_or(FromCodeError::Unknown)?; linter .all_rules() - .find(|rule| rule.noqa_code().suffix() == code) + .find(|rule| { + rule.noqa_code() + .is_some_and(|rule_code| rule_code.suffix() == code) + }) .ok_or(FromCodeError::Unknown) } } @@ -366,6 +369,31 @@ impl Rule { let name: &'static str = self.into(); LintName::of(name) } + + /// Return the rule's name, followed by its code in parentheses when available. + /// + /// For example: + /// + /// ```text + /// unused-import (F401) + /// ``` + /// + /// When formatted with the `#` flag, both the name and code will be surrounded by backticks: + /// + /// ```text + /// `unused-import` (`F401`) + /// ``` + pub fn name_and_code(&self) -> impl std::fmt::Display + use<> { + let rule = *self; + std::fmt::from_fn(move |f| { + let quote = if f.alternate() { "`" } else { "" }; + write!(f, "{quote}{}{quote}", rule.name())?; + if let Some(code) = rule.noqa_code() { + write!(f, " ({quote}{code}{quote})")?; + } + Ok(()) + }) + } } /// Pairs of checks that shouldn't be enabled together. @@ -400,12 +428,17 @@ pub mod clap_completion { impl RuleParser { fn values() -> impl Iterator { Rule::iter().flat_map(|rule| { - let code = rule.noqa_code().to_string(); let name = rule.name().as_str(); - [ - PossibleValue::new(&code).help(name), - PossibleValue::new(name).help(code), - ] + let (code, name) = if let Some(code) = rule.noqa_code() { + let code = code.to_string(); + ( + Some(PossibleValue::new(&code).help(name)), + PossibleValue::new(name).help(code), + ) + } else { + (None, PossibleValue::new(name)) + }; + code.into_iter().chain(std::iter::once(name)) }) } } @@ -487,8 +520,12 @@ mod tests { #[test] fn check_code_serialization() { for rule in Rule::iter() { + let Some(code) = rule.noqa_code() else { + continue; + }; + assert!( - Rule::from_code(&format!("{}", rule.noqa_code())).is_ok(), + Rule::from_code(&code.to_string()).is_ok(), "{rule:?} could not be round-trip serialized." ); } @@ -497,7 +534,10 @@ mod tests { #[test] fn linter_parse_code() { for rule in Rule::iter() { - let code = format!("{}", rule.noqa_code()); + let Some(code) = rule.noqa_code() else { + continue; + }; + let code = code.to_string(); let (linter, rest) = Linter::parse_code(&code).unwrap_or_else(|| panic!("couldn't parse {code:?}")); assert_eq!(code, format!("{}{rest}", linter.common_prefix())); diff --git a/crates/ruff_linter/src/registry/rule_set.rs b/crates/ruff_linter/src/registry/rule_set.rs index bd785d463e..c530eaeefd 100644 --- a/crates/ruff_linter/src/registry/rule_set.rs +++ b/crates/ruff_linter/src/registry/rule_set.rs @@ -298,8 +298,7 @@ impl Display for RuleSet { } else { writeln!(f, "[")?; for rule in self { - let code = rule.noqa_code(); - writeln!(f, "\t{name} ({code}),", name = rule.name())?; + writeln!(f, "\t{},", rule.name_and_code())?; } write!(f, "]")?; } diff --git a/crates/ruff_linter/src/rule_documentation.rs b/crates/ruff_linter/src/rule_documentation.rs index d30116bcc8..dc48bee31e 100644 --- a/crates/ruff_linter/src/rule_documentation.rs +++ b/crates/ruff_linter/src/rule_documentation.rs @@ -15,19 +15,22 @@ use crate::registry::{Linter, Rule, RuleNamespace}; /// rule with documentation and one without read as the same kind of document. pub fn rule_documentation(rule: Rule) -> String { let mut output = String::new(); - let _ = write!(&mut output, "# {} ({})", rule.name(), rule.noqa_code()); + let _ = write!(&mut output, "# {}", rule.name_and_code()); output.push('\n'); output.push('\n'); - let (linter, _) = Linter::parse_code(&rule.noqa_code().to_string()) - .expect("a rule's own noqa code is one its linter parses"); - let _ = write!( - &mut output, - "Derived from the **{}** linter.", - linter.name() - ); - output.push('\n'); - output.push('\n'); + if let Some(linter) = rule + .noqa_code() + .and_then(|code| Linter::parse_code(&code.to_string()).map(|(linter, _)| linter)) + { + let _ = write!( + &mut output, + "Derived from the **{}** linter.", + linter.name() + ); + output.push('\n'); + output.push('\n'); + } let fix_availability = rule.fixable(); if matches!( @@ -71,12 +74,12 @@ mod tests { for rule in Rule::iter() { let doc = rule_documentation(rule); assert!( - doc.starts_with(&format!("# {} ({})", rule.name(), rule.noqa_code())), + doc.starts_with(&format!("# {}", rule.name_and_code())), "{} did not lead with its own name and code", rule.name() ); assert!( - doc.contains("linter."), + rule.noqa_code().is_none() || doc.contains("linter."), "{} did not say which linter it came from", rule.name() ); diff --git a/crates/ruff_linter/src/rule_redirects.rs b/crates/ruff_linter/src/rule_redirects.rs index b85523c6a1..2d680d305f 100644 --- a/crates/ruff_linter/src/rule_redirects.rs +++ b/crates/ruff_linter/src/rule_redirects.rs @@ -140,7 +140,7 @@ static REDIRECTS: LazyLock> = LazyLock::new( #[cfg(test)] mod tests { - use crate::codes::{Rule, RuleGroup}; + use crate::codes::{Rule, RuleStatus}; use crate::rule_redirects::REDIRECTS; use strum::IntoEnumIterator; @@ -148,9 +148,11 @@ mod tests { #[test] fn overshadowing_redirects() { for rule in Rule::iter() { - let (code, group) = (rule.noqa_code(), rule.group()); + let Some(code) = rule.noqa_code() else { + continue; + }; - if matches!(group, RuleGroup::Removed { .. }) { + if matches!(rule.status(), RuleStatus::Removed { .. }) { continue; } diff --git a/crates/ruff_linter/src/rule_selector.rs b/crates/ruff_linter/src/rule_selector.rs index 1205af1a6b..3ec7b4ffb0 100644 --- a/crates/ruff_linter/src/rule_selector.rs +++ b/crates/ruff_linter/src/rule_selector.rs @@ -7,9 +7,8 @@ use strum_macros::EnumIter; use ruff_ranged_value::{RangedValue, ValueSource}; -use crate::codes::RuleIter; -use crate::codes::{RuleCodePrefix, RuleGroup}; -use crate::preview::is_human_readable_names_enabled; +use crate::codes::{Category, NoqaCode, RuleCodePrefix, RuleIter, RuleStatus}; +use crate::preview::{is_human_readable_names_enabled, is_rule_categories_enabled}; use crate::registry::{Linter, Rule, RuleNamespace}; use crate::rule_redirects::get_redirect; use crate::settings::types::PreviewMode; @@ -25,13 +24,20 @@ pub struct UnresolvedRuleSelector(RangedValue); impl UnresolvedRuleSelector { pub fn resolve(&self, preview: PreviewMode) -> Result { - RuleSelector::from_str(self.0.as_str()).or_else(|_| { - let kind = if let Ok(rule) = Rule::from_name(self.0.as_str()) { + let selector = self.0.as_str(); + + RuleSelector::from_str(selector).or_else(|_| { + let kind = if let Ok(category) = Category::from_str(selector) { + if is_rule_categories_enabled(preview) { + return Ok(RuleSelector::Category(category)); + } + RuleResolutionErrorKind::PreviewCategory + } else if let Ok(rule) = Rule::from_name(selector) { if is_human_readable_names_enabled(preview) { return Ok(RuleSelector::rule(rule)); } RuleResolutionErrorKind::PreviewName - } else if matches!(self.0.as_str(), "PREVIEW" | "NURSERY") { + } else if matches!(selector, "PREVIEW" | "NURSERY") { RuleResolutionErrorKind::Removed } else { RuleResolutionErrorKind::Unknown @@ -57,6 +63,7 @@ impl UnresolvedRuleSelector { enum RuleResolutionErrorKind { Removed, Unknown, + PreviewCategory, PreviewName, } @@ -104,9 +111,10 @@ impl std::fmt::Display for RuleResolutionError { }; let source = match &source { ValueSource::File(path) => format_args!("`{}`", path.as_path()), + ValueSource::ScriptMetadata(_) => format_args!("script metadata"), ValueSource::Cli => format_args!("the CLI"), ValueSource::Editor => format_args!("the editor configuration"), - ValueSource::UvWorkspace => format_args!("uv workspace metadata"), + ValueSource::UvMetadata => format_args!("uv metadata"), }; match kind { RuleResolutionErrorKind::Removed => { @@ -116,6 +124,11 @@ impl std::fmt::Display for RuleResolutionError { f, "Unknown rule selector `{selector}`{setting} from {source}" ), + RuleResolutionErrorKind::PreviewCategory => write!( + f, + "Invalid selector `{selector}`{setting} from {source}. \ + Selecting rules by category requires preview mode" + ), RuleResolutionErrorKind::PreviewName => write!( f, "Invalid selector `{selector}`{setting} from {source}. \ @@ -131,6 +144,8 @@ impl std::error::Error for RuleResolutionError {} pub enum RuleSelector { /// Select all rules (includes rules in preview if enabled) All, + /// Select all rules in a semantic category. + Category(Category), /// Legacy category to select both the `mccabe` and `flake8-comprehensions` linters /// via a single selector. C, @@ -228,7 +243,9 @@ impl RuleCodePrefix { } // The rule must match the selector exactly. - (rule.noqa_code().suffix() == self.short_code()).then_some(rule) + rule.noqa_code() + .is_some_and(|code| code.suffix() == self.short_code()) + .then_some(rule) } } @@ -244,22 +261,28 @@ impl RuleSelector { pub fn prefix_and_code(&self) -> (&'static str, &'static str) { match self { RuleSelector::All => ("", "ALL"), + RuleSelector::Category(category) => ("", category.into_str()), RuleSelector::C => ("", "C"), RuleSelector::T => ("", "T"), RuleSelector::Prefix { prefix, .. } => { (prefix.linter().common_prefix(), prefix.short_code()) } - RuleSelector::Rule { rule, .. } => rule.noqa_code().into_parts(), + RuleSelector::Rule { rule, .. } => rule + .noqa_code() + .map_or_else(|| ("", rule.name().as_str()), NoqaCode::into_parts), RuleSelector::Linter(l) => (l.common_prefix(), ""), } } } impl RuleSelector { - /// Return all matching rules, regardless of rule group filters like preview and deprecated. + /// Return all matching rules, regardless of rule status filters like preview and deprecated. pub fn all_rules(&self) -> impl Iterator + use<> { match self { RuleSelector::All => RuleSelectorIter::All(Rule::iter()), + RuleSelector::Category(category) => { + RuleSelectorIter::Slice(category.rules().iter().copied()) + } RuleSelector::C => RuleSelectorIter::Chain( Linter::Flake8Comprehensions @@ -271,29 +294,29 @@ impl RuleSelector { .rules() .chain(Linter::Flake8Print.rules()), ), - RuleSelector::Linter(linter) => RuleSelectorIter::Vec(linter.rules()), - RuleSelector::Prefix { prefix, .. } => RuleSelectorIter::Vec(prefix.clone().rules()), + RuleSelector::Linter(linter) => RuleSelectorIter::Slice(linter.rules()), + RuleSelector::Prefix { prefix, .. } => RuleSelectorIter::Slice(prefix.rules()), RuleSelector::Rule { rule, .. } => RuleSelectorIter::Once(std::iter::once(*rule)), } } - /// Returns rules matching the selector, taking into account rule groups like preview and deprecated. + /// Returns rules matching the selector, taking into account rule statuses like preview and deprecated. pub fn rules<'a>(&'a self, preview: &PreviewOptions) -> impl Iterator + use<'a> { let preview_enabled = preview.mode.is_enabled(); let preview_require_explicit = preview.require_explicit; self.all_rules().filter(move |rule| { - match rule.group() { + match rule.status() { // Always include stable rules - RuleGroup::Stable { .. } => true, + RuleStatus::Stable { .. } => true, // Enabling preview includes all preview rules unless explicit selection is turned on - RuleGroup::Preview { .. } => { + RuleStatus::Preview { .. } => { preview_enabled && (self.is_exact() || !preview_require_explicit) } // Deprecated rules are excluded by default unless explicitly selected - RuleGroup::Deprecated { .. } => !preview_enabled && self.is_exact(), + RuleStatus::Deprecated { .. } => !preview_enabled && self.is_exact(), // Removed rules are included if explicitly selected but will error downstream - RuleGroup::Removed { .. } => self.is_exact(), + RuleStatus::Removed { .. } => self.is_exact(), } }) } @@ -304,10 +327,12 @@ impl RuleSelector { } } +type RuleSliceIter = std::iter::Copied>; + pub enum RuleSelectorIter { All(RuleIter), - Chain(std::iter::Chain, std::vec::IntoIter>), - Vec(std::vec::IntoIter), + Chain(std::iter::Chain), + Slice(RuleSliceIter), Once(std::iter::Once), } @@ -318,7 +343,7 @@ impl Iterator for RuleSelectorIter { match self { RuleSelectorIter::All(iter) => iter.next(), RuleSelectorIter::Chain(iter) => iter.next(), - RuleSelectorIter::Vec(iter) => iter.next(), + RuleSelectorIter::Slice(iter) => iter.next(), RuleSelectorIter::Once(iter) => iter.next(), } } @@ -339,7 +364,7 @@ mod schema { use serde_json::Value; use strum::IntoEnumIterator; - use crate::codes::Rule; + use crate::codes::{Category, Rule}; use crate::registry::RuleNamespace; use crate::rule_selector::{Linter, RuleCodePrefix}; use crate::{RuleSelector, UnresolvedRuleSelector}; @@ -362,6 +387,7 @@ mod schema { "T2".to_string(), ] .into_iter() + .chain(Category::iter().map(|category| category.to_string())) .chain( RuleCodePrefix::iter() .map(|p| { @@ -374,6 +400,11 @@ mod schema { (!prefix.is_empty()).then(|| prefix.to_string()) })), ) + .chain( + Rule::iter() + .filter(|rule| !rule.is_removed()) + .map(|rule| rule.name().to_string()), + ) .filter(|p| { // Exclude removed rules and prefixes where all of the rules are removed match RuleSelector::parse_no_redirect(p) { @@ -388,18 +419,16 @@ mod schema { // Filter out all test-only rules #[cfg(any(feature = "test-rules", test))] #[expect(clippy::used_underscore_binding)] - if _rule.starts_with("RUF9") || _rule == "PLW0101" { + if _rule.starts_with("RUF9") + || _rule == "PLW0101" + || Rule::from_name(_rule) + .is_ok_and(|rule| matches!(rule.category(), Category::Testing)) + { return false; } true }) - .flat_map(|code| { - Rule::from_code(&code) - .map(|rule| rule.name().to_string()) - .into_iter() - .chain(std::iter::once(code)) - }) .sorted() .collect(); @@ -418,6 +447,7 @@ impl RuleSelector { pub fn specificity(&self) -> Specificity { match self { RuleSelector::All => Specificity::All, + RuleSelector::Category(..) => Specificity::Category, RuleSelector::T => Specificity::LinterGroup, RuleSelector::C => Specificity::LinterGroup, RuleSelector::Linter(..) => Specificity::Linter, @@ -476,9 +506,11 @@ impl RuleSelector { pub enum Specificity { /// The specificity when selecting all rules (e.g., `--select ALL`). All, + /// The specificity when selecting a category (e.g., `--select correctness`). + Category, /// The specificity when selecting a legacy linter group (e.g., `--select C` or `--select T`). LinterGroup, - /// The specificity when selecting a linter (e.g., `--select PLE` or `--select UP`). + /// The specificity when selecting a linter (e.g., `--select UP`). Linter, /// The specificity when selecting via a rule prefix with a one-character code (e.g., `--select PLE1`). Prefix1Char, @@ -498,7 +530,7 @@ pub mod clap_completion { use strum::IntoEnumIterator; use crate::{ - codes::{Rule, RuleCodePrefix}, + codes::{Category, Rule, RuleCodePrefix}, registry::{Linter, RuleNamespace}, rule_selector::UnresolvedRuleSelector, }; @@ -559,8 +591,15 @@ pub mod clap_completion { None })) .chain(Rule::iter().map(|rule| { - PossibleValue::new(rule.name().as_str()) - .help(rule.noqa_code().to_string()) + let value = PossibleValue::new(rule.name().as_str()); + if let Some(code) = rule.noqa_code() { + value.help(code.to_string()) + } else { + value + } + })) + .chain(Category::iter().map(|category| { + PossibleValue::new(category.into_str()).help(category.description()) })), ), )) diff --git a/crates/ruff_linter/src/rules/airflow/mod.rs b/crates/ruff_linter/src/rules/airflow/mod.rs index f6e7baa224..615c85a6a5 100644 --- a/crates/ruff_linter/src/rules/airflow/mod.rs +++ b/crates/ruff_linter/src/rules/airflow/mod.rs @@ -67,7 +67,7 @@ mod tests { #[test_case(Rule::Airflow3SuggestedToMoveToProvider, Path::new("AIR312.py"))] #[test_case(Rule::Airflow3SuggestedToMoveToProvider, Path::new("AIR312_try.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("airflow").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/airflow/rules/dag_schedule_argument.rs b/crates/ruff_linter/src/rules/airflow/rules/dag_schedule_argument.rs index c374c528ce..e415aaab6b 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/dag_schedule_argument.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/dag_schedule_argument.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for a `DAG()` class or `@dag()` decorator without an explicit @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// dag = DAG(dag_id="my_dag", schedule=timedelta(days=1)) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Suspicious)] pub(crate) struct AirflowDagNoScheduleArgument; impl Violation for AirflowDagNoScheduleArgument { diff --git a/crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs b/crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs index 967ebbb101..e1d4260aa4 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{FunctionSignatureChange, is_method_in_subclass}; use crate::{FixAvailability, Violation}; use ruff_macros::{ViolationMetadata, derive_message_formats}; @@ -36,7 +37,7 @@ use ruff_text_size::Ranged; /// collector.create_asset(uri="s3://bucket/key") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Pedantic)] pub(crate) struct Airflow3IncompatibleFunctionSignature { function_name: String, change: FunctionSignatureChange, diff --git a/crates/ruff_linter/src/rules/airflow/rules/moved_in_3_1.rs b/crates/ruff_linter/src/rules/airflow/rules/moved_in_3_1.rs index 1ec9187d59..36b145058b 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/moved_in_3_1.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/moved_in_3_1.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{ INTERNAL_MODULE_WARNING, Replacement, generate_import_edit, generate_remove_and_runtime_import_edit, is_guarded_by_try_except, @@ -31,7 +32,7 @@ use ruff_text_size::TextRange; /// convert_to_utc(datetime.now()) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.1")] +#[violation_metadata(preview_since = "0.15.1", category = Category::Pedantic)] pub(crate) struct Airflow31Moved { deprecated: String, replacement: Replacement, diff --git a/crates/ruff_linter/src/rules/airflow/rules/moved_to_provider_in_3.rs b/crates/ruff_linter/src/rules/airflow/rules/moved_to_provider_in_3.rs index d420c25a6d..fd0f42bb58 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/moved_to_provider_in_3.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/moved_to_provider_in_3.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{ ProviderReplacement, generate_import_edit, generate_remove_and_runtime_import_edit, is_guarded_by_try_except, @@ -35,7 +36,7 @@ use crate::{FixAvailability, Violation}; /// fab_auth_manager_app = FabAuthManager().get_fastapi_app() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct Airflow3MovedToProvider<'a> { deprecated: QualifiedName<'a>, replacement: ProviderReplacement, diff --git a/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs b/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs index da9126a4f3..0b0a655b6a 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{ Replacement, generate_import_edit, generate_remove_and_runtime_import_edit, in_airflow_task_function, is_airflow_builtin_or_provider, is_airflow_task, @@ -43,7 +44,7 @@ use ruff_text_size::TextRange; /// yesterday = today - timedelta(days=1) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct Airflow3Removal { deprecated: String, replacement: Replacement, diff --git a/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs b/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs index eea5116ee5..d9c507ca16 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::{Modules, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::is_airflow_builtin_or_provider; use crate::{FixAvailability, Violation}; @@ -37,7 +38,7 @@ use crate::{FixAvailability, Violation}; /// dag = DAG(dag_id="my_dag", start_date=datetime(2024, 1, 1)) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.6")] +#[violation_metadata(preview_since = "0.15.6", category = Category::Correctness)] pub(crate) struct Airflow3DagDynamicValue { function_name: String, } diff --git a/crates/ruff_linter/src/rules/airflow/rules/suggested_to_move_to_provider_in_3.rs b/crates/ruff_linter/src/rules/airflow/rules/suggested_to_move_to_provider_in_3.rs index bb31ef7b98..3fbbe6114d 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/suggested_to_move_to_provider_in_3.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/suggested_to_move_to_provider_in_3.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{ ProviderReplacement, generate_import_edit, generate_remove_and_runtime_import_edit, is_guarded_by_try_except, @@ -51,7 +52,7 @@ use ruff_text_size::TextRange; /// ) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct Airflow3SuggestedToMoveToProvider<'a> { deprecated: QualifiedName<'a>, replacement: ProviderReplacement, diff --git a/crates/ruff_linter/src/rules/airflow/rules/suggested_to_update_3_0.rs b/crates/ruff_linter/src/rules/airflow/rules/suggested_to_update_3_0.rs index ad9414b4e0..fd96455fa7 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/suggested_to_update_3_0.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/suggested_to_update_3_0.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::{Replacement, is_airflow_builtin_or_provider}; use crate::rules::airflow::helpers::{ generate_import_edit, generate_remove_and_runtime_import_edit, is_guarded_by_try_except, @@ -37,7 +38,7 @@ use ruff_text_size::TextRange; /// Asset(uri="test://test/") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct Airflow3SuggestedUpdate { deprecated: String, replacement: Replacement, diff --git a/crates/ruff_linter/src/rules/airflow/rules/task_branch_as_short_circuit.rs b/crates/ruff_linter/src/rules/airflow/rules/task_branch_as_short_circuit.rs index e47ac29a9d..90dca1d7a0 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/task_branch_as_short_circuit.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/task_branch_as_short_circuit.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::is_airflow_task_variant; /// ## What it does @@ -70,7 +71,7 @@ use crate::rules::airflow::helpers::is_airflow_task_variant; /// task = ShortCircuitOperator(task_id="my_task", python_callable=my_callable) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.12")] +#[violation_metadata(preview_since = "0.15.12", category = Category::Complexity)] pub(crate) struct AirflowTaskBranchAsShortCircuit { kind: BranchKind, } diff --git a/crates/ruff_linter/src/rules/airflow/rules/task_implicit_multiple_outputs.rs b/crates/ruff_linter/src/rules/airflow/rules/task_implicit_multiple_outputs.rs index e718d36471..eef8cd48f2 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/task_implicit_multiple_outputs.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/task_implicit_multiple_outputs.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::{BindingKind, Modules, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_argument; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -53,7 +54,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// `XCom` layout, and a function with multiple return paths may not always /// return a dict. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.14")] +#[violation_metadata(preview_since = "0.15.14", category = Category::Style)] pub(crate) struct AirflowTaskImplicitMultipleOutputs { annotation_is_mapping: bool, } diff --git a/crates/ruff_linter/src/rules/airflow/rules/task_variable_name.rs b/crates/ruff_linter/src/rules/airflow/rules/task_variable_name.rs index 204665902c..a0ef503da0 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/task_variable_name.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/task_variable_name.rs @@ -1,4 +1,5 @@ use crate::Violation; +use crate::codes::Category; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast as ast; use ruff_python_ast::Expr; @@ -32,7 +33,7 @@ use crate::checkers::ast::Checker; /// my_task = PythonOperator(task_id="my_task") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Style)] pub(crate) struct AirflowVariableNameTaskIdMismatch { task_id: String, } diff --git a/crates/ruff_linter/src/rules/airflow/rules/variable_get_outside_task.rs b/crates/ruff_linter/src/rules/airflow/rules/variable_get_outside_task.rs index 26bfc45594..dceaf0b713 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/variable_get_outside_task.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/variable_get_outside_task.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::is_airflow_task; /// ## What it does @@ -54,7 +55,7 @@ use crate::rules::airflow::helpers::is_airflow_task; /// ) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.6")] +#[violation_metadata(preview_since = "0.15.6", category = Category::Suspicious)] pub(crate) struct AirflowVariableGetOutsideTask { in_function: bool, } diff --git a/crates/ruff_linter/src/rules/airflow/rules/xcom_pull_in_template_string.rs b/crates/ruff_linter/src/rules/airflow/rules/xcom_pull_in_template_string.rs index bc2d72ffb5..4e75138cae 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/xcom_pull_in_template_string.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/xcom_pull_in_template_string.rs @@ -6,6 +6,7 @@ use ruff_python_trivia::Cursor; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::airflow::helpers::is_airflow_builtin_or_provider; use crate::{FixAvailability, Violation}; @@ -49,7 +50,7 @@ use crate::{FixAvailability, Violation}; /// The fix is always unsafe because the variable in scope that matches the /// task ID may not be the Airflow task object that produced the `XCom` value. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.11")] +#[violation_metadata(preview_since = "0.15.11", category = Category::Complexity)] pub(crate) struct AirflowXcomPullInTemplateString { task_id: String, } diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR002_AIR002.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-dag-no-schedule-argument_AIR002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR002_AIR002.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-dag-no-schedule-argument_AIR002.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR004_AIR004.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-branch-as-short-circuit_AIR004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR004_AIR004.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-branch-as-short-circuit_AIR004.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR004_AIR004_sdk.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-branch-as-short-circuit_AIR004_sdk.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR004_AIR004_sdk.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-branch-as-short-circuit_AIR004_sdk.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR202_AIR202.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-implicit-multiple-outputs_AIR202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR202_AIR202.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-task-implicit-multiple-outputs_AIR202.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003_dag_decorator.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003_dag_decorator.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003_dag_decorator.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003_dag_decorator.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003_no_dag.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003_no_dag.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR003_AIR003_no_dag.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-get-outside-task_AIR003_no_dag.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR001_AIR001.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-name-task-id-mismatch_AIR001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR001_AIR001.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-variable-name-task-id-mismatch_AIR001.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR201_AIR201.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-xcom-pull-in-template-string_AIR201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR201_AIR201.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow-xcom-pull-in-template-string_AIR201.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR304_AIR304.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-dag-dynamic-value_AIR304.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR304_AIR304.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-dag-dynamic-value_AIR304.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR303_AIR303.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-incompatible-function-signature_AIR303.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR303_AIR303.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-incompatible-function-signature_AIR303.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_amazon.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_amazon.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_amazon.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_amazon.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_celery.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_celery.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_celery.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_celery.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_common_sql.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_common_sql.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_common_sql.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_common_sql.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_daskexecutor.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_daskexecutor.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_daskexecutor.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_daskexecutor.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_druid.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_druid.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_druid.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_druid.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_fab.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_fab.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_fab.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_fab.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_hdfs.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_hdfs.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_hdfs.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_hdfs.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_hive.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_hive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_hive.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_hive.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_http.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_http.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_http.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_http.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_jdbc.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_jdbc.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_jdbc.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_jdbc.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_kubernetes.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_kubernetes.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_kubernetes.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_kubernetes.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_mysql.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_mysql.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_mysql.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_mysql.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_oracle.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_oracle.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_oracle.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_oracle.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_papermill.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_papermill.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_papermill.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_papermill.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_pig.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_pig.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_pig.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_pig.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_postgres.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_postgres.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_postgres.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_postgres.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_presto.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_presto.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_presto.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_presto.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_samba.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_samba.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_samba.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_samba.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_slack.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_slack.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_slack.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_slack.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_smtp.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_smtp.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_smtp.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_smtp.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_sqlite.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_sqlite.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_sqlite.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_sqlite.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_standard.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_standard.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_standard.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_standard.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names_try.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_try.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names_try.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_try.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_zendesk.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_zendesk.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_zendesk.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-moved-to-provider_AIR302_zendesk.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_airflow_plugin.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_airflow_plugin.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_airflow_plugin.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_airflow_plugin.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_args.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_args.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_args.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_args.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_class_attribute.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_class_attribute.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_class_attribute.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_class_attribute.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_context.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_context.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_context.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_context.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_decorator.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_decorator.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_decorator.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_decorator.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names_fix.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names_fix.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names_fix.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names_fix.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_try.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names_try.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR302_AIR302_try.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_names_try.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_provider_names_fix.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_provider_names_fix.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_provider_names_fix.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-removal_AIR301_provider_names_fix.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR312_AIR312.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-to-move-to-provider_AIR312.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR312_AIR312.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-to-move-to-provider_AIR312.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_try.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-to-move-to-provider_AIR312_try.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_try.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-to-move-to-provider_AIR312_try.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_args.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_args.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_args.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_args.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_names.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_names.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR311_AIR311_names.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_names.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR312_AIR312_try.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_try.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR312_AIR312_try.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow3-suggested-update_AIR311_try.py.snap diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR321_AIR321_names.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow31-moved_AIR321_names.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR321_AIR321_names.py.snap rename to crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__airflow31-moved_AIR321_names.py.snap diff --git a/crates/ruff_linter/src/rules/basedpython/mod.rs b/crates/ruff_linter/src/rules/basedpython/mod.rs index 5169396264..d10bd39e01 100644 --- a/crates/ruff_linter/src/rules/basedpython/mod.rs +++ b/crates/ruff_linter/src/rules/basedpython/mod.rs @@ -38,7 +38,13 @@ mod tests { #[test_case(Rule::ManualModifier, Path::new("BY022.by"))] #[test_case(Rule::RedundantNoneCoalesce, Path::new("BY101.by"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!( + "{}_{}", + rule_code + .noqa_code() + .expect("a basedpython rule always has a noqa code"), + path.to_string_lossy() + ); let diagnostics = test_path( Path::new("basedpython").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_any_annotation.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_any_annotation.rs index bb73318fe1..e930324e6c 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_any_annotation.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_any_annotation.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Expr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: dynamic](https://docs.basedpython.org/features/dynamic) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualAnyAnnotation; impl AlwaysFixableViolation for ManualAnyAnnotation { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_cast_call.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_cast_call.rs index 5d0dc82552..fad15eac12 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_cast_call.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_cast_call.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::basedpython::helpers::{comparison_fits, comparison_operand_source}; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: `cast` keyword](https://docs.basedpython.org/features/cast) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualCastCall; impl AlwaysFixableViolation for ManualCastCall { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs index fac185826b..52ce7d36de 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr, UnaryOp}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::basedpython::helpers::{comparison_fits, comparison_operand_source}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: identity and isinstance](https://docs.basedpython.org/features/identity-swap) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualIsinstance; impl AlwaysFixableViolation for ManualIsinstance { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_modifier.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_modifier.rs index 331b435afd..804bfd4ec9 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_modifier.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_modifier.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{Decorator, Expr, Stmt}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -54,7 +55,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: modifiers](https://docs.basedpython.org/features/modifiers) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualModifier { decorator: String, modifier: &'static str, diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_none_coalesce.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_none_coalesce.rs index d617a40ad7..67f7fa79b4 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_none_coalesce.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_none_coalesce.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, CmpOp, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::basedpython::helpers::none_test; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: none-coalesce operator](https://docs.basedpython.org/features/none-coalesce) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualNoneCoalesce; impl AlwaysFixableViolation for ManualNoneCoalesce { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_optional_chain.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_optional_chain.rs index 32c17a7801..4db2092bf2 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_optional_chain.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_optional_chain.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, CmpOp, Expr}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::basedpython::helpers::none_test; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: optional chaining](https://docs.basedpython.org/features/optional-chaining) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualOptionalChain; impl AlwaysFixableViolation for ManualOptionalChain { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_property.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_property.rs index 0e34bcc4ca..97646c4b9c 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_property.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_property.rs @@ -7,6 +7,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -64,7 +65,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [basedpython documentation: properties](https://docs.basedpython.org/features/properties) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualProperty { name: String, keyword: &'static str, diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_re_export.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_re_export.rs index 521485c88b..a75f3439d9 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_re_export.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_re_export.rs @@ -4,6 +4,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: export imports](https://docs.basedpython.org/features/export-imports) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualReExport; impl AlwaysFixableViolation for ManualReExport { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_sentinel.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_sentinel.rs index 76d6cd0ecb..f7cfc8d985 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_sentinel.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_sentinel.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: sentinel](https://docs.basedpython.org/features/sentinel) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualSentinel; impl AlwaysFixableViolation for ManualSentinel { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_super_call.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_super_call.rs index 0d6db4c385..d3b6b40eae 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_super_call.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_super_call.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: `super` keyword](https://docs.basedpython.org/features/super) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualSuperCall; impl AlwaysFixableViolation for ManualSuperCall { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_typeof_annotation.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_typeof_annotation.rs index 47e064585f..2c0cf6f2bd 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_typeof_annotation.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_typeof_annotation.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: typeof](https://docs.basedpython.org/features/typeof) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualTypeofAnnotation; impl AlwaysFixableViolation for ManualTypeofAnnotation { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_unpack_annotation.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_unpack_annotation.rs index 0d9543bfd3..6afb43904d 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/manual_unpack_annotation.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_unpack_annotation.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [basedpython documentation: unpack syntax](https://docs.basedpython.org/features/unpack-syntax) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct ManualUnpackAnnotation; impl AlwaysFixableViolation for ManualUnpackAnnotation { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/redundant_none_coalesce.rs b/crates/ruff_linter/src/rules/basedpython/rules/redundant_none_coalesce.rs index 1fb511d30c..4930d6423a 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/redundant_none_coalesce.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/redundant_none_coalesce.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, Operator}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -33,7 +34,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: none-coalesce operator](https://docs.basedpython.org/features/none-coalesce) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct RedundantNoneCoalesce; impl AlwaysFixableViolation for RedundantNoneCoalesce { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/redundant_typing_import.rs b/crates/ruff_linter/src/rules/basedpython/rules/redundant_typing_import.rs index 7b8020c085..8a24aec5d6 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/redundant_typing_import.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/redundant_typing_import.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Fix, fix}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Applicability, Fix, fix}; /// ## References /// - [basedpython documentation: implicit typing imports](https://docs.basedpython.org/features/implicit-typing) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct RedundantTypingImport; impl AlwaysFixableViolation for RedundantTypingImport { diff --git a/crates/ruff_linter/src/rules/basedpython/rules/unnecessary_stub_body.rs b/crates/ruff_linter/src/rules/basedpython/rules/unnecessary_stub_body.rs index f928803b34..8393fad7b7 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/unnecessary_stub_body.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/unnecessary_stub_body.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{Expr, Stmt}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [basedpython documentation: empty declarations](https://docs.basedpython.org/features/empty-declarations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.1-a10")] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] pub(crate) struct UnnecessaryStubBody; impl AlwaysFixableViolation for UnnecessaryStubBody { diff --git a/crates/ruff_linter/src/rules/eradicate/mod.rs b/crates/ruff_linter/src/rules/eradicate/mod.rs index 328868a878..be9e2a623f 100644 --- a/crates/ruff_linter/src/rules/eradicate/mod.rs +++ b/crates/ruff_linter/src/rules/eradicate/mod.rs @@ -15,7 +15,7 @@ mod tests { #[test_case(Rule::CommentedOutCode, Path::new("ERA001.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("eradicate").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs b/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs index a26d64d0ea..e99049b44f 100644 --- a/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs +++ b/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs @@ -5,6 +5,7 @@ use ruff_text_size::TextRange; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::eradicate::detection::comment_contains_code; @@ -30,7 +31,7 @@ use crate::rules::eradicate::detection::comment_contains_code; /// /// [#4845]: https://github.com/astral-sh/ruff/issues/4845 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.145")] +#[violation_metadata(stable_since = "v0.0.145", category = Category::Pedantic)] pub(crate) struct CommentedOutCode; impl Violation for CommentedOutCode { diff --git a/crates/ruff_linter/src/rules/eradicate/snapshots/ruff_linter__rules__eradicate__tests__ERA001_ERA001.py.snap b/crates/ruff_linter/src/rules/eradicate/snapshots/ruff_linter__rules__eradicate__tests__commented-out-code_ERA001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/eradicate/snapshots/ruff_linter__rules__eradicate__tests__ERA001_ERA001.py.snap rename to crates/ruff_linter/src/rules/eradicate/snapshots/ruff_linter__rules__eradicate__tests__commented-out-code_ERA001.py.snap diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs index 4515962880..81d9cd3d8c 100644 --- a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::fastapi::rules::is_fastapi_route; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -79,7 +80,7 @@ use ruff_python_ast::PythonVersion; /// [typing-annotated]: https://docs.python.org/3/library/typing.html#typing.Annotated /// [typing-extensions]: https://typing-extensions.readthedocs.io/en/stable/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Style)] pub(crate) struct FastApiNonAnnotatedDependency { py_version: PythonVersion, } diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs index 9d396622f4..c300cad2dc 100644 --- a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::{Modules, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::rules::fastapi::rules::is_fastapi_route_decorator; use crate::{AlwaysFixableViolation, Fix}; @@ -64,7 +65,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// runtime behavior and API documentation generation. Additionally, comments inside /// the decorator might be removed when the argument is deleted. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Complexity)] pub(crate) struct FastApiRedundantResponseModel; impl AlwaysFixableViolation for FastApiRedundantResponseModel { diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs index 64f1fac64d..1e6fea3774 100644 --- a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs @@ -12,6 +12,7 @@ use ruff_text_size::{Ranged, TextSize}; use crate::Fix; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_parameter; use crate::rules::fastapi::rules::is_fastapi_route_decorator; use crate::{FixAvailability, Violation}; @@ -64,7 +65,7 @@ use crate::{FixAvailability, Violation}; /// This rule's fix is marked as unsafe, as modifying a function signature can /// change the behavior of the code. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Suspicious)] pub(crate) struct FastApiUnusedPathParameter { arg_name: String, function_name: String, diff --git a/crates/ruff_linter/src/rules/flake8_2020/mod.rs b/crates/ruff_linter/src/rules/flake8_2020/mod.rs index 21a0e46ad7..ddcb398677 100644 --- a/crates/ruff_linter/src/rules/flake8_2020/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_2020/mod.rs @@ -24,7 +24,7 @@ mod tests { #[test_case(Rule::SysVersionCmpStr10, Path::new("YTT302.py"))] #[test_case(Rule::SysVersionSlice1, Path::new("YTT303.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_2020").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_2020/rules/compare.rs b/crates/ruff_linter/src/rules/flake8_2020/rules/compare.rs index 0fb5e5ce8d..fa31ab2252 100644 --- a/crates/ruff_linter/src/rules/flake8_2020/rules/compare.rs +++ b/crates/ruff_linter/src/rules/flake8_2020/rules/compare.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_2020::helpers::is_sys; @@ -41,7 +42,7 @@ use crate::rules::flake8_2020::helpers::is_sys; /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionCmpStr3; impl Violation for SysVersionCmpStr3 { @@ -92,7 +93,7 @@ impl Violation for SysVersionCmpStr3 { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionInfo0Eq3 { eq: bool, } @@ -139,7 +140,7 @@ impl Violation for SysVersionInfo0Eq3 { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionInfo1CmpInt; impl Violation for SysVersionInfo1CmpInt { @@ -182,7 +183,7 @@ impl Violation for SysVersionInfo1CmpInt { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionInfoMinorCmpInt; impl Violation for SysVersionInfoMinorCmpInt { @@ -226,7 +227,7 @@ impl Violation for SysVersionInfoMinorCmpInt { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionCmpStr10; impl Violation for SysVersionCmpStr10 { diff --git a/crates/ruff_linter/src/rules/flake8_2020/rules/name_or_attribute.rs b/crates/ruff_linter/src/rules/flake8_2020/rules/name_or_attribute.rs index 10fc95a7c4..306f7ad555 100644 --- a/crates/ruff_linter/src/rules/flake8_2020/rules/name_or_attribute.rs +++ b/crates/ruff_linter/src/rules/flake8_2020/rules/name_or_attribute.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `six.PY3`. @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// - [Six documentation: `six.PY2`](https://six.readthedocs.io/#six.PY2) /// - [Six documentation: `six.PY3`](https://six.readthedocs.io/#six.PY3) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SixPY3; impl Violation for SixPY3 { diff --git a/crates/ruff_linter/src/rules/flake8_2020/rules/subscript.rs b/crates/ruff_linter/src/rules/flake8_2020/rules/subscript.rs index 591b8a529c..7ac3ee32d5 100644 --- a/crates/ruff_linter/src/rules/flake8_2020/rules/subscript.rs +++ b/crates/ruff_linter/src/rules/flake8_2020/rules/subscript.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_2020::helpers::is_sys; @@ -38,7 +39,7 @@ use crate::rules::flake8_2020::helpers::is_sys; /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionSlice3; impl Violation for SysVersionSlice3 { @@ -79,7 +80,7 @@ impl Violation for SysVersionSlice3 { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersion2; impl Violation for SysVersion2 { @@ -120,7 +121,7 @@ impl Violation for SysVersion2 { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersion0; impl Violation for SysVersion0 { @@ -161,7 +162,7 @@ impl Violation for SysVersion0 { /// - [Python documentation: `sys.version`](https://docs.python.org/3/library/sys.html#sys.version) /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.113")] +#[violation_metadata(stable_since = "v0.0.113", category = Category::Suspicious)] pub(crate) struct SysVersionSlice1; impl Violation for SysVersionSlice1 { diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT202_YTT202.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__six-py3_YTT202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT202_YTT202.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__six-py3_YTT202.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT302_YTT302.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-cmp-str10_YTT302.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT302_YTT302.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-cmp-str10_YTT302.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT103_YTT103.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-cmp-str3_YTT103.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT103_YTT103.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-cmp-str3_YTT103.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT204_YTT204.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info-minor-cmp-int_YTT204.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT204_YTT204.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info-minor-cmp-int_YTT204.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT201_YTT201.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info0-eq3_YTT201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT201_YTT201.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info0-eq3_YTT201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT203_YTT203.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info1-cmp-int_YTT203.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT203_YTT203.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-info1-cmp-int_YTT203.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT303_YTT303.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-slice1_YTT303.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT303_YTT303.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-slice1_YTT303.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT101_YTT101.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-slice3_YTT101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT101_YTT101.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version-slice3_YTT101.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT301_YTT301.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version0_YTT301.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT301_YTT301.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version0_YTT301.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT102_YTT102.py.snap b/crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version2_YTT102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__YTT102_YTT102.py.snap rename to crates/ruff_linter/src/rules/flake8_2020/snapshots/ruff_linter__rules__flake8_2020__tests__sys-version2_YTT102.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_annotations/rules/definition.rs b/crates/ruff_linter/src/rules/flake8_annotations/rules/definition.rs index 514960ec53..775bf7ba35 100644 --- a/crates/ruff_linter/src/rules/flake8_annotations/rules/definition.rs +++ b/crates/ruff_linter/src/rules/flake8_annotations/rules/definition.rs @@ -10,6 +10,7 @@ use ruff_python_stdlib::typing::simple_magic_return_type; use ruff_text_size::Ranged; use crate::checkers::ast::{Checker, DiagnosticGuard}; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_annotations::helpers::{auto_return_type, type_expr}; use crate::rules::ruff::typing::type_hint_resolves_to_any; @@ -38,7 +39,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## Options /// - `lint.flake8-annotations.suppress-dummy-args` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingTypeFunctionArgument { name: String, } @@ -74,7 +75,7 @@ impl Violation for MissingTypeFunctionArgument { /// ## Options /// - `lint.flake8-annotations.suppress-dummy-args` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingTypeArgs { name: String, } @@ -110,7 +111,7 @@ impl Violation for MissingTypeArgs { /// ## Options /// - `lint.flake8-annotations.suppress-dummy-args` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingTypeKwargs { name: String, } @@ -152,7 +153,7 @@ impl Violation for MissingTypeKwargs { /// ``` #[derive(ViolationMetadata)] #[deprecated(note = "ANN101 has been removed")] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct MissingTypeSelf; #[expect(deprecated)] @@ -197,7 +198,7 @@ impl Violation for MissingTypeSelf { /// ``` #[derive(ViolationMetadata)] #[deprecated(note = "ANN102 has been removed")] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct MissingTypeCls; #[expect(deprecated)] @@ -241,7 +242,7 @@ impl Violation for MissingTypeCls { /// /// - `lint.typing-extensions` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingReturnTypeUndocumentedPublicFunction { name: String, annotation: Option, @@ -295,7 +296,7 @@ impl Violation for MissingReturnTypeUndocumentedPublicFunction { /// /// - `lint.typing-extensions` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingReturnTypePrivateFunction { name: String, annotation: Option, @@ -356,7 +357,7 @@ impl Violation for MissingReturnTypePrivateFunction { /// /// - `lint.flake8-annotations.mypy-init-return` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingReturnTypeSpecialMethod { name: String, annotation: Option, @@ -408,7 +409,7 @@ impl Violation for MissingReturnTypeSpecialMethod { /// /// - `lint.flake8-annotations.suppress-none-returning` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingReturnTypeStaticMethod { name: String, annotation: Option, @@ -460,7 +461,7 @@ impl Violation for MissingReturnTypeStaticMethod { /// /// - `lint.flake8-annotations.suppress-none-returning` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.105")] +#[violation_metadata(stable_since = "v0.0.105", category = Category::Pedantic)] pub(crate) struct MissingReturnTypeClassMethod { name: String, annotation: Option, @@ -533,7 +534,7 @@ impl Violation for MissingReturnTypeClassMethod { /// - [Python documentation: `typing.Any`](https://docs.python.org/3/library/typing.html#typing.Any) /// - [Mypy documentation: The Any type](https://mypy.readthedocs.io/en/stable/kinds_of_types.html#the-any-type) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.108")] +#[violation_metadata(stable_since = "v0.0.108", category = Category::Pedantic)] pub(crate) struct AnyType { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_async/mod.rs b/crates/ruff_linter/src/rules/flake8_async/mod.rs index 4d5c602928..12d114988b 100644 --- a/crates/ruff_linter/src/rules/flake8_async/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_async/mod.rs @@ -33,7 +33,7 @@ mod tests { #[test_case(Rule::BlockingInputInAsyncFunction, Path::new("ASYNC250.py"))] #[test_case(Rule::BlockingSleepInAsyncFunction, Path::new("ASYNC251.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_async").join(path).as_path(), &LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/async_busy_wait.rs b/crates/ruff_linter/src/rules/flake8_async/rules/async_busy_wait.rs index 5799c7cc89..1951403daf 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/async_busy_wait.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/async_busy_wait.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_async::helpers::AsyncModule; /// ## What it does @@ -47,7 +48,7 @@ use crate::rules::flake8_async::helpers::AsyncModule; /// - [`anyio` events](https://anyio.readthedocs.io/en/latest/api.html#anyio.Event) /// - [`trio` events](https://trio.readthedocs.io/en/latest/reference-core.html#trio.Event) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct AsyncBusyWait { module: AsyncModule, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/async_function_with_timeout.rs b/crates/ruff_linter/src/rules/flake8_async/rules/async_function_with_timeout.rs index 4b78cc5eb9..72b10191e2 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/async_function_with_timeout.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/async_function_with_timeout.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_async::helpers::AsyncModule; use ruff_python_ast::PythonVersion; @@ -76,7 +77,7 @@ use ruff_python_ast::PythonVersion; /// ["structured concurrency"]: https://vorpus.org/blog/some-thoughts-on-asynchronous-api-design-in-a-post-asyncawait-world/#timeouts-and-cancellation /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct AsyncFunctionWithTimeout { module: AsyncModule, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/async_zero_sleep.rs b/crates/ruff_linter/src/rules/flake8_async/rules/async_zero_sleep.rs index 572b270871..2c80f15cd7 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/async_zero_sleep.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/async_zero_sleep.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::rules::flake8_async::helpers::AsyncModule; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -49,7 +50,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Style)] pub(crate) struct AsyncZeroSleep { module: AsyncModule, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call.rs index 7f68504da4..118314bfdd 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not contain blocking HTTP calls. @@ -38,7 +39,7 @@ use crate::checkers::ast::Checker; /// ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct BlockingHttpCallInAsyncFunction; impl Violation for BlockingHttpCallInAsyncFunction { @@ -55,7 +56,8 @@ fn is_blocking_http_call(qualified_name: &QualifiedName) -> bool { | ["urllib3", "request"] | [ "httpx" | "requests", - "get" + "request" + | "get" | "post" | "delete" | "patch" diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs index 2869e6ceb0..835ea07340 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not use blocking httpx clients. @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// response = await client.get(...) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct BlockingHttpCallHttpxInAsyncFunction { name: String, call: String, diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs index bb3f07816e..451a505257 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not contain blocking usage of input from user. @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// username = await loop.run_in_executor(None, input, "Username:") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct BlockingInputInAsyncFunction; impl Violation for BlockingInputInAsyncFunction { diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_open_call.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_open_call.rs index 01bea3def4..651524bbbb 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_open_call.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_open_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not open files with blocking methods like `open`. @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// contents = await f.read() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct BlockingOpenCallInAsyncFunction; impl Violation for BlockingOpenCallInAsyncFunction { @@ -64,7 +65,7 @@ fn is_open_call(func: &Expr, semantic: &SemanticModel) -> bool { .is_some_and(|qualified_name| { matches!( qualified_name.segments(), - ["" | "io", "open"] | ["io", "open_code"] + ["" | "builtins" | "io", "open"] | ["io", "open_code"] ) }) } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_path_methods.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_path_methods.rs index fd4064417b..9e6f803041 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_path_methods.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_path_methods.rs @@ -1,20 +1,23 @@ use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr, ExprCall}; use ruff_python_semantic::analyze::typing::{TypeChecker, check_type, traverse_union_and_optional}; use ruff_text_size::Ranged; /// ## What it does -/// Checks that async functions do not call blocking `os.path` or `pathlib.Path` -/// methods. +/// Checks that async functions do not call blocking `os.path` functions or +/// `pathlib.Path` methods. /// /// ## Why is this bad? -/// Calling some `os.path` or `pathlib.Path` methods in an async function will block -/// the entire event loop, preventing it from executing other tasks while waiting -/// for the operation. This negates the benefits of asynchronous programming. +/// Calling some `os.path` functions or `pathlib.Path` methods in an async function +/// will block the entire event loop, preventing it from executing other tasks while +/// waiting for the operation. This negates the benefits of asynchronous programming. /// -/// Instead, use the methods' async equivalents from `trio.Path` or `anyio.Path`. +/// Instead, run blocking path operations in a separate thread, or use an API that +/// provides asynchronous path operations, such as `aiofiles.os.path`, `anyio.Path`, +/// or `trio.Path`. /// /// ## Example /// ```python @@ -26,28 +29,40 @@ use ruff_text_size::Ranged; /// file_exists = os.path.exists(path) /// ``` /// -/// Use instead: +/// On Python 3.9 and later, use instead: /// ```python -/// import trio +/// import asyncio +/// import os /// /// /// async def func(): -/// path = trio.Path("my_file.txt") -/// file_exists = await path.exists() +/// path = "my_file.txt" +/// file_exists = await asyncio.to_thread(os.path.exists, path) /// ``` /// -/// Non-blocking methods are OK to use: +/// On earlier Python versions, use `loop.run_in_executor()`. +/// +/// Or, use an asynchronous path API: /// ```python -/// import pathlib +/// import trio /// /// /// async def func(): -/// path = pathlib.Path("my_file.txt") -/// file_dirname = path.dirname() -/// new_path = os.path.join("/tmp/src/", path) +/// path = trio.Path("my_file.txt") +/// file_exists = await path.exists() /// ``` +/// +/// Purely computational path operations, such as `os.path.join()` and +/// `pathlib.Path.with_suffix()`, are not flagged. +/// +/// ## References +/// - [Python documentation: `asyncio.to_thread`](https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread) +/// - [Python documentation: `loop.run_in_executor`](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor) +/// - [aiofiles documentation: `aiofiles.os`](https://github.com/Tinche/aiofiles#usage) +/// - [AnyIO documentation: `anyio.Path`](https://anyio.readthedocs.io/en/stable/api.html#anyio.Path) +/// - [Trio documentation: `trio.Path`](https://trio.readthedocs.io/en/stable/reference-io.html#trio.Path) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct BlockingPathMethodInAsyncFunction { path_library: String, } @@ -56,7 +71,7 @@ impl Violation for BlockingPathMethodInAsyncFunction { #[derive_message_formats] fn message(&self) -> String { format!( - "Async functions should not use {path_library} methods, use trio.Path or anyio.path", + "Async functions should not perform blocking {path_library} operations", path_library = self.path_library ) } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_process_invocation.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_process_invocation.rs index 8ea20eb2b0..3a8c849076 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_process_invocation.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_process_invocation.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not create subprocesses with blocking methods. @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// asyncio.create_subprocess_shell(cmd) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct CreateSubprocessInAsyncFunction; impl Violation for CreateSubprocessInAsyncFunction { @@ -77,7 +78,7 @@ impl Violation for CreateSubprocessInAsyncFunction { /// asyncio.create_subprocess_shell(cmd) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct RunProcessInAsyncFunction; impl Violation for RunProcessInAsyncFunction { @@ -122,7 +123,7 @@ impl Violation for RunProcessInAsyncFunction { /// await asyncio.loop.run_in_executor(None, wait_for_process) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct WaitForProcessInAsyncFunction; impl Violation for WaitForProcessInAsyncFunction { diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_sleep.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_sleep.rs index a06ed76f6c..3ae9e73687 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_sleep.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_sleep.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that async functions do not call `time.sleep`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// await asyncio.sleep(1) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct BlockingSleepInAsyncFunction; impl Violation for BlockingSleepInAsyncFunction { diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/cancel_scope_no_checkpoint.rs b/crates/ruff_linter/src/rules/flake8_async/rules/cancel_scope_no_checkpoint.rs index 6eedc05514..c3303dc3ef 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/cancel_scope_no_checkpoint.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/cancel_scope_no_checkpoint.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{Expr, StmtWith, WithItem}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_async::helpers::MethodName; /// ## What it does @@ -45,7 +46,7 @@ use crate::rules::flake8_async::helpers::MethodName; /// - [`anyio` timeouts](https://anyio.readthedocs.io/en/stable/cancellation.html) /// - [`trio` timeouts](https://trio.readthedocs.io/en/stable/reference-core.html#cancellation-and-timeouts) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Suspicious)] pub(crate) struct CancelScopeNoCheckpoint { method_name: MethodName, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/long_sleep_not_forever.rs b/crates/ruff_linter/src/rules/flake8_async/rules/long_sleep_not_forever.rs index e64e9644a3..58a9d83ea0 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/long_sleep_not_forever.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/long_sleep_not_forever.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::rules::flake8_async::helpers::AsyncModule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -39,7 +40,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// This fix is marked as unsafe as it changes program behavior. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Style)] pub(crate) struct LongSleepNotForever { module: AsyncModule, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/sync_call.rs b/crates/ruff_linter/src/rules/flake8_async/rules/sync_call.rs index 2887a5560c..73139ef9fe 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/sync_call.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/sync_call.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::rules::flake8_async::helpers::MethodName; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -38,7 +39,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// This rule's fix is marked as unsafe, as adding an `await` to a function /// call changes its semantics and runtime behavior. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct TrioSyncCall { method_name: MethodName, } diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/yield_in_context_manager_in_async_generator.rs b/crates/ruff_linter/src/rules/flake8_async/rules/yield_in_context_manager_in_async_generator.rs index 6cec4c4283..b35779cd61 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/yield_in_context_manager_in_async_generator.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/yield_in_context_manager_in_async_generator.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `yield` inside a context manager in an async generator. @@ -66,7 +67,7 @@ use crate::checkers::ast::Checker; /// - [`contextlib.aclosing`](https://docs.python.org/3/library/contextlib.html#contextlib.aclosing) /// - [trio.as_safe_channel](https://trio.readthedocs.io/en/latest/reference-core.html#trio.as_safe_channel) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.16")] +#[violation_metadata(preview_since = "0.15.16", category = Category::Pedantic)] pub(crate) struct YieldInContextManagerInAsyncGenerator; impl Violation for YieldInContextManagerInAsyncGenerator { diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC110_ASYNC110.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-busy-wait_ASYNC110.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC110_ASYNC110.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-busy-wait_ASYNC110.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC109_ASYNC109_0.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-function-with-timeout_ASYNC109_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC109_ASYNC109_0.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-function-with-timeout_ASYNC109_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC109_ASYNC109_1.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-function-with-timeout_ASYNC109_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC109_ASYNC109_1.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-function-with-timeout_ASYNC109_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC115_ASYNC115.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-zero-sleep_ASYNC115.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC115_ASYNC115.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__async-zero-sleep_ASYNC115.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-http-call-httpx-in-async-function_ASYNC212.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-http-call-httpx-in-async-function_ASYNC212.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC210_ASYNC210.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-http-call-in-async-function_ASYNC210.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC210_ASYNC210.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-http-call-in-async-function_ASYNC210.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC250_ASYNC250.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-input-in-async-function_ASYNC250.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC250_ASYNC250.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-input-in-async-function_ASYNC250.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC230_ASYNC230.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-open-call-in-async-function_ASYNC230.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC230_ASYNC230.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-open-call-in-async-function_ASYNC230.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC240_ASYNC240.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-path-method-in-async-function_ASYNC240.py.snap similarity index 70% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC240_ASYNC240.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-path-method-in-async-function_ASYNC240.py.snap index 18bcfd35b2..3b1f99210e 100644 --- a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC240_ASYNC240.py.snap +++ b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-path-method-in-async-function_ASYNC240.py.snap @@ -1,7 +1,7 @@ --- source: crates/ruff_linter/src/rules/flake8_async/mod.rs --- -ASYNC240 Async functions should not use os.path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking os.path operations --> ASYNC240.py:67:5 | 65 | file = "file.txt" @@ -11,7 +11,7 @@ ASYNC240 Async functions should not use os.path methods, use trio.Path or anyio. 68 | os.path.exists(file) # ASYNC240 | -ASYNC240 Async functions should not use os.path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking os.path operations --> ASYNC240.py:68:5 | 67 | os.path.abspath(file) # ASYNC240 @@ -21,7 +21,7 @@ ASYNC240 Async functions should not use os.path methods, use trio.Path or anyio. 70 | async def pathlib_path_in_foo(): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:72:5 | 70 | async def pathlib_path_in_foo(): @@ -32,7 +32,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 74 | async def pathlib_path_in_foo(): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:78:5 | 77 | path = pathlib.Path("src/my_text.txt") @@ -42,7 +42,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 80 | async def inline_path_method_call(): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:81:5 | 80 | async def inline_path_method_call(): @@ -51,7 +51,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 82 | Path("src/my_text.txt").absolute().exists() # ASYNC240 | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:82:5 | 80 | async def inline_path_method_call(): @@ -62,7 +62,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 84 | async def aliased_path_in_foo(): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:88:5 | 87 | path = PathAlias("src/my_text.txt") @@ -72,7 +72,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 90 | global_path = Path("src/my_text.txt") | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:93:5 | 92 | async def global_path_in_foo(): @@ -82,7 +82,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 95 | async def path_as_simple_parameter_type(path: Path): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:96:5 | 95 | async def path_as_simple_parameter_type(path: Path): @@ -92,7 +92,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 98 | async def path_as_union_parameter_type(path: Path | None): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:99:5 | 98 | async def path_as_union_parameter_type(path: Path | None): @@ -102,7 +102,7 @@ ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or a 101 | async def path_as_optional_parameter_type(path: Optional[Path]): | -ASYNC240 Async functions should not use pathlib.Path methods, use trio.Path or anyio.path +ASYNC240 Async functions should not perform blocking pathlib.Path operations --> ASYNC240.py:102:5 | 101 | async def path_as_optional_parameter_type(path: Optional[Path]): diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC251_ASYNC251.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-sleep-in-async-function_ASYNC251.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC251_ASYNC251.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__blocking-sleep-in-async-function_ASYNC251.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__cancel-scope-no-checkpoint_ASYNC100.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__cancel-scope-no-checkpoint_ASYNC100.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC220_ASYNC22x.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__create-subprocess-in-async-function_ASYNC22x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC220_ASYNC22x.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__create-subprocess-in-async-function_ASYNC22x.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC116_ASYNC116.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__long-sleep-not-forever_ASYNC116.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC116_ASYNC116.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__long-sleep-not-forever_ASYNC116.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC221_ASYNC22x.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__run-process-in-async-function_ASYNC22x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC221_ASYNC22x.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__run-process-in-async-function_ASYNC22x.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC105_ASYNC105.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__trio-sync-call_ASYNC105.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC105_ASYNC105.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__trio-sync-call_ASYNC105.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC222_ASYNC22x.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__wait-for-process-in-async-function_ASYNC22x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC222_ASYNC22x.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__wait-for-process-in-async-function_ASYNC22x.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC119_ASYNC119.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__yield-in-context-manager-in-async-generator_ASYNC119.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC119_ASYNC119.py.snap rename to crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__yield-in-context-manager-in-async-generator_ASYNC119.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/mod.rs b/crates/ruff_linter/src/rules/flake8_bandit/mod.rs index f978ecc22d..2cb386c4a6 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/mod.rs @@ -88,7 +88,7 @@ mod tests { #[test_case(Rule::DjangoRawSql, Path::new("S611.py"))] #[test_case(Rule::TarfileUnsafeMembers, Path::new("S202.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_bandit").join(path).as_path(), &LinterSettings::for_rule(rule_code), @@ -106,11 +106,7 @@ mod tests { #[test_case(Rule::SuspiciousTelnetUsage, Path::new("S312.py"))] #[test_case(Rule::UnsafeYAMLLoad, Path::new("S506.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/assert_used.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/assert_used.rs index f208658e29..a7f0c8c8a8 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/assert_used.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/assert_used.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use ruff_text_size::{TextLen, TextRange}; use crate::Violation; +use crate::codes::Category; use crate::checkers::ast::Checker; @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// raise ValueError("Expected positive value.") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Restriction)] pub(crate) struct Assert; impl Violation for Assert { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/bad_file_permissions.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/bad_file_permissions.rs index af6716dee9..94c551c2bf 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/bad_file_permissions.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/bad_file_permissions.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_s103_extended_dangerous_bits_enabled; /// ## What it does @@ -42,7 +43,7 @@ use crate::preview::is_s103_extended_dangerous_bits_enabled; /// - [Python documentation: `stat`](https://docs.python.org/3/library/stat.html) /// - [Common Weakness Enumeration: CWE-732](https://cwe.mitre.org/data/definitions/732.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Security)] pub(crate) struct BadFilePermissions { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/django_extra.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/django_extra.rs index 3c33e38e9f..c7748a4850 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/django_extra.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/django_extra.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of Django's `extra` function where one or more arguments @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// - [Django documentation: SQL injection protection](https://docs.djangoproject.com/en/dev/topics/security/#sql-injection-protection) /// - [Common Weakness Enumeration: CWE-89](https://cwe.mitre.org/data/definitions/89.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Security)] pub(crate) struct DjangoExtra; impl Violation for DjangoExtra { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/django_raw_sql.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/django_raw_sql.rs index 16611b8e38..8a430650ff 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/django_raw_sql.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/django_raw_sql.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of Django's `RawSQL` function. @@ -25,7 +26,7 @@ use crate::checkers::ast::Checker; /// - [Django documentation: SQL injection protection](https://docs.djangoproject.com/en/dev/topics/security/#sql-injection-protection) /// - [Common Weakness Enumeration: CWE-89](https://cwe.mitre.org/data/definitions/89.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct DjangoRawSql; impl Violation for DjangoRawSql { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/exec_used.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/exec_used.rs index 442e322b93..4fb9f77dea 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/exec_used.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/exec_used.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the builtin `exec` function. @@ -22,7 +23,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `exec`](https://docs.python.org/3/library/functions.html#exec) /// - [Common Weakness Enumeration: CWE-78](https://cwe.mitre.org/data/definitions/78.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Security)] pub(crate) struct ExecBuiltin; impl Violation for ExecBuiltin { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/flask_debug_true.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/flask_debug_true.rs index df51a33599..2cb3c6c498 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/flask_debug_true.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/flask_debug_true.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `debug=True` in Flask. @@ -39,7 +40,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Flask documentation: Debug Mode](https://flask.palletsprojects.com/en/latest/quickstart/#debug-mode) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct FlaskDebugTrue; impl Violation for FlaskDebugTrue { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_bind_all_interfaces.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_bind_all_interfaces.rs index 91994a042e..c7a4ec5a62 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_bind_all_interfaces.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_bind_all_interfaces.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for hardcoded bindings to all network interfaces (`0.0.0.0`). @@ -27,7 +28,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Common Weakness Enumeration: CWE-200](https://cwe.mitre.org/data/definitions/200.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Security)] pub(crate) struct HardcodedBindAllInterfaces; impl Violation for HardcodedBindAllInterfaces { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_default.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_default.rs index db23388252..a66a360409 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_default.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_default.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal}; @@ -39,7 +40,7 @@ use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal /// ## References /// - [Common Weakness Enumeration: CWE-259](https://cwe.mitre.org/data/definitions/259.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Security)] pub(crate) struct HardcodedPasswordDefault { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_func_arg.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_func_arg.rs index 995c80e656..7021fdb5d8 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_func_arg.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_func_arg.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal}; @@ -35,7 +36,7 @@ use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal /// ## References /// - [Common Weakness Enumeration: CWE-259](https://cwe.mitre.org/data/definitions/259.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Security)] pub(crate) struct HardcodedPasswordFuncArg { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_string.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_string.rs index 5ffe5bdc8d..7c013f3a67 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_string.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_password_string.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal}; @@ -34,7 +35,7 @@ use crate::rules::flake8_bandit::helpers::{matches_password_name, string_literal /// ## References /// - [Common Weakness Enumeration: CWE-259](https://cwe.mitre.org/data/definitions/259.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Security)] pub(crate) struct HardcodedPasswordString { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs index 996708324c..2c984f19ce 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; static SQL_REGEX: LazyLock = LazyLock::new(|| { Regex::new( @@ -45,7 +46,7 @@ static SQL_REGEX: LazyLock = LazyLock::new(|| { /// - [B608: Test for SQL injection](https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html) /// - [psycopg3: Server-side binding](https://www.psycopg.org/psycopg3/docs/basic/from_pg2.html#server-side-binding) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Security)] pub(crate) struct HardcodedSQLExpression; impl Violation for HardcodedSQLExpression { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_tmp_directory.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_tmp_directory.rs index 055f16a5f6..7042283a47 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_tmp_directory.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_tmp_directory.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of hardcoded temporary file or directory paths. @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// - [Common Weakness Enumeration: CWE-379](https://cwe.mitre.org/data/definitions/379.html) /// - [Python documentation: `tempfile`](https://docs.python.org/3/library/tempfile.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Security)] pub(crate) struct HardcodedTempFile { string: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hashlib_insecure_hash_functions.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hashlib_insecure_hash_functions.rs index 9268e39e2f..51a6eb1b9f 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hashlib_insecure_hash_functions.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hashlib_insecure_hash_functions.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::string_literal; @@ -74,7 +75,7 @@ use crate::rules::flake8_bandit::helpers::string_literal; /// - [Common Weakness Enumeration: CWE-328](https://cwe.mitre.org/data/definitions/328.html) /// - [Common Weakness Enumeration: CWE-916](https://cwe.mitre.org/data/definitions/916.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.212")] +#[violation_metadata(stable_since = "v0.0.212", category = Category::Security)] pub(crate) struct HashlibInsecureHashFunction { library: String, string: String, diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/jinja2_autoescape_false.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/jinja2_autoescape_false.rs index 17aca8105f..1c8f5b84e0 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/jinja2_autoescape_false.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/jinja2_autoescape_false.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `jinja2` templates that use `autoescape=False`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// - [Jinja documentation: API](https://jinja.palletsprojects.com/en/latest/api/#autoescaping) /// - [Common Weakness Enumeration: CWE-94](https://cwe.mitre.org/data/definitions/94.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.220")] +#[violation_metadata(stable_since = "v0.0.220", category = Category::Security)] pub(crate) struct Jinja2AutoescapeFalse { value: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/logging_config_insecure_listen.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/logging_config_insecure_listen.rs index 54ad45d645..e23ca399e8 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/logging_config_insecure_listen.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/logging_config_insecure_listen.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for insecure `logging.config.listen` calls. @@ -25,7 +26,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `logging.config.listen()`](https://docs.python.org/3/library/logging.config.html#logging.config.listen) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Security)] pub(crate) struct LoggingConfigInsecureListen; impl Violation for LoggingConfigInsecureListen { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/mako_templates.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/mako_templates.rs index d0b9b3afc1..b641f5cc48 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/mako_templates.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/mako_templates.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the `mako` templates. @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// - [OpenStack security: Cross site scripting XSS](https://security.openstack.org/guidelines/dg_cross-site-scripting-xss.html) /// - [Common Weakness Enumeration: CWE-80](https://cwe.mitre.org/data/definitions/80.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct MakoTemplates; impl Violation for MakoTemplates { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/paramiko_calls.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/paramiko_calls.rs index ea85c1ea20..ce5b06c217 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/paramiko_calls.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/paramiko_calls.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `paramiko` calls. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// - [Common Weakness Enumeration: CWE-78](https://cwe.mitre.org/data/definitions/78.html) /// - [Paramiko documentation: `SSHClient.exec_command()`](https://docs.paramiko.org/en/stable/api/client.html#paramiko.client.SSHClient.exec_command) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.270")] +#[violation_metadata(stable_since = "v0.0.270", category = Category::Security)] pub(crate) struct ParamikoCall; impl Violation for ParamikoCall { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/request_with_no_cert_validation.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/request_with_no_cert_validation.rs index 5513751ff0..84b38246f3 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/request_with_no_cert_validation.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/request_with_no_cert_validation.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for HTTPS requests that disable SSL certificate checks. @@ -31,7 +32,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Common Weakness Enumeration: CWE-295](https://cwe.mitre.org/data/definitions/295.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Security)] pub(crate) struct RequestWithNoCertValidation { string: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/request_without_timeout.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/request_without_timeout.rs index f9ae386b02..c0fbeae817 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/request_without_timeout.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/request_without_timeout.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the Python `requests` or `httpx` module that omit the @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// - [Requests documentation: Timeouts](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts) /// - [httpx documentation: Timeouts](https://www.python-httpx.org/advanced/timeouts/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Security)] pub(crate) struct RequestWithoutTimeout { implicit: bool, module: String, diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs index 9569d72fa9..b286869981 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::Violation; +use crate::codes::Category; use crate::{ checkers::ast::Checker, registry::Rule, rules::flake8_bandit::helpers::string_literal, }; @@ -37,7 +38,7 @@ use crate::{ /// - [Python documentation: `subprocess` — Subprocess management](https://docs.python.org/3/library/subprocess.html) /// - [Common Weakness Enumeration: CWE-78](https://cwe.mitre.org/data/definitions/78.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct SubprocessPopenWithShellEqualsTrue { safety: Safety, is_exact: bool, @@ -88,7 +89,7 @@ impl Violation for SubprocessPopenWithShellEqualsTrue { /// /// [#4045]: https://github.com/astral-sh/ruff/issues/4045 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct SubprocessWithoutShellEqualsTrue; impl Violation for SubprocessWithoutShellEqualsTrue { @@ -127,7 +128,7 @@ impl Violation for SubprocessWithoutShellEqualsTrue { /// ## References /// - [Python documentation: Security Considerations](https://docs.python.org/3/library/subprocess.html#security-considerations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct CallWithShellEqualsTrue { is_exact: bool, } @@ -180,7 +181,7 @@ impl Violation for CallWithShellEqualsTrue { /// ## References /// - [Python documentation: `subprocess`](https://docs.python.org/3/library/subprocess.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct StartProcessWithAShell { safety: Safety, } @@ -226,7 +227,7 @@ impl Violation for StartProcessWithAShell { /// /// [S605]: https://docs.astral.sh/ruff/rules/start-process-with-a-shell #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct StartProcessWithNoShell; impl Violation for StartProcessWithNoShell { @@ -262,7 +263,7 @@ impl Violation for StartProcessWithNoShell { /// - [Python documentation: `subprocess.Popen()`](https://docs.python.org/3/library/subprocess.html#subprocess.Popen) /// - [Common Weakness Enumeration: CWE-426](https://cwe.mitre.org/data/definitions/426.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Security)] pub(crate) struct StartProcessWithPartialPath; impl Violation for StartProcessWithPartialPath { @@ -296,7 +297,7 @@ impl Violation for StartProcessWithPartialPath { /// ## References /// - [Common Weakness Enumeration: CWE-78](https://cwe.mitre.org/data/definitions/78.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Security)] pub(crate) struct UnixCommandWildcardInjection; impl Violation for UnixCommandWildcardInjection { @@ -334,9 +335,14 @@ fn is_trusted_input(arg: &Expr, semantic: &SemanticModel) -> bool { pub(crate) fn shell_injection(checker: &Checker, call: &ast::ExprCall) { let call_kind = get_call_kind(&call.func, checker.semantic()); let shell_keyword = find_shell_keyword(&call.arguments, checker.semantic()); + let command_argument = match call_kind { + Some(CallKind::Subprocess) => find_subprocess_argument(&call.arguments), + Some(CallKind::Shell | CallKind::NoShell) => call.arguments.args.first(), + None => None, + }; if matches!(call_kind, Some(CallKind::Subprocess)) { - if let Some(arg) = call.arguments.args.first() { + if let Some(arg) = command_argument { match shell_keyword { // S602 Some(ShellKeyword { @@ -397,11 +403,9 @@ pub(crate) fn shell_injection(checker: &Checker, call: &ast::ExprCall) { // S607 if checker.is_rule_enabled(Rule::StartProcessWithPartialPath) { - if call_kind.is_some() { - if let Some(arg) = call.arguments.args.first() { - if is_partial_path(arg) { - checker.report_diagnostic(StartProcessWithPartialPath, arg.range()); - } + if let Some(arg) = command_argument { + if is_partial_path(arg) { + checker.report_diagnostic(StartProcessWithPartialPath, arg.range()); } } } @@ -419,7 +423,7 @@ pub(crate) fn shell_injection(checker: &Checker, call: &ast::ExprCall) { ) ) { - if let Some(arg) = call.arguments.args.first() { + if let Some(arg) = command_argument { if is_wildcard_command(arg) { checker.report_diagnostic(UnixCommandWildcardInjection, arg.range()); } @@ -428,6 +432,18 @@ pub(crate) fn shell_injection(checker: &Checker, call: &ast::ExprCall) { } } +/// Return the command argument to a `subprocess` call. +fn find_subprocess_argument(arguments: &Arguments) -> Option<&Expr> { + arguments.find_argument_value("args", 0).or_else(|| { + // A starred first argument (`subprocess.run(*cmd)`) prevents us from locating the + // command with `find_argument_value`; treat it as untrusted input. + arguments + .args + .first() + .filter(|argument| argument.is_starred_expr()) + }) +} + #[derive(Copy, Clone, Debug)] enum CallKind { Subprocess, diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_insecure_version.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_insecure_version.rs index 44b90786e9..25a83e7267 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_insecure_version.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_insecure_version.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of SNMPv1 or SNMPv2. @@ -31,7 +32,7 @@ use crate::checkers::ast::Checker; /// - [Cybersecurity and Infrastructure Security Agency (CISA): Alert TA17-156A](https://www.cisa.gov/news-events/alerts/2017/06/05/reducing-risk-snmp-abuse) /// - [Common Weakness Enumeration: CWE-319](https://cwe.mitre.org/data/definitions/319.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.218")] +#[violation_metadata(stable_since = "v0.0.218", category = Category::Security)] pub(crate) struct SnmpInsecureVersion; impl Violation for SnmpInsecureVersion { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_weak_cryptography.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_weak_cryptography.rs index 4e9297fe4a..bde6b8a655 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_weak_cryptography.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/snmp_weak_cryptography.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the SNMPv3 protocol without encryption. @@ -29,7 +30,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Common Weakness Enumeration: CWE-319](https://cwe.mitre.org/data/definitions/319.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.218")] +#[violation_metadata(stable_since = "v0.0.218", category = Category::Security)] pub(crate) struct SnmpWeakCryptography; impl Violation for SnmpWeakCryptography { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssh_no_host_key_verification.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssh_no_host_key_verification.rs index 626da50782..940cce1757 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssh_no_host_key_verification.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssh_no_host_key_verification.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of policies disabling SSH verification in Paramiko. @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Paramiko documentation: set_missing_host_key_policy](https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.set_missing_host_key_policy) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct SSHNoHostKeyVerification; impl Violation for SSHNoHostKeyVerification { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_insecure_version.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_insecure_version.rs index 5d96ca27b7..2a6daffd3e 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_insecure_version.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_insecure_version.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function calls with parameters that indicate the use of insecure @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// ssl.wrap_socket(ssl_version=ssl.PROTOCOL_TLSv1_2) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct SslInsecureVersion { protocol: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_bad_defaults.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_bad_defaults.rs index 712e364532..4f641735b3 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_bad_defaults.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_bad_defaults.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, StmtFunctionDef}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function definitions with default arguments set to insecure SSL @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// def func(version=ssl.PROTOCOL_TLSv1_2): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct SslWithBadDefaults { protocol: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_no_version.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_no_version.rs index a5cfccaf6c..ff90e6700c 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_no_version.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/ssl_with_no_version.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for calls to `ssl.wrap_socket()` without an `ssl_version`. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// ssl.wrap_socket(ssl_version=ssl.PROTOCOL_TLSv1_2) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct SslWithNoVersion; impl Violation for SslWithNoVersion { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs index 41c09b6f70..f7b85ee623 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_suspicious_function_reference_enabled; /// ## What it does @@ -53,7 +54,7 @@ use crate::preview::is_suspicious_function_reference_enabled; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousPickleUsage; impl Violation for SuspiciousPickleUsage { @@ -105,7 +106,7 @@ impl Violation for SuspiciousPickleUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousMarshalUsage; impl Violation for SuspiciousMarshalUsage { @@ -156,7 +157,7 @@ impl Violation for SuspiciousMarshalUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousInsecureHashUsage; impl Violation for SuspiciousInsecureHashUsage { @@ -199,7 +200,7 @@ impl Violation for SuspiciousInsecureHashUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousInsecureCipherUsage; impl Violation for SuspiciousInsecureCipherUsage { @@ -244,7 +245,7 @@ impl Violation for SuspiciousInsecureCipherUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousInsecureCipherModeUsage; impl Violation for SuspiciousInsecureCipherModeUsage { @@ -294,7 +295,7 @@ impl Violation for SuspiciousInsecureCipherModeUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousMktempUsage; impl Violation for SuspiciousMktempUsage { @@ -335,7 +336,7 @@ impl Violation for SuspiciousMktempUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousEvalUsage; impl Violation for SuspiciousEvalUsage { @@ -389,7 +390,7 @@ impl Violation for SuspiciousEvalUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousMarkSafeUsage; impl Violation for SuspiciousMarkSafeUsage { @@ -442,7 +443,7 @@ impl Violation for SuspiciousMarkSafeUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousURLOpenUsage; impl Violation for SuspiciousURLOpenUsage { @@ -487,7 +488,7 @@ impl Violation for SuspiciousURLOpenUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousNonCryptographicRandomUsage; impl Violation for SuspiciousNonCryptographicRandomUsage { @@ -532,7 +533,7 @@ impl Violation for SuspiciousNonCryptographicRandomUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLCElementTreeUsage; impl Violation for SuspiciousXMLCElementTreeUsage { @@ -579,7 +580,7 @@ impl Violation for SuspiciousXMLCElementTreeUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLElementTreeUsage; impl Violation for SuspiciousXMLElementTreeUsage { @@ -626,7 +627,7 @@ impl Violation for SuspiciousXMLElementTreeUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLExpatReaderUsage; impl Violation for SuspiciousXMLExpatReaderUsage { @@ -673,7 +674,7 @@ impl Violation for SuspiciousXMLExpatReaderUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLExpatBuilderUsage; impl Violation for SuspiciousXMLExpatBuilderUsage { @@ -720,7 +721,7 @@ impl Violation for SuspiciousXMLExpatBuilderUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLSaxUsage; impl Violation for SuspiciousXMLSaxUsage { @@ -767,7 +768,7 @@ impl Violation for SuspiciousXMLSaxUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLMiniDOMUsage; impl Violation for SuspiciousXMLMiniDOMUsage { @@ -814,7 +815,7 @@ impl Violation for SuspiciousXMLMiniDOMUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousXMLPullDOMUsage; impl Violation for SuspiciousXMLPullDOMUsage { @@ -858,7 +859,7 @@ impl Violation for SuspiciousXMLPullDOMUsage { /// [preview]: https://docs.astral.sh/ruff/preview/ /// [deprecated]: https://pypi.org/project/defusedxml/0.8.0rc2/#defusedxml-lxml #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.12.0")] +#[violation_metadata(removed_since = "0.12.0", category = Category::Security)] pub(crate) struct SuspiciousXMLETreeUsage; impl Violation for SuspiciousXMLETreeUsage { @@ -905,7 +906,7 @@ impl Violation for SuspiciousXMLETreeUsage { /// [PEP 476]: https://peps.python.org/pep-0476/ /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousUnverifiedContextUsage; impl Violation for SuspiciousUnverifiedContextUsage { @@ -934,7 +935,7 @@ impl Violation for SuspiciousUnverifiedContextUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousTelnetUsage; impl Violation for SuspiciousTelnetUsage { @@ -960,7 +961,7 @@ impl Violation for SuspiciousTelnetUsage { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Security)] pub(crate) struct SuspiciousFTPLibUsage; impl Violation for SuspiciousFTPLibUsage { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_imports.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_imports.rs index 09ee508ed0..494385d6c2 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_imports.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_imports.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for imports of the `telnetlib` module. @@ -25,7 +26,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `telnetlib` - Telnet client](https://docs.python.org/3.12/library/telnetlib.html#module-telnetlib) /// - [PEP 594: `telnetlib`](https://peps.python.org/pep-0594/#telnetlib) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousTelnetlibImport; impl Violation for SuspiciousTelnetlibImport { @@ -52,7 +53,7 @@ impl Violation for SuspiciousTelnetlibImport { /// ## References /// - [Python documentation: `ftplib` - FTP protocol client](https://docs.python.org/3/library/ftplib.html) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousFtplibImport; impl Violation for SuspiciousFtplibImport { @@ -80,7 +81,7 @@ impl Violation for SuspiciousFtplibImport { /// ## References /// - [Python documentation: `pickle` — Python object serialization](https://docs.python.org/3/library/pickle.html) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousPickleImport; impl Violation for SuspiciousPickleImport { @@ -102,7 +103,7 @@ impl Violation for SuspiciousPickleImport { /// import subprocess /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousSubprocessImport; impl Violation for SuspiciousSubprocessImport { @@ -126,7 +127,7 @@ impl Violation for SuspiciousSubprocessImport { /// import xml.etree.cElementTree /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlEtreeImport; impl Violation for SuspiciousXmlEtreeImport { @@ -150,7 +151,7 @@ impl Violation for SuspiciousXmlEtreeImport { /// import xml.sax /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlSaxImport; impl Violation for SuspiciousXmlSaxImport { @@ -174,7 +175,7 @@ impl Violation for SuspiciousXmlSaxImport { /// import xml.dom.expatbuilder /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlExpatImport; impl Violation for SuspiciousXmlExpatImport { @@ -198,7 +199,7 @@ impl Violation for SuspiciousXmlExpatImport { /// import xml.dom.minidom /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlMinidomImport; impl Violation for SuspiciousXmlMinidomImport { @@ -222,7 +223,7 @@ impl Violation for SuspiciousXmlMinidomImport { /// import xml.dom.pulldom /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlPulldomImport; impl Violation for SuspiciousXmlPulldomImport { @@ -253,7 +254,7 @@ impl Violation for SuspiciousXmlPulldomImport { /// /// [deprecated]: https://github.com/tiran/defusedxml/blob/c7445887f5e1bcea470a16f61369d29870cfcfe1/README.md#defusedxmllxml #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.3.0")] +#[violation_metadata(removed_since = "v0.3.0", category = Category::Security)] pub(crate) struct SuspiciousLxmlImport; impl Violation for SuspiciousLxmlImport { @@ -277,7 +278,7 @@ impl Violation for SuspiciousLxmlImport { /// import xmlrpc /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousXmlrpcImport; impl Violation for SuspiciousXmlrpcImport { @@ -304,7 +305,7 @@ impl Violation for SuspiciousXmlrpcImport { /// ## References /// - [httpoxy website](https://httpoxy.org/) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousHttpoxyImport; impl Violation for SuspiciousHttpoxyImport { @@ -333,7 +334,7 @@ impl Violation for SuspiciousHttpoxyImport { /// ## References /// - [Buffer Overflow Issue](https://github.com/pycrypto/pycrypto/issues/176) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousPycryptoImport; impl Violation for SuspiciousPycryptoImport { @@ -359,7 +360,7 @@ impl Violation for SuspiciousPycryptoImport { /// ## References /// - [Buffer Overflow Issue](https://github.com/pycrypto/pycrypto/issues/176) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Security)] pub(crate) struct SuspiciousPyghmiImport; impl Violation for SuspiciousPyghmiImport { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/tarfile_unsafe_members.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/tarfile_unsafe_members.rs index 92d58e8448..297f5cebfb 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/tarfile_unsafe_members.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/tarfile_unsafe_members.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `tarfile.extractall`. @@ -38,7 +39,7 @@ use crate::checkers::ast::Checker; /// /// [PEP 706]: https://peps.python.org/pep-0706/#backporting-forward-compatibility #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct TarfileUnsafeMembers; impl Violation for TarfileUnsafeMembers { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_continue.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_continue.rs index 85e7e4cdb6..0f0eb0ae48 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_continue.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_continue.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::is_untyped_exception; /// ## What it does @@ -45,7 +46,7 @@ use crate::rules::flake8_bandit::helpers::is_untyped_exception; /// - [Common Weakness Enumeration: CWE-703](https://cwe.mitre.org/data/definitions/703.html) /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Suspicious)] pub(crate) struct TryExceptContinue; impl Violation for TryExceptContinue { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_pass.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_pass.rs index 383f51f957..8c083f5433 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_pass.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/try_except_pass.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bandit::helpers::is_untyped_exception; /// ## What it does @@ -41,7 +42,7 @@ use crate::rules::flake8_bandit::helpers::is_untyped_exception; /// - [Common Weakness Enumeration: CWE-703](https://cwe.mitre.org/data/definitions/703.html) /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.237")] +#[violation_metadata(stable_since = "v0.0.237", category = Category::Suspicious)] pub(crate) struct TryExceptPass; impl Violation for TryExceptPass { diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_markup_use.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_markup_use.rs index d325649603..45604e6fdd 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_markup_use.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_markup_use.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{Modules, SemanticModel}; use ruff_text_size::Ranged; use crate::Violation; +use crate::codes::Category; use crate::{checkers::ast::Checker, settings::LinterSettings}; /// ## What it does @@ -75,7 +76,7 @@ use crate::{checkers::ast::Checker, settings::LinterSettings}; /// [markupsafe-markup]: https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup /// [flake8-markupsafe]: https://github.com/vmagamedov/flake8-markupsafe #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Security)] pub(crate) struct UnsafeMarkupUse { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_yaml_load.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_yaml_load.rs index 188670c2fb..d53f517e58 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_yaml_load.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/unsafe_yaml_load.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_baseloader_safe_in_yaml_load_enabled; /// ## What it does @@ -36,7 +37,7 @@ use crate::preview::is_baseloader_safe_in_yaml_load_enabled; /// - [PyYAML documentation: Loading YAML](https://pyyaml.org/wiki/PyYAMLDocumentation) /// - [Common Weakness Enumeration: CWE-20](https://cwe.mitre.org/data/definitions/20.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.212")] +#[violation_metadata(stable_since = "v0.0.212", category = Category::Security)] pub(crate) struct UnsafeYAMLLoad { pub loader: Option, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/weak_cryptographic_key.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/weak_cryptographic_key.rs index 956d90f2ae..4f01fd0633 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/weak_cryptographic_key.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/weak_cryptographic_key.rs @@ -6,6 +6,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of cryptographic keys with vulnerable key sizes. @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [CSRC: Transitioning the Use of Cryptographic Algorithms and Key Lengths](https://csrc.nist.gov/pubs/sp/800/131/a/r2/final) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Security)] pub(crate) struct WeakCryptographicKey { cryptographic_key: CryptographicKey, } diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S101_S101.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__assert_S101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S101_S101.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__assert_S101.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S103_S103.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__bad-file-permissions_S103.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S103_S103.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__bad-file-permissions_S103.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__call-with-shell-equals-true_S604.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__call-with-shell-equals-true_S604.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S610_S610.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__django-extra_S610.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S610_S610.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__django-extra_S610.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S611_S611.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__django-raw-sql_S611.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S611_S611.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__django-raw-sql_S611.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S102_S102.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__exec-builtin_S102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S102_S102.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__exec-builtin_S102.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S201_S201.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__flask-debug-true_S201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S201_S201.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__flask-debug-true_S201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S104_S104.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-bind-all-interfaces_S104.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S104_S104.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-bind-all-interfaces_S104.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S107_S107.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-default_S107.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S107_S107.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-default_S107.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S106_S106.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-func-arg_S106.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S106_S106.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-func-arg_S106.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S105_S105.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-string_S105.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S105_S105.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-password-string_S105.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S608_S608.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-sql-expression_S608.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S608_S608.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-sql-expression_S608.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S108_S108.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-temp-file_S108.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S108_S108.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hardcoded-temp-file_S108.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S324_S324.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hashlib-insecure-hash-function_S324.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S324_S324.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__hashlib-insecure-hash-function_S324.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S701_S701.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__jinja2-autoescape-false_S701.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S701_S701.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__jinja2-autoescape-false_S701.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S612_S612.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__logging-config-insecure-listen_S612.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S612_S612.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__logging-config-insecure-listen_S612.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S702_S702.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__mako-templates_S702.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S702_S702.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__mako-templates_S702.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S601_S601.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__paramiko-call_S601.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S601_S601.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__paramiko-call_S601.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S103_S103.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__bad-file-permissions_S103.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S103_S103.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__bad-file-permissions_S103.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S307_S307.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-eval-usage_S307.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S307_S307.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-eval-usage_S307.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S308_S308.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-mark-safe-usage_S308.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S308_S308.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-mark-safe-usage_S308.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S311_S311.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-non-cryptographic-random-usage_S311.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S311_S311.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-non-cryptographic-random-usage_S311.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S301_S301.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-pickle-usage_S301.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S301_S301.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-pickle-usage_S301.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S312_S312.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-telnet-usage_S312.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S312_S312.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-telnet-usage_S312.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S310_S310.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-url-open-usage_S310.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S310_S310.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__suspicious-url-open-usage_S310.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S506_S506.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__unsafe-yaml-load_S506.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__S506_S506.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__preview__unsafe-yaml-load_S506.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S501_S501.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__request-with-no-cert-validation_S501.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S501_S501.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__request-with-no-cert-validation_S501.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S113_S113.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__request-without-timeout_S113.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S113_S113.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__request-without-timeout_S113.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S508_S508.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__snmp-insecure-version_S508.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S508_S508.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__snmp-insecure-version_S508.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S509_S509.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__snmp-weak-cryptography_S509.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S509_S509.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__snmp-weak-cryptography_S509.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S507_S507.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssh-no-host-key-verification_S507.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S507_S507.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssh-no-host-key-verification_S507.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S502_S502.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-insecure-version_S502.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S502_S502.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-insecure-version_S502.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S503_S503.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-with-bad-defaults_S503.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S503_S503.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-with-bad-defaults_S503.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S504_S504.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-with-no-version_S504.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S504_S504.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__ssl-with-no-version_S504.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S605_S605.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-a-shell_S605.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S605_S605.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-a-shell_S605.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S606_S606.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-no-shell_S606.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S606_S606.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-no-shell_S606.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S607_S607.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-partial-path_S607.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S607_S607.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__start-process-with-partial-path_S607.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__subprocess-popen-with-shell-equals-true_S602.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__subprocess-popen-with-shell-equals-true_S602.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S603_S603.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__subprocess-without-shell-equals-true_S603.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S603_S603.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__subprocess-without-shell-equals-true_S603.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S307_S307.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-eval-usage_S307.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S307_S307.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-eval-usage_S307.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S402_S402.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-ftplib-import_S402.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S402_S402.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-ftplib-import_S402.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S401_S401.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-ftplib-import_S402.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S401_S401.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-ftplib-import_S402.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S412_S412.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-httpoxy-import_S412.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S412_S412.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-httpoxy-import_S412.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S402_S402.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-httpoxy-import_S412.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S402_S402.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-httpoxy-import_S412.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S410_S410.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-lxml-import_S410.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S410_S410.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-lxml-import_S410.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S403_S403.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-lxml-import_S410.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S403_S403.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-lxml-import_S410.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S308_S308.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-mark-safe-usage_S308.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S308_S308.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-mark-safe-usage_S308.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S311_S311.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-non-cryptographic-random-usage_S311.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S311_S311.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-non-cryptographic-random-usage_S311.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S403_S403.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-import_S403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S403_S403.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-import_S403.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S404_S404.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-import_S403.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S404_S404.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-import_S403.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S301_S301.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-usage_S301.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S301_S301.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pickle-usage_S301.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S413_S413.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pycrypto-import_S413.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S413_S413.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pycrypto-import_S413.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S405_S405.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pycrypto-import_S413.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S405_S405.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pycrypto-import_S413.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S415_S415.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pyghmi-import_S415.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S415_S415.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pyghmi-import_S415.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S406_S406.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pyghmi-import_S415.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S406_S406.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-pyghmi-import_S415.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S404_S404.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-subprocess-import_S404.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S404_S404.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-subprocess-import_S404.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S407_S407.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-subprocess-import_S404.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S407_S407.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-subprocess-import_S404.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S312_S312.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnet-usage_S312.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S312_S312.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnet-usage_S312.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S401_S401.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnetlib-import_S401.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S401_S401.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnetlib-import_S401.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnetlib-import_S401.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-telnetlib-import_S401.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S310_S310.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-url-open-usage_S310.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S310_S310.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-url-open-usage_S310.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S405_S405.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-etree-import_S405.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S405_S405.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-etree-import_S405.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408_type_checking.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-etree-import_S405.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408_type_checking.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-etree-import_S405.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S407_S407.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-expat-import_S407.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S407_S407.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-expat-import_S407.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S409_S409.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-expat-import_S407.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S409_S409.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-expat-import_S407.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S408_S408.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S410_S410.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S410_S410.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S411_S411.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408_type_checking.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S411_S411.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-minidom-import_S408_type_checking.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S409_S409.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-pulldom-import_S409.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S409_S409.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-pulldom-import_S409.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S412_S412.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-pulldom-import_S409.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S412_S412.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-pulldom-import_S409.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S406_S406.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-sax-import_S406.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S406_S406.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-sax-import_S406.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S413_S413.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-sax-import_S406.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S413_S413.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xml-sax-import_S406.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S411_S411.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xmlrpc-import_S411.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S411_S411.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xmlrpc-import_S411.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S415_S415.pyi.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xmlrpc-import_S411.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S415_S415.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__suspicious-xmlrpc-import_S411.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S202_S202.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__tarfile-unsafe-members_S202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S202_S202.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__tarfile-unsafe-members_S202.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S112_S112.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__try-except-continue_S112.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S112_S112.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__try-except-continue_S112.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S110_S110.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__try-except-pass_S110.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S110_S110.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__try-except-pass_S110.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__unix-command-wildcard-injection_S609.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__unix-command-wildcard-injection_S609.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S506_S506.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__unsafe-yaml-load_S506.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S506_S506.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__unsafe-yaml-load_S506.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S505_S505.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__weak-cryptographic-key_S505.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S505_S505.py.snap rename to crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__weak-cryptographic-key_S505.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_blind_except/mod.rs b/crates/ruff_linter/src/rules/flake8_blind_except/mod.rs index 658908efb6..9488f444f5 100644 --- a/crates/ruff_linter/src/rules/flake8_blind_except/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_blind_except/mod.rs @@ -14,7 +14,7 @@ mod tests { #[test_case(Rule::BlindExcept, Path::new("BLE.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_blind_except").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_blind_except/rules/blind_except.rs b/crates/ruff_linter/src/rules/flake8_blind_except/rules/blind_except.rs index f62788d800..7cfe27bd15 100644 --- a/crates/ruff_linter/src/rules/flake8_blind_except/rules/blind_except.rs +++ b/crates/ruff_linter/src/rules/flake8_blind_except/rules/blind_except.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_logging::helpers::is_logger_method_name; /// ## What it does @@ -67,7 +68,7 @@ use crate::rules::flake8_logging::helpers::is_logger_method_name; /// - [Python documentation: Exception hierarchy](https://docs.python.org/3/library/exceptions.html#exception-hierarchy) /// - [PEP 8: Programming Recommendations on bare `except`](https://peps.python.org/pep-0008/#programming-recommendations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.127")] +#[violation_metadata(stable_since = "v0.0.127", category = Category::Suspicious)] pub(crate) struct BlindExcept { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_blind_except/snapshots/ruff_linter__rules__flake8_blind_except__tests__BLE001_BLE.py.snap b/crates/ruff_linter/src/rules/flake8_blind_except/snapshots/ruff_linter__rules__flake8_blind_except__tests__blind-except_BLE.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_blind_except/snapshots/ruff_linter__rules__flake8_blind_except__tests__BLE001_BLE.py.snap rename to crates/ruff_linter/src/rules/flake8_blind_except/snapshots/ruff_linter__rules__flake8_blind_except__tests__blind-except_BLE.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/mod.rs b/crates/ruff_linter/src/rules/flake8_boolean_trap/mod.rs index cd7832181c..0797601cf5 100644 --- a/crates/ruff_linter/src/rules/flake8_boolean_trap/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_boolean_trap/mod.rs @@ -19,7 +19,7 @@ mod tests { #[test_case(Rule::BooleanDefaultValuePositionalArgument, Path::new("FBT.py"))] #[test_case(Rule::BooleanPositionalValueInCall, Path::new("FBT.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_boolean_trap").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_default_value_positional_argument.rs b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_default_value_positional_argument.rs index d20c770445..e0d973f634 100644 --- a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_default_value_positional_argument.rs +++ b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_default_value_positional_argument.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::visibility; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_boolean_trap::helpers::{ add_liskov_substitution_principle_help, is_allowed_func_def, }; @@ -99,7 +100,7 @@ use crate::rules::flake8_boolean_trap::helpers::{ /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.127")] +#[violation_metadata(stable_since = "v0.0.127", category = Category::Pedantic)] pub(crate) struct BooleanDefaultValuePositionalArgument; impl Violation for BooleanDefaultValuePositionalArgument { diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_positional_value_in_call.rs b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_positional_value_in_call.rs index 4a62e65923..91c8dc1a1f 100644 --- a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_positional_value_in_call.rs +++ b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_positional_value_in_call.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_boolean_trap::helpers::allow_boolean_trap; /// ## What it does @@ -42,7 +43,7 @@ use crate::rules::flake8_boolean_trap::helpers::allow_boolean_trap; /// - [Python documentation: Calls](https://docs.python.org/3/reference/expressions.html#calls) /// - [_How to Avoid “The Boolean Trap”_ by Adam Johnson](https://adamj.eu/tech/2021/07/10/python-type-hints-how-to-avoid-the-boolean-trap/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.127")] +#[violation_metadata(stable_since = "v0.0.127", category = Category::Pedantic)] pub(crate) struct BooleanPositionalValueInCall; impl Violation for BooleanPositionalValueInCall { diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_type_hint_positional_argument.rs b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_type_hint_positional_argument.rs index dd3363fee6..e2657a69df 100644 --- a/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_type_hint_positional_argument.rs +++ b/crates/ruff_linter/src/rules/flake8_boolean_trap/rules/boolean_type_hint_positional_argument.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::visibility; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_boolean_trap::helpers::{ add_liskov_substitution_principle_help, is_allowed_func_def, }; @@ -98,7 +99,7 @@ use crate::rules::flake8_boolean_trap::helpers::{ /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.127")] +#[violation_metadata(stable_since = "v0.0.127", category = Category::Pedantic)] pub(crate) struct BooleanTypeHintPositionalArgument; impl Violation for BooleanTypeHintPositionalArgument { diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT002_FBT.py.snap b/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-default-value-positional-argument_FBT.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT002_FBT.py.snap rename to crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-default-value-positional-argument_FBT.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT003_FBT.py.snap b/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-positional-value-in-call_FBT.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT003_FBT.py.snap rename to crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-positional-value-in-call_FBT.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT001_FBT.py.snap b/crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-type-hint-positional-argument_FBT.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__FBT001_FBT.py.snap rename to crates/ruff_linter/src/rules/flake8_boolean_trap/snapshots/ruff_linter__rules__flake8_boolean_trap__tests__boolean-type-hint-positional-argument_FBT.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index b145617f79..7a2cc0413b 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -88,7 +88,7 @@ mod tests { #[test_case(Rule::BatchedWithoutExplicitStrict, Path::new("B911.py"))] #[test_case(Rule::MapWithoutExplicitStrict, Path::new("B912.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_bugbear").join(path).as_path(), &LinterSettings::for_rule(rule_code), @@ -109,11 +109,7 @@ mod tests { #[test_case(Rule::MutableArgumentDefault, Path::new("B006_B008.py"))] #[test_case(Rule::MutableArgumentDefault, Path::new("B006_1.pyi"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_bugbear").join(path).as_path(), &LinterSettings::for_rule(rule_code) @@ -146,7 +142,7 @@ mod tests { ) -> Result<()> { let snapshot = format!( "{}_py{}{}_{}", - rule_code.noqa_code(), + rule_code.name(), target_version.major, target_version.minor, path.to_string_lossy(), diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/abstract_base_class.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/abstract_base_class.rs index 2b57ee35e1..3d85da861f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/abstract_base_class.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/abstract_base_class.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; /// ## What it does @@ -54,7 +55,7 @@ use crate::registry::Rule; /// - [Python documentation: `abc`](https://docs.python.org/3/library/abc.html) /// - [Python documentation: `typing.ClassVar`](https://docs.python.org/3/library/typing.html#typing.ClassVar) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.118")] +#[violation_metadata(stable_since = "v0.0.118", category = Category::Suspicious)] pub(crate) struct AbstractBaseClassWithoutAbstractMethod { name: String, } @@ -100,7 +101,7 @@ impl Violation for AbstractBaseClassWithoutAbstractMethod { /// ## References /// - [Python documentation: `abc`](https://docs.python.org/3/library/abc.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.118")] +#[violation_metadata(stable_since = "v0.0.118", category = Category::Suspicious)] pub(crate) struct EmptyMethodWithoutAbstractDecorator { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_false.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_false.rs index bfbac0c464..9bf8c33a94 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_false.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_false.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::helpers::is_const_false; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -35,7 +36,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `assert`](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.67")] +#[violation_metadata(stable_since = "v0.0.67", category = Category::Pedantic)] pub(crate) struct AssertFalse; impl AlwaysFixableViolation for AssertFalse { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs index af1b46aa01..9d87434c4c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `assertRaises` and `pytest.raises` context managers that catch @@ -29,7 +30,7 @@ use crate::checkers::ast::Checker; /// self.assertRaises(SomeSpecificException, foo) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.83")] +#[violation_metadata(stable_since = "v0.0.83", category = Category::Suspicious)] pub(crate) struct AssertRaisesException { exception: ExceptionKind, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assignment_to_os_environ.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assignment_to_os_environ.rs index f0ca244999..14f5132c79 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assignment_to_os_environ.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assignment_to_os_environ.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for assignments to `os.environ`. @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `os.environ`](https://docs.python.org/3/library/os.html#os.environ) /// - [Python documentation: `subprocess.Popen`](https://docs.python.org/3/library/subprocess.html#subprocess.Popen) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Suspicious)] pub(crate) struct AssignmentToOsEnviron; impl Violation for AssignmentToOsEnviron { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/batched_without_explicit_strict.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/batched_without_explicit_strict.rs index 8b7a75b39b..03294a277b 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/batched_without_explicit_strict.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/batched_without_explicit_strict.rs @@ -4,6 +4,7 @@ use ruff_python_ast::PythonVersion; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bugbear::helpers::is_infinite_iterable; use crate::{FixAvailability, Violation}; @@ -50,7 +51,7 @@ use crate::{FixAvailability, Violation}; /// ## References /// - [Python documentation: `batched`](https://docs.python.org/3/library/itertools.html#batched) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Pedantic)] pub(crate) struct BatchedWithoutExplicitStrict; impl Violation for BatchedWithoutExplicitStrict { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/cached_instance_method.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/cached_instance_method.rs index 024bafeb72..be7b5e2b4c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/cached_instance_method.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/cached_instance_method.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the `functools.lru_cache` and `functools.cache` @@ -71,7 +72,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) /// - [don't lru_cache methods!](https://www.youtube.com/watch?v=sVjtp6tGo0g) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.114")] +#[violation_metadata(stable_since = "v0.0.114", category = Category::Suspicious)] pub(crate) struct CachedInstanceMethod; impl Violation for CachedInstanceMethod { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/class_as_data_structure.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/class_as_data_structure.rs index 1652670db7..ed9e81cfa9 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/class_as_data_structure.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/class_as_data_structure.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use ruff_python_ast::PythonVersion; /// ## What it does @@ -34,7 +35,7 @@ use ruff_python_ast::PythonVersion; /// y: float /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.0")] +#[violation_metadata(preview_since = "0.9.0", category = Category::Pedantic)] pub(crate) struct ClassAsDataStructure; impl Violation for ClassAsDataStructure { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/delattr_with_constant.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/delattr_with_constant.rs index b47f406746..7df1aae796 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/delattr_with_constant.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/delattr_with_constant.rs @@ -7,6 +7,7 @@ use ruff_python_stdlib::identifiers::{is_identifier, is_mangled_private}; use unicode_normalization::UnicodeNormalization; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation: `delattr`](https://docs.python.org/3/library/functions.html#delattr) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.6")] +#[violation_metadata(preview_since = "0.15.6", category = Category::Complexity)] pub(crate) struct DelAttrWithConstant; impl AlwaysFixableViolation for DelAttrWithConstant { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_exceptions.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_exceptions.rs index 7c65e0f9e8..b2401fbe40 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_exceptions.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_exceptions.rs @@ -7,6 +7,7 @@ use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Violation}; @@ -43,7 +44,7 @@ use crate::{Edit, Fix}; /// ## References /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.67")] +#[violation_metadata(stable_since = "v0.0.67", category = Category::Correctness)] pub(crate) struct DuplicateTryBlockException { name: String, is_star: bool, @@ -91,7 +92,7 @@ impl Violation for DuplicateTryBlockException { /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) /// - [Python documentation: Exception hierarchy](https://docs.python.org/3/library/exceptions.html#exception-hierarchy) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.67")] +#[violation_metadata(stable_since = "v0.0.67", category = Category::Correctness)] pub(crate) struct DuplicateHandlerException { pub names: Vec, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_value.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_value.rs index 5ac6ef68a4..92e928d4b0 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_value.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/duplicate_value.rs @@ -8,6 +8,7 @@ use ruff_python_ast::comparable::HashableExpr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::{FixAvailability, Violation}; @@ -43,7 +44,7 @@ use crate::{FixAvailability, Violation}; /// } /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct DuplicateValue { value: String, existing: String, diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_empty_tuple.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_empty_tuple.rs index 2b4a6ab243..b32a152d96 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_empty_tuple.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_empty_tuple.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for exception handlers that catch an empty tuple. @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.250")] +#[violation_metadata(stable_since = "v0.0.250", category = Category::Correctness)] pub(crate) struct ExceptWithEmptyTuple { is_star: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_non_exception_classes.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_non_exception_classes.rs index 3da20df439..74f79e7de0 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_non_exception_classes.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/except_with_non_exception_classes.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for exception handlers that catch non-exception classes. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) /// - [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.255")] +#[violation_metadata(stable_since = "v0.0.255", category = Category::Correctness)] pub(crate) struct ExceptWithNonExceptionClasses { is_star: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_docstring.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_docstring.rs index 305249b7bc..8879ba85e3 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_docstring.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_docstring.rs @@ -5,6 +5,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for docstrings that are written via f-strings. @@ -31,7 +32,7 @@ use crate::checkers::ast::Checker; /// - [PEP 257 – Docstring Conventions](https://peps.python.org/pep-0257/) /// - [Python documentation: Formatted string literals](https://docs.python.org/3/reference/lexical_analysis.html#f-strings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Correctness)] pub(crate) struct FStringDocstring; impl Violation for FStringDocstring { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_call_in_argument_default.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_call_in_argument_default.rs index 2a775dd6a8..b02fe8e831 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_call_in_argument_default.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_call_in_argument_default.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function calls in default function arguments. @@ -62,7 +63,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.flake8-bugbear.extend-immutable-calls` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Suspicious)] pub(crate) struct FunctionCallInDefaultArgument { name: Option, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs index 06778e5491..dec25b7a45 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/function_uses_loop_variable.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function definitions that use a loop variable. @@ -43,7 +44,7 @@ use crate::checkers::ast::Checker; /// - [The Hitchhiker's Guide to Python: Late Binding Closures](https://docs.python-guide.org/writing/gotchas/#late-binding-closures) /// - [Python documentation: `functools.partial`](https://docs.python.org/3/library/functools.html#functools.partial) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.139")] +#[violation_metadata(stable_since = "v0.0.139", category = Category::Suspicious)] pub(crate) struct FunctionUsesLoopVariable { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/getattr_with_constant.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/getattr_with_constant.rs index cc5209bde0..1a7deb3118 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/getattr_with_constant.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/getattr_with_constant.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use unicode_normalization::UnicodeNormalization; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -51,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `getattr`](https://docs.python.org/3/library/functions.html#getattr) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.110")] +#[violation_metadata(stable_since = "v0.0.110", category = Category::Complexity)] pub(crate) struct GetAttrWithConstant; impl AlwaysFixableViolation for GetAttrWithConstant { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/jump_statement_in_finally.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/jump_statement_in_finally.rs index 944efa0432..220c9d519f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/jump_statement_in_finally.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/jump_statement_in_finally.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `break`, `continue`, and `return` statements in `finally` @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `try` statement](https://docs.python.org/3/reference/compound_stmts.html#the-try-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.116")] +#[violation_metadata(stable_since = "v0.0.116", category = Category::Suspicious)] pub(crate) struct JumpStatementInFinally { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_iterator_mutation.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_iterator_mutation.rs index 2efeaa8a62..2354fdac94 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_iterator_mutation.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_iterator_mutation.rs @@ -14,6 +14,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; /// ## What it does @@ -37,7 +38,7 @@ use crate::fix::snippet::SourceCodeSnippet; /// ## References /// - [Python documentation: Mutable Sequence Types](https://docs.python.org/3/library/stdtypes.html#typesseq-mutable) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.7")] +#[violation_metadata(preview_since = "v0.3.7", category = Category::Suspicious)] pub(crate) struct LoopIteratorMutation { name: Option, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_variable_overrides_iterator.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_variable_overrides_iterator.rs index a7e47fb2f9..c56fe887b7 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_variable_overrides_iterator.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/loop_variable_overrides_iterator.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for loop control variables that override the loop iterable. @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `for` statement](https://docs.python.org/3/reference/compound_stmts.html#the-for-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.121")] +#[violation_metadata(stable_since = "v0.0.121", category = Category::Suspicious)] pub(crate) struct LoopVariableOverridesIterator { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/map_without_explicit_strict.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/map_without_explicit_strict.rs index f9bc46860c..216cda0c2f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/map_without_explicit_strict.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/map_without_explicit_strict.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_argument; use crate::rules::flake8_bugbear::helpers::any_infinite_iterables; use crate::{AlwaysFixableViolation, Applicability, Fix}; @@ -46,7 +47,7 @@ use crate::{AlwaysFixableViolation, Applicability, Fix}; /// /// [What's New in Python 3.14]: https://docs.python.org/dev/whatsnew/3.14.html #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Pedantic)] pub(crate) struct MapWithoutExplicitStrict; impl AlwaysFixableViolation for MapWithoutExplicitStrict { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs index 790e9ca506..c71a27a7cc 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs @@ -13,6 +13,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::{ is_b006_check_guaranteed_mutable_expr_enabled, is_b006_unsafe_fix_preserve_assignment_expr_enabled, @@ -79,7 +80,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Default Argument Values](https://docs.python.org/3/tutorial/controlflow.html#default-argument-values) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.92")] +#[violation_metadata(stable_since = "v0.0.92", category = Category::Suspicious)] pub(crate) struct MutableArgumentDefault; impl Violation for MutableArgumentDefault { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_contextvar_default.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_contextvar_default.rs index a81e1b2c3f..de63a3e704 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_contextvar_default.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_contextvar_default.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of mutable objects as `ContextVar` defaults. @@ -54,7 +55,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `contextvars` — Context Variables](https://docs.python.org/3/library/contextvars.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Suspicious)] pub(crate) struct MutableContextvarDefault; impl Violation for MutableContextvarDefault { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/no_explicit_stacklevel.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/no_explicit_stacklevel.rs index 88583651d9..b7cb5d7d41 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/no_explicit_stacklevel.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/no_explicit_stacklevel.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use crate::{checkers::ast::Checker, fix::edits::add_argument}; @@ -46,7 +47,7 @@ use crate::{checkers::ast::Checker, fix::edits::add_argument}; /// ///[Python documentation]: https://docs.python.org/3/library/warnings.html#warnings.warn #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Pedantic)] pub(crate) struct NoExplicitStacklevel; impl AlwaysFixableViolation for NoExplicitStacklevel { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_literal.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_literal.rs index 1fd6b3123a..956ddc8a98 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_literal.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_literal.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `raise` statements that raise a literal value. @@ -27,7 +28,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `raise` statement](https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Correctness)] pub(crate) struct RaiseLiteral; impl Violation for RaiseLiteral { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_without_from_inside_except.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_without_from_inside_except.rs index b918074aea..b46c9fbfff 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_without_from_inside_except.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/raise_without_from_inside_except.rs @@ -7,6 +7,7 @@ use ruff_python_ast::statement_visitor::StatementVisitor; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `raise` statements in exception handlers that lack a `from` @@ -49,7 +50,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `raise` statement](https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.138")] +#[violation_metadata(stable_since = "v0.0.138", category = Category::Pedantic)] pub(crate) struct RaiseWithoutFromInsideExcept { is_star: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/re_sub_positional_args.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/re_sub_positional_args.rs index 0b7845fa39..e006e6ae09 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/re_sub_positional_args.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/re_sub_positional_args.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for calls to `re.sub`, `re.subn`, and `re.split` that pass `count`, @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `re.subn`](https://docs.python.org/3/library/re.html#re.subn) /// - [Python documentation: `re.split`](https://docs.python.org/3/library/re.html#re.split) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Pedantic)] pub(crate) struct ReSubPositionalArgs { method: Method, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/redundant_tuple_in_exception_handler.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/redundant_tuple_in_exception_handler.rs index cd98dbc7a3..f9d55a9ed7 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/redundant_tuple_in_exception_handler.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/redundant_tuple_in_exception_handler.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, ExceptHandler, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.89")] +#[violation_metadata(stable_since = "v0.0.89", category = Category::Complexity)] pub(crate) struct RedundantTupleInExceptionHandler { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/return_in_generator.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/return_in_generator.rs index d3aa9fea8d..edcdd7d41d 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/return_in_generator.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/return_in_generator.rs @@ -5,6 +5,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pytest_style::helpers::is_pytest_hookimpl_wrapper; /// ## What it does @@ -79,7 +80,7 @@ use crate::rules::flake8_pytest_style::helpers::is_pytest_hookimpl_wrapper; /// yield from dir_path.glob(f"*.{file_type}") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.4.8")] +#[violation_metadata(preview_since = "v0.4.8", category = Category::Suspicious)] pub(crate) struct ReturnInGenerator; impl Violation for ReturnInGenerator { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs index ff8a6e329e..b7d87f629a 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for multiple usage of the generator returned from @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// do_something_with_the_group(values) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Suspicious)] pub(crate) struct ReuseOfGroupbyGenerator; impl Violation for ReuseOfGroupbyGenerator { @@ -159,13 +160,16 @@ impl<'a> Visitor<'a> for GroupNameFinder<'a> { range: _, node_index: _, }) => { + // Visit the test before pushing the branch counters as it + // is evaluated unconditionally. + self.visit_expr(test); + // base if plus branches let mut if_stack = Vec::with_capacity(1 + elif_else_clauses.len()); // Initialize the vector with the count for the if branch. if_stack.push(0); self.counter_stack.push(if_stack); - self.visit_expr(test); self.visit_body(body); for clause in elif_else_clauses { @@ -186,8 +190,10 @@ impl<'a> Visitor<'a> for GroupNameFinder<'a> { range: _, node_index: _, }) => { - self.counter_stack.push(Vec::with_capacity(cases.len())); + // Visit the subject before pushing the branch counters as it + // is evaluated unconditionally. self.visit_expr(subject); + self.counter_stack.push(Vec::with_capacity(cases.len())); for match_case in cases { self.counter_stack.last_mut().unwrap().push(0); self.visit_match_case(match_case); diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/setattr_with_constant.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/setattr_with_constant.rs index 9a7e8946b8..7430378575 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/setattr_with_constant.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/setattr_with_constant.rs @@ -7,6 +7,7 @@ use ruff_python_stdlib::identifiers::{is_identifier, is_mangled_private}; use unicode_normalization::UnicodeNormalization; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -52,7 +53,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `setattr`](https://docs.python.org/3/library/functions.html#setattr) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.111")] +#[violation_metadata(stable_since = "v0.0.111", category = Category::Complexity)] pub(crate) struct SetAttrWithConstant; impl AlwaysFixableViolation for SetAttrWithConstant { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/star_arg_unpacking_after_keyword_arg.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/star_arg_unpacking_after_keyword_arg.rs index cf79b66502..5cdcb02b21 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/star_arg_unpacking_after_keyword_arg.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/star_arg_unpacking_after_keyword_arg.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function calls that use star-argument unpacking after providing a @@ -46,7 +47,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: Calls](https://docs.python.org/3/reference/expressions.html#calls) /// - [Disallow iterable argument unpacking after a keyword argument?](https://github.com/python/cpython/issues/82741) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.109")] +#[violation_metadata(stable_since = "v0.0.109", category = Category::Suspicious)] pub(crate) struct StarArgUnpackingAfterKeywordArg; impl Violation for StarArgUnpackingAfterKeywordArg { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs index 3e762874da..afa2a5575d 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; /// ## What it does @@ -31,7 +32,7 @@ use crate::fix::snippet::SourceCodeSnippet; /// {value: value.upper() for value in data} /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Correctness)] pub(crate) struct StaticKeyDictComprehension { key: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/strip_with_multi_characters.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/strip_with_multi_characters.rs index e50661e8b9..014ce061a0 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/strip_with_multi_characters.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/strip_with_multi_characters.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of multi-character strings in `.strip()`, `.lstrip()`, and @@ -45,7 +46,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `str.strip`](https://docs.python.org/3/library/stdtypes.html#str.strip) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.106")] +#[violation_metadata(stable_since = "v0.0.106", category = Category::Correctness)] pub(crate) struct StripWithMultiCharacters; impl Violation for StripWithMultiCharacters { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unary_prefix_increment_decrement.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unary_prefix_increment_decrement.rs index 3bfcb06125..4e70e51752 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unary_prefix_increment_decrement.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unary_prefix_increment_decrement.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the attempted use of the unary prefix increment (`++`) or @@ -31,7 +32,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: Unary arithmetic and bitwise operations](https://docs.python.org/3/reference/expressions.html#unary-arithmetic-and-bitwise-operations) /// - [Python documentation: Augmented assignment statements](https://docs.python.org/3/reference/simple_stmts.html#augmented-assignment-statements) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.83")] +#[violation_metadata(stable_since = "v0.0.83", category = Category::Correctness)] pub(crate) struct UnaryPrefixIncrementDecrement { operator: UnaryPrefixOperatorType, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unintentional_type_annotation.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unintentional_type_annotation.rs index f7dad72f7f..40e2e1b7e2 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unintentional_type_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unintentional_type_annotation.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the unintentional use of type annotations. @@ -23,7 +24,7 @@ use crate::checkers::ast::Checker; /// a["b"] = 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.250")] +#[violation_metadata(stable_since = "v0.0.250", category = Category::Suspicious)] pub(crate) struct UnintentionalTypeAnnotation; impl Violation for UnintentionalTypeAnnotation { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unreliable_callable_check.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unreliable_callable_check.rs index b3bcbf5ba9..e8c4a502fa 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unreliable_callable_check.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unreliable_callable_check.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -67,7 +68,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `__getattr__`](https://docs.python.org/3/reference/datamodel.html#object.__getattr__) /// - [Python documentation: `__call__`](https://docs.python.org/3/reference/datamodel.html#object.__call__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.106")] +#[violation_metadata(stable_since = "v0.0.106", category = Category::Suspicious)] pub(crate) struct UnreliableCallableCheck; impl Violation for UnreliableCallableCheck { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs index 8488eab23a..c896454d8d 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::Binding; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -39,7 +40,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [PEP 8: Naming Conventions](https://peps.python.org/pep-0008/#naming-conventions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.84")] +#[violation_metadata(stable_since = "v0.0.84", category = Category::Pedantic)] pub(crate) struct UnusedLoopControlVariable { /// The name of the loop control variable. name: String, diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_comparison.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_comparison.rs index 00f4beb283..27455f0c86 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_comparison.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_comparison.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bugbear::helpers::at_last_top_level_expression_in_cell; @@ -34,7 +35,7 @@ use crate::rules::flake8_bugbear::helpers::at_last_top_level_expression_in_cell; /// ## References /// - [Python documentation: `assert` statement](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Correctness)] pub(crate) struct UselessComparison { at: ComparisonLocationAt, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_contextlib_suppress.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_contextlib_suppress.rs index 8e07bbd96c..1f17fedf4d 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_contextlib_suppress.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_contextlib_suppress.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `contextlib.suppress` without arguments. @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `contextlib.suppress`](https://docs.python.org/3/library/contextlib.html#contextlib.suppress) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.118")] +#[violation_metadata(stable_since = "v0.0.118", category = Category::Correctness)] pub(crate) struct UselessContextlibSuppress; impl Violation for UselessContextlibSuppress { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_expression.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_expression.rs index 952f5d8161..53aa4440c9 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_expression.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/useless_expression.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_bugbear::helpers::at_last_top_level_expression_in_cell; @@ -51,7 +52,7 @@ use crate::rules::flake8_bugbear::helpers::at_last_top_level_expression_in_cell; /// _ = obj.attribute /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.100")] +#[violation_metadata(stable_since = "v0.0.100", category = Category::Suspicious)] pub(crate) struct UselessExpression { kind: Kind, } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs index 475a920b2c..69990d729d 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_argument; use crate::rules::flake8_bugbear::helpers::any_infinite_iterables; use crate::{AlwaysFixableViolation, Applicability, Fix}; @@ -39,7 +40,7 @@ use crate::{AlwaysFixableViolation, Applicability, Fix}; /// ## References /// - [Python documentation: `zip`](https://docs.python.org/3/library/functions.html#zip) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.167")] +#[violation_metadata(stable_since = "v0.0.167", category = Category::Pedantic)] pub(crate) struct ZipWithoutExplicitStrict; impl AlwaysFixableViolation for ZipWithoutExplicitStrict { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B024_B024.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__abstract-base-class-without-abstract-method_B024.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B024_B024.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__abstract-base-class-without-abstract-method_B024.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B024_B024_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__abstract-base-class-without-abstract-method_B024_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B024_B024_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__abstract-base-class-without-abstract-method_B024_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B011_B011.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-false_B011.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B011_B011.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-false_B011.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017_0.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-raises-exception_B017_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017_0.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-raises-exception_B017_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017_1.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-raises-exception_B017_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017_1.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assert-raises-exception_B017_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B003_B003.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assignment-to-os-environ_B003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B003_B003.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__assignment-to-os-environ_B003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B911_B911.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__batched-without-explicit-strict_B911.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B911_B911.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__batched-without-explicit-strict_B911.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B019_B019.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__cached-instance-method_B019.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B019_B019.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__cached-instance-method_B019.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B903_class_as_data_structure.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__class-as-data-structure_class_as_data_structure.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B903_class_as_data_structure.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__class-as-data-structure_class_as_data_structure.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B903_py39_class_as_data_structure.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__class-as-data-structure_py39_class_as_data_structure.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B903_py39_class_as_data_structure.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__class-as-data-structure_py39_class_as_data_structure.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B043_B043.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__del-attr-with-constant_B043.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B043_B043.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__del-attr-with-constant_B043.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B014_B014.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-handler-exception_B014.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B014_B014.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-handler-exception_B014.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B025_B025.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-try-block-exception_B025.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B025_B025.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-try-block-exception_B025.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B033_B033.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-value_B033.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B033_B033.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__duplicate-value_B033.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__empty-method-without-abstract-decorator_B027_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B029_B029.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__except-with-empty-tuple_B029.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B029_B029.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__except-with-empty-tuple_B029.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B030_B030.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__except-with-non-exception-classes_B030.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B030_B030.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__except-with-non-exception-classes_B030.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B021_B021.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__f-string-docstring_B021.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B021_B021.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__f-string-docstring_B021.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B008_B006_B008.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-call-in-default-argument_B006_B008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B008_B006_B008.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-call-in-default-argument_B006_B008.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B008_B008_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-call-in-default-argument_B008_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B008_B008_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-call-in-default-argument_B008_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-uses-loop-variable_B023.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-uses-loop-variable_B023.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-uses-loop-variable_B023_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B023_B023_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__function-uses-loop-variable_B023_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B009_B009_B010.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__get-attr-with-constant_B009_B010.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B009_B009_B010.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__get-attr-with-constant_B009_B010.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B012_B012.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__jump-statement-in-finally_B012.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B012_B012.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__jump-statement-in-finally_B012.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B909_B909.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__loop-iterator-mutation_B909.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B909_B909.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__loop-iterator-mutation_B909.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B020_B020.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__loop-variable-overrides-iterator_B020.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B020_B020.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__loop-variable-overrides-iterator_B020.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B912_B912.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__map-without-explicit-strict_B912.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B912_B912.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__map-without-explicit-strict_B912.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__map-without-explicit-strict_py313_B912.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__map-without-explicit-strict_py313_B912.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_1.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_1.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027.pyi.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B027_B027.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_2.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_2.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_3.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_3.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_4.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_4.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_4.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_5.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_5.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_5.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_6.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_6.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_6.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_7.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_7.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_7.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_8.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_8.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_8.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_8.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_9.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_9.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_9.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_B008.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_B008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B006_B006_B008.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_B008.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-argument-default_B006_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B039_B039.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-contextvar-default_B039.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B039_B039.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__mutable-contextvar-default_B039.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B028_B028.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__no-explicit-stacklevel_B028.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B028_B028.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__no-explicit-stacklevel_B028.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_1.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_1.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B912_py313_B912.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B912_py313_B912.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_2.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_2.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_3.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_3.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_4.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_4.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_4.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_5.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_6.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_6.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_6.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_7.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_7.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_7.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_8.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_8.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_9.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_9.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_9.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_B008.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_B008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_B008.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__mutable-argument-default_B006_B008.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B016_B016.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__raise-literal_B016.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B016_B016.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__raise-literal_B016.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B904_B904.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__raise-without-from-inside-except_B904.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B904_B904.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__raise-without-from-inside-except_B904.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B034_B034.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__re-sub-positional-args_B034.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B034_B034.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__re-sub-positional-args_B034.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B013_B013.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__redundant-tuple-in-exception-handler_B013.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B013_B013.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__redundant-tuple-in-exception-handler_B013.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B901_B901.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__return-in-generator_B901.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B901_B901.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__return-in-generator_B901.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B031_B031.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__reuse-of-groupby-generator_B031.py.snap similarity index 85% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B031_B031.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__reuse-of-groupby-generator_B031.py.snap index b0854c5b61..1baba40b9c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B031_B031.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__reuse-of-groupby-generator_B031.py.snap @@ -243,5 +243,48 @@ B031 Using the generator returned from `itertools.groupby()` more than once will 226 | collect_shop_items(shopper, section_items) # B031 | ^^^^^^^^^^^^^ 227 | -228 | # Let's redefine the `groupby` function to make sure we pick up the correct one. +228 | # https://github.com/astral-sh/ruff/issues/26624 + | + +B031 Using the generator returned from `itertools.groupby()` more than once will do nothing on the second usage + --> B031.py:240:41 + | +238 | match list(section_items): +239 | case []: +240 | collect_shop_items(shopper, section_items) # B031 + | ^^^^^^^^^^^^^ +241 | case _: +242 | collect_shop_items(shopper, section_items) # B031 + | + +B031 Using the generator returned from `itertools.groupby()` more than once will do nothing on the second usage + --> B031.py:242:41 + | +240 | collect_shop_items(shopper, section_items) # B031 +241 | case _: +242 | collect_shop_items(shopper, section_items) # B031 + | ^^^^^^^^^^^^^ +243 | +244 | for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): + | + +B031 Using the generator returned from `itertools.groupby()` more than once will do nothing on the second usage + --> B031.py:245:38 + | +244 | for _section, section_items in itertools.groupby(items, key=lambda p: p[1]): +245 | match (list(section_items), list(section_items)): # B031 + | ^^^^^^^^^^^^^ +246 | case _: +247 | pass + | + +B031 Using the generator returned from `itertools.groupby()` more than once will do nothing on the second usage + --> B031.py:255:37 + | +253 | pass +254 | else: +255 | collect_shop_items(shopper, section_items) # B031 + | ^^^^^^^^^^^^^ +256 | +257 | # Let's redefine the `groupby` function to make sure we pick up the correct one. | diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B010_B009_B010.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__set-attr-with-constant_B009_B010.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B010_B009_B010.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__set-attr-with-constant_B009_B010.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B026_B026.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__star-arg-unpacking-after-keyword-arg_B026.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B026_B026.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__star-arg-unpacking-after-keyword-arg_B026.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_B035.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__static-key-dict-comprehension_B035.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_B035.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__static-key-dict-comprehension_B035.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__static-key-dict-comprehension_py315_B035_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__static-key-dict-comprehension_py315_B035_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B005_B005.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__strip-with-multi-characters_B005.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B005_B005.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__strip-with-multi-characters_B005.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B002_B002.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unary-prefix-increment-decrement_B002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B002_B002.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unary-prefix-increment-decrement_B002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B032_B032.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unintentional-type-annotation_B032.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B032_B032.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unintentional-type-annotation_B032.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B004_B004.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unreliable-callable-check_B004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B004_B004.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unreliable-callable-check_B004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B007_B007.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unused-loop-control-variable_B007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B007_B007.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unused-loop-control-variable_B007.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B015_B015.ipynb.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-comparison_B015.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B015_B015.ipynb.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-comparison_B015.ipynb.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B015_B015.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-comparison_B015.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B015_B015.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-comparison_B015.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B022_B022.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-contextlib-suppress_B022.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B022_B022.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-contextlib-suppress_B022.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018.ipynb.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018.ipynb.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018.ipynb.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018.py.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B018_B018_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__useless-expression_B018_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/mod.rs b/crates/ruff_linter/src/rules/flake8_builtins/mod.rs index 6f289a219d..e44f967f08 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/mod.rs @@ -49,7 +49,7 @@ mod tests { #[test_case(Rule::StdlibModuleShadowing, Path::new("A005/modules/package/xml.py"))] #[test_case(Rule::BuiltinLambdaArgumentShadowing, Path::new("A006.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_builtins").join(path).as_path(), &LinterSettings { @@ -91,11 +91,7 @@ mod tests { false )] fn non_strict_checking(rule_code: Rule, path: &Path, strict: bool) -> Result<()> { - let snapshot = format!( - "{}_{}_{strict}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("{}_{}_{strict}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_builtins").join(path).as_path(), &LinterSettings { @@ -116,7 +112,7 @@ mod tests { Path::new("A005/modules/utils/logging.py") )] fn non_strict_checking_src(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}_src", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}_src", rule_code.name(), path.to_string_lossy()); let src = Path::new("fixtures/flake8_builtins"); let diagnostics = test_path( Path::new("flake8_builtins").join(path).as_path(), @@ -140,7 +136,7 @@ mod tests { Path::new("A005/modules/utils/logging.py") )] fn non_strict_checking_root(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}_root", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}_root", rule_code.name(), path.to_string_lossy()); let src = Path::new("fixtures/flake8_builtins"); let diagnostics = test_path( Path::new("flake8_builtins").join(path).as_path(), @@ -165,7 +161,7 @@ mod tests { fn builtins_ignorelist(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "{}_{}_builtins_ignorelist", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); @@ -208,7 +204,7 @@ mod tests { fn builtins_allowed_modules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "{}_{}_builtins_allowed_modules", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); @@ -230,7 +226,7 @@ mod tests { #[test_case(Rule::BuiltinImportShadowing, Path::new("A004.py"))] fn rules_py312(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}_py38", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}_py38", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_builtins").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY38), diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_argument_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_argument_shadowing.rs index 2545d9e8fd..5aad8ea47d 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_argument_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_argument_shadowing.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_builtins::helpers::shadows_builtin; @@ -54,7 +55,7 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin; /// [override]: https://docs.python.org/3/library/typing.html#typing.override /// [overload]: https://docs.python.org/3/library/typing.html#typing.overload #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.48")] +#[violation_metadata(stable_since = "v0.0.48", category = Category::Pedantic)] pub(crate) struct BuiltinArgumentShadowing { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_attribute_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_attribute_shadowing.rs index 15081d8354..3e955d9c3f 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_attribute_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_attribute_shadowing.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## What it does @@ -56,7 +57,7 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## Options /// - `lint.flake8-builtins.ignorelist` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.48")] +#[violation_metadata(stable_since = "v0.0.48", category = Category::Pedantic)] pub(crate) struct BuiltinAttributeShadowing { kind: Kind, name: String, diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_import_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_import_shadowing.rs index 29bcf1d034..2e68a22ec6 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_import_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_import_shadowing.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Alias; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## What it does @@ -41,7 +42,7 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin; /// - `lint.flake8-builtins.ignorelist` /// - `target-version` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct BuiltinImportShadowing { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_lambda_argument_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_lambda_argument_shadowing.rs index b9314e842f..42e582b03b 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_lambda_argument_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_lambda_argument_shadowing.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## What it does @@ -21,7 +22,7 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## Options /// - `lint.flake8-builtins.ignorelist` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Pedantic)] pub(crate) struct BuiltinLambdaArgumentShadowing { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_variable_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_variable_shadowing.rs index 5b8f937a2a..291c821e91 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_variable_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_variable_shadowing.rs @@ -3,6 +3,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## What it does @@ -44,7 +45,7 @@ use crate::rules::flake8_builtins::helpers::shadows_builtin; /// ## References /// - [_Why is it a bad idea to name a variable `id` in Python?_](https://stackoverflow.com/questions/77552/id-is-a-bad-variable-name-in-python) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.48")] +#[violation_metadata(stable_since = "v0.0.48", category = Category::Pedantic)] pub(crate) struct BuiltinVariableShadowing { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/stdlib_module_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/stdlib_module_shadowing.rs index 55ea5e72d8..0dec429278 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/stdlib_module_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/stdlib_module_shadowing.rs @@ -9,6 +9,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::settings::LinterSettings; /// ## What it does @@ -53,7 +54,7 @@ use crate::settings::LinterSettings; /// - `lint.flake8-builtins.allowed-modules` /// - `lint.flake8-builtins.strict-checking` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Pedantic)] pub(crate) struct StdlibModuleShadowing { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A002_A002.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-argument-shadowing_A002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A002_A002.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-argument-shadowing_A002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A002_A002.py_builtins_ignorelist.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-argument-shadowing_A002.py_builtins_ignorelist.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A002_A002.py_builtins_ignorelist.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-argument-shadowing_A002.py_builtins_ignorelist.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A003_A003.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-attribute-shadowing_A003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A003_A003.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-attribute-shadowing_A003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A003_A003.py_builtins_ignorelist.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-attribute-shadowing_A003.py_builtins_ignorelist.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A003_A003.py_builtins_ignorelist.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-attribute-shadowing_A003.py_builtins_ignorelist.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py_builtins_ignorelist.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py_builtins_ignorelist.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py_builtins_ignorelist.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py_builtins_ignorelist.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py_py38.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py_py38.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A004_A004.py_py38.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-import-shadowing_A004.py_py38.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A006_A006.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-lambda-argument-shadowing_A006.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A006_A006.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-lambda-argument-shadowing_A006.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A006_A006.py_builtins_ignorelist.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-lambda-argument-shadowing_A006.py_builtins_ignorelist.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A006_A006.py_builtins_ignorelist.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-lambda-argument-shadowing_A006.py_builtins_ignorelist.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001.py_builtins_ignorelist.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001.py_builtins_ignorelist.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001.py_builtins_ignorelist.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001.py_builtins_ignorelist.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A001_A001_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__builtin-variable-shadowing_A001_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules___abc____init__.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules___abc____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules___abc____init__.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules___abc____init__.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules___abc____init__.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules___abc____init__.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules___abc____init__.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules___abc____init__.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__logging____init__.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__logging____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__logging____init__.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__logging____init__.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__logging____init__.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__logging____init__.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__logging____init__.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__logging____init__.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__non_builtin____init__.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__non_builtin____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__non_builtin____init__.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__non_builtin____init__.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__non_builtin____init__.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__non_builtin____init__.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__non_builtin____init__.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__non_builtin____init__.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__bisect.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__bisect.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__bisect.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__bisect.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__bisect.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__bisect.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__bisect.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__bisect.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__collections.pyi.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__collections.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__collections.pyi.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__collections.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__xml.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__xml.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__xml.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__xml.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__xml.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__xml.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__package__xml.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__package__xml.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__string____init__.py.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__string____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__string____init__.py.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__string____init__.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__string____init__.py_builtins_allowed_modules.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__string____init__.py_builtins_allowed_modules.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__string____init__.py_builtins_allowed_modules.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__string____init__.py_builtins_allowed_modules.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_false.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_false.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_false.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_false.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_root.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_root.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_root.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_root.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_src.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_src.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_src.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_src.snap diff --git a/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_true.snap b/crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_true.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__A005_A005__modules__utils__logging.py_true.snap rename to crates/ruff_linter/src/rules/flake8_builtins/snapshots/ruff_linter__rules__flake8_builtins__tests__stdlib-module-shadowing_A005__modules__utils__logging.py_true.snap diff --git a/crates/ruff_linter/src/rules/flake8_commas/rules/trailing_commas.rs b/crates/ruff_linter/src/rules/flake8_commas/rules/trailing_commas.rs index be1b97bc58..70f68e950f 100644 --- a/crates/ruff_linter/src/rules/flake8_commas/rules/trailing_commas.rs +++ b/crates/ruff_linter/src/rules/flake8_commas/rules/trailing_commas.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Violation}; use crate::{Edit, Fix}; @@ -153,7 +154,7 @@ impl Context { /// /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.223")] +#[violation_metadata(stable_since = "v0.0.223", category = Category::Formatting)] pub(crate) struct MissingTrailingComma; impl AlwaysFixableViolation for MissingTrailingComma { @@ -199,7 +200,7 @@ impl AlwaysFixableViolation for MissingTrailingComma { /// foo = (json.dumps({"bar": 1}),) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.223")] +#[violation_metadata(stable_since = "v0.0.223", category = Category::Suspicious)] pub(crate) struct TrailingCommaOnBareTuple; impl Violation for TrailingCommaOnBareTuple { @@ -235,7 +236,7 @@ impl Violation for TrailingCommaOnBareTuple { /// /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.223")] +#[violation_metadata(stable_since = "v0.0.223", category = Category::Formatting)] pub(crate) struct ProhibitedTrailingComma; impl AlwaysFixableViolation for ProhibitedTrailingComma { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs index c7d99586b8..50c114350f 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs @@ -42,7 +42,7 @@ mod tests { #[test_case(Rule::UnnecessaryMap, Path::new("C417_1.py"))] #[test_case(Rule::UnnecessarySubscriptReversal, Path::new("C415.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_comprehensions").join(path).as_path(), &LinterSettings::for_rule(rule_code), @@ -59,7 +59,7 @@ mod tests { #[test_case(Rule::UnnecessaryLiteralWithinTupleCall, Path::new("C409_py315.py"))] #[test_case(Rule::UnnecessaryComprehensionInCall, Path::new("C419_py315.py"))] fn rules_py315(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_comprehensions").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY315), @@ -70,11 +70,7 @@ mod tests { #[test_case(Rule::UnnecessaryComprehensionInCall, Path::new("C419_1.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_comprehensions").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -87,7 +83,7 @@ mod tests { fn allow_dict_calls_with_keyword_arguments(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "{}_{}_allow_dict_calls_with_keyword_arguments", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_call_around_sorted.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_call_around_sorted.rs index a36ef72ec0..b2487ef669 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_call_around_sorted.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_call_around_sorted.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Fix}; use crate::rules::flake8_comprehensions::fixes; @@ -42,7 +43,7 @@ use crate::rules::flake8_comprehensions::fixes; /// The fix is marked as safe for `list()` cases, as removing `list()` around /// `sorted()` does not change the behavior. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Complexity)] pub(crate) struct UnnecessaryCallAroundSorted { func: UnnecessaryFunction, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_collection_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_collection_call.rs index ca01f07e54..7bd83cb339 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_collection_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_collection_call.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; use crate::rules::flake8_comprehensions::settings::Settings; @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## Options /// - `lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryCollectionCall { kind: Collection, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs index 174f97d5e2..8ee977d2db 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use crate::rules::flake8_comprehensions::fixes; @@ -59,7 +60,7 @@ use crate::rules::flake8_comprehensions::fixes; /// This rule's fix is always marked as unsafe because of the known problems described above and /// because comments may be dropped when rewriting the comprehension. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Pedantic)] pub(crate) struct UnnecessaryComprehension { kind: ComprehensionKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs index 906fdfcf1e..1228122394 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextSize}; use crate::FixAvailability; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_comprehension_with_min_max_sum_enabled; use crate::rules::flake8_comprehensions::fixes; use crate::{Edit, Fix, Violation}; @@ -67,7 +68,7 @@ use crate::{Edit, Fix, Violation}; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Complexity)] pub(crate) struct UnnecessaryComprehensionInCall { comprehension_kind: ComprehensionKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs index afead60988..3a846fc201 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{self as ast, Arguments, Comprehension, Expr, ExprCall, Exp use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad_start; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -49,7 +50,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `dict.fromkeys`](https://docs.python.org/3/library/stdtypes.html#dict.fromkeys) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Complexity)] pub(crate) struct UnnecessaryDictComprehensionForIterable { is_value_none_literal: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_double_cast_or_process.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_double_cast_or_process.rs index 8a94a0f423..d6fb343e1b 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_double_cast_or_process.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_double_cast_or_process.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Arguments, Expr, Keyword}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use crate::rules::flake8_comprehensions::fixes; @@ -48,7 +49,7 @@ use crate::rules::flake8_comprehensions::fixes; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Complexity)] pub(crate) struct UnnecessaryDoubleCastOrProcess { inner: String, outer: String, diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_dict.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_dict.rs index 547f5bd8e0..45f88791e3 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_dict.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_dict.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, Keyword}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use crate::rules::flake8_comprehensions::fixes; @@ -32,7 +33,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryGeneratorDict; impl AlwaysFixableViolation for UnnecessaryGeneratorDict { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs index d271a13792..edce594d89 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs @@ -7,6 +7,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::rules::flake8_comprehensions::helpers; @@ -42,7 +43,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryGeneratorList { short_circuit: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs index 05a1c523cf..88a55574cc 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs @@ -7,6 +7,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -43,7 +44,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryGeneratorSet { short_circuit: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs index 6dd4618119..cbc1cf49ca 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes; use crate::{Fix, FixAvailability, Violation}; @@ -29,7 +30,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Complexity)] pub(crate) struct UnnecessaryListCall; impl Violation for UnnecessaryListCall { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_dict.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_dict.rs index e0b523c17b..708d0b871b 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_dict.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_dict.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, Keyword}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes; use crate::{AlwaysFixableViolation, Fix}; @@ -29,7 +30,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.58")] +#[violation_metadata(stable_since = "v0.0.58", category = Category::Complexity)] pub(crate) struct UnnecessaryListComprehensionDict; impl AlwaysFixableViolation for UnnecessaryListComprehensionDict { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_set.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_set.rs index f6699500af..ec2b192398 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_set.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_comprehension_set.rs @@ -5,6 +5,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -31,7 +32,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.58")] +#[violation_metadata(stable_since = "v0.0.58", category = Category::Complexity)] pub(crate) struct UnnecessaryListComprehensionSet; impl AlwaysFixableViolation for UnnecessaryListComprehensionSet { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_dict.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_dict.rs index cea5f9c2f9..adb894755b 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_dict.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_dict.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, Keyword}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes; use crate::{AlwaysFixableViolation, Fix}; @@ -33,7 +34,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralDict { obj_type: LiteralKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_set.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_set.rs index f06964f33d..26d9f45f87 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_set.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_set.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::{Ranged, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -34,7 +35,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralSet { kind: UnnecessaryLiteral, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs index c53e31a958..c77e6c62c4 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs @@ -6,6 +6,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -35,7 +36,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralWithinDictCall { kind: DictKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_list_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_list_call.rs index 2861e19174..6fbc864d56 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_list_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_list_call.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::{Ranged, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::rules::flake8_comprehensions::helpers; @@ -35,7 +36,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.66")] +#[violation_metadata(stable_since = "v0.0.66", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralWithinListCall { kind: LiteralKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs index 48b8e3e704..16492a5643 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs @@ -4,6 +4,7 @@ use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -36,7 +37,7 @@ use crate::rules::flake8_comprehensions::helpers; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.66")] +#[violation_metadata(stable_since = "v0.0.66", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralWithinTupleCall { literal_kind: TupleLiteralKind, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_map.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_map.rs index 7850d9f60c..561f511a17 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_map.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_map.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Fix; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_comprehensions::fixes; use crate::{FixAvailability, Violation}; @@ -45,7 +46,7 @@ use crate::{FixAvailability, Violation}; /// This rule's fix is marked as unsafe, as it may occasionally drop comments /// when rewriting the call. In most cases, though, comments will be preserved. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.74")] +#[violation_metadata(stable_since = "v0.0.74", category = Category::Complexity)] pub(crate) struct UnnecessaryMap { object_type: ObjectType, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_subscript_reversal.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_subscript_reversal.rs index 72e11829ec..db7ff2875f 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_subscript_reversal.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_subscript_reversal.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for unnecessary subscript reversal of iterable. @@ -27,7 +28,7 @@ use crate::checkers::ast::Checker; /// iterable /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.64")] +#[violation_metadata(stable_since = "v0.0.64", category = Category::Complexity)] pub(crate) struct UnnecessarySubscriptReversal { func: String, } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C419_C419_1.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__unnecessary-comprehension-in-call_C419_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C419_C419_1.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__unnecessary-comprehension-in-call_C419_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C413_C413.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-call-around-sorted_C413.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C413_C413.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-call-around-sorted_C413.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-collection-call_C408.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-collection-call_C408.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py_allow_dict_calls_with_keyword_arguments.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-collection-call_C408.py_allow_dict_calls_with_keyword_arguments.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py_allow_dict_calls_with_keyword_arguments.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-collection-call_C408.py_allow_dict_calls_with_keyword_arguments.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C409_C409_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C409_C409_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension-in-call_C419_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C416_C416.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension_C416.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C416_C416.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-comprehension_C416.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_1.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_1.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_2.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_2.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_2.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_2.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-dict-comprehension-for-iterable_C420_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C414_C414.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-double-cast-or-process_C414.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C414_C414.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-double-cast-or-process_C414.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C402_C402.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-dict_C402.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C402_C402.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-dict_C402.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-list_C400.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-list_C400.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-list_C400_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-list_C400_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-set_C401.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-set_C401.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-set_C401_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-generator-set_C401_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-call_C411.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-call_C411.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-call_C411_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-call_C411_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C404_C404.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-dict_C404.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C404_C404.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-dict_C404.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-set_C403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-set_C403.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-set_C403_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-list-comprehension-set_C403_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C406_C406.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-dict_C406.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C406_C406.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-dict_C406.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C405_C405.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-set_C405.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C405_C405.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-set_C405.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-dict-call_C418.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-dict-call_C418.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-dict-call_C418_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-dict-call_C418_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C410_C410.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-list-call_C410.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C410_C410.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-list-call_C410.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C409_C409.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-tuple-call_C409.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C409_C409.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-tuple-call_C409.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_3.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-tuple-call_C409_py315.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C420_C420_3.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-literal-within-tuple-call_C409_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C417_C417.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-map_C417.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C417_C417.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-map_C417.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C417_C417_1.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-map_C417_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C417_C417_1.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-map_C417_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C415_C415.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-subscript-reversal_C415.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C415_C415.py.snap rename to crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__unnecessary-subscript-reversal_C415.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_copyright/rules/missing_copyright_notice.rs b/crates/ruff_linter/src/rules/flake8_copyright/rules/missing_copyright_notice.rs index 0b182c288d..b38719c8d4 100644 --- a/crates/ruff_linter/src/rules/flake8_copyright/rules/missing_copyright_notice.rs +++ b/crates/ruff_linter/src/rules/flake8_copyright/rules/missing_copyright_notice.rs @@ -4,6 +4,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::settings::LinterSettings; /// ## What it does @@ -20,7 +21,7 @@ use crate::settings::LinterSettings; /// - `lint.flake8-copyright.min-file-size` /// - `lint.flake8-copyright.notice-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Pedantic)] pub(crate) struct MissingCopyrightNotice; impl Violation for MissingCopyrightNotice { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs b/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs index 34599703fa..9dbb7955d8 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs @@ -24,7 +24,7 @@ mod tests { #[test_case(Rule::CallDateFromtimestamp, Path::new("DTZ012.py"))] #[test_case(Rule::DatetimeMinMax, Path::new("DTZ901.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_datetimez").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_fromtimestamp.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_fromtimestamp.rs index 8cdc1a7a46..0f816a03de 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_fromtimestamp.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_fromtimestamp.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for usage of `datetime.date.fromtimestamp()`. @@ -45,7 +46,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDateFromtimestamp; impl Violation for CallDateFromtimestamp { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_today.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_today.rs index 8b84a68625..c1c7f7d129 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_today.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_date_today.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for usage of `datetime.date.today()`. @@ -45,7 +46,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDateToday; impl Violation for CallDateToday { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_fromtimestamp.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_fromtimestamp.rs index 484b88fbd1..a58c763af9 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_fromtimestamp.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_fromtimestamp.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; @@ -49,7 +50,7 @@ use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDatetimeFromtimestamp(DatetimeModuleAntipattern); impl Violation for CallDatetimeFromtimestamp { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_now_without_tzinfo.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_now_without_tzinfo.rs index be0f86d395..75b07ef5f5 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_now_without_tzinfo.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_now_without_tzinfo.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; @@ -47,7 +48,7 @@ use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDatetimeNowWithoutTzinfo(DatetimeModuleAntipattern); impl Violation for CallDatetimeNowWithoutTzinfo { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_strptime_without_zone.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_strptime_without_zone.rs index 384e3ea58d..22220afb60 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_strptime_without_zone.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_strptime_without_zone.rs @@ -5,8 +5,9 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; -use crate::rules::flake8_datetimez::helpers::DatetimeModuleAntipattern; +use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; /// ## What it does /// Checks for uses of `datetime.datetime.strptime()` that lead to naive @@ -52,7 +53,7 @@ use crate::rules::flake8_datetimez::helpers::DatetimeModuleAntipattern; /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) /// - [Python documentation: `strftime()` and `strptime()` Behavior](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDatetimeStrptimeWithoutZone(DatetimeModuleAntipattern); impl Violation for CallDatetimeStrptimeWithoutZone { @@ -104,6 +105,10 @@ pub(crate) fn call_datetime_strptime_without_zone(checker: &Checker, call: &ast: return; } + if helpers::followed_by_astimezone(checker) { + return; + } + // Does the `strptime` call contain a format string with a timezone specifier? if let Some(expr) = call.arguments.args.get(1) { match expr { @@ -155,10 +160,6 @@ fn find_antipattern( let Some(Expr::Attribute(ast::ExprAttribute { attr, .. })) = parent else { return Some(DatetimeModuleAntipattern::NoTzArgumentPassed); }; - // Ex) `datetime.strptime(...).astimezone()` - if attr == "astimezone" { - return None; - } if attr != "replace" { return Some(DatetimeModuleAntipattern::NoTzArgumentPassed); } diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs index 061583826b..29f7ea3a74 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers; @@ -50,7 +51,7 @@ use crate::rules::flake8_datetimez::helpers; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Suspicious)] pub(crate) struct CallDatetimeToday; impl Violation for CallDatetimeToday { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcfromtimestamp.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcfromtimestamp.rs index 3441ecd6b5..f813406e7a 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcfromtimestamp.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcfromtimestamp.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers; @@ -47,7 +48,7 @@ use crate::rules::flake8_datetimez::helpers; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Suspicious)] pub(crate) struct CallDatetimeUtcfromtimestamp; impl Violation for CallDatetimeUtcfromtimestamp { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcnow.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcnow.rs index c1dfd21a42..741c41c838 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcnow.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_utcnow.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers; @@ -46,7 +47,7 @@ use crate::rules::flake8_datetimez::helpers; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Suspicious)] pub(crate) struct CallDatetimeUtcnow; impl Violation for CallDatetimeUtcnow { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_without_tzinfo.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_without_tzinfo.rs index c937149c5c..4f255afe15 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_without_tzinfo.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_without_tzinfo.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; @@ -46,7 +47,7 @@ use crate::rules::flake8_datetimez::helpers::{self, DatetimeModuleAntipattern}; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct CallDatetimeWithoutTzinfo(DatetimeModuleAntipattern); impl Violation for CallDatetimeWithoutTzinfo { diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs index a1428c9a77..be269e926d 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `datetime.datetime.min` and `datetime.datetime.max`. @@ -42,7 +43,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Pedantic)] pub(crate) struct DatetimeMinMax { min_max: MinMax, } @@ -98,7 +99,11 @@ fn usage_is_safe(semantic: &SemanticModel) -> bool { match (parent, grandparent) { (Expr::Attribute(ExprAttribute { attr, .. }), Expr::Call(ExprCall { arguments, .. })) => { - attr == "time" || (attr == "replace" && arguments.find_keyword("tzinfo").is_some()) + attr == "time" + || (attr == "replace" + && arguments + .find_keyword("tzinfo") + .is_some_and(|keyword| !keyword.value.is_none_literal_expr())) } _ => false, } diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ012_DTZ012.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-date-fromtimestamp_DTZ012.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ012_DTZ012.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-date-fromtimestamp_DTZ012.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ011_DTZ011.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-date-today_DTZ011.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ011_DTZ011.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-date-today_DTZ011.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ006_DTZ006.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-fromtimestamp_DTZ006.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ006_DTZ006.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-fromtimestamp_DTZ006.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ005_DTZ005.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-now-without-tzinfo_DTZ005.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ005_DTZ005.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-now-without-tzinfo_DTZ005.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ007_DTZ007.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-strptime-without-zone_DTZ007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ007_DTZ007.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-strptime-without-zone_DTZ007.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ002_DTZ002.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-today_DTZ002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ002_DTZ002.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-today_DTZ002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ004_DTZ004.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-utcfromtimestamp_DTZ004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ004_DTZ004.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-utcfromtimestamp_DTZ004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ003_DTZ003.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-utcnow_DTZ003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ003_DTZ003.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-utcnow_DTZ003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ001_DTZ001.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-without-tzinfo_DTZ001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ001_DTZ001.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__call-datetime-without-tzinfo_DTZ001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ901_DTZ901.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__datetime-min-max_DTZ901.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ901_DTZ901.py.snap rename to crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__datetime-min-max_DTZ901.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_debugger/mod.rs b/crates/ruff_linter/src/rules/flake8_debugger/mod.rs index 56e0dcec7f..e04d641b58 100644 --- a/crates/ruff_linter/src/rules/flake8_debugger/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_debugger/mod.rs @@ -15,7 +15,7 @@ mod tests { #[test_case(Rule::Debugger, Path::new("T100.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_debugger").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_debugger/rules/debugger.rs b/crates/ruff_linter/src/rules/flake8_debugger/rules/debugger.rs index f7ce0eb580..2024375c42 100644 --- a/crates/ruff_linter/src/rules/flake8_debugger/rules/debugger.rs +++ b/crates/ruff_linter/src/rules/flake8_debugger/rules/debugger.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_debugger::types::DebuggerUsingType; /// ## What it does @@ -31,7 +32,7 @@ use crate::rules::flake8_debugger::types::DebuggerUsingType; /// - [Python documentation: `pdb` — The Python Debugger](https://docs.python.org/3/library/pdb.html) /// - [Python documentation: `logging` — Logging facility for Python](https://docs.python.org/3/library/logging.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.141")] +#[violation_metadata(stable_since = "v0.0.141", category = Category::Correctness)] pub(crate) struct Debugger { using_type: DebuggerUsingType, } diff --git a/crates/ruff_linter/src/rules/flake8_debugger/snapshots/ruff_linter__rules__flake8_debugger__tests__T100_T100.py.snap b/crates/ruff_linter/src/rules/flake8_debugger/snapshots/ruff_linter__rules__flake8_debugger__tests__debugger_T100.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_debugger/snapshots/ruff_linter__rules__flake8_debugger__tests__T100_T100.py.snap rename to crates/ruff_linter/src/rules/flake8_debugger/snapshots/ruff_linter__rules__flake8_debugger__tests__debugger_T100.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/mod.rs b/crates/ruff_linter/src/rules/flake8_django/mod.rs index 8be904a8e0..00ab4f7d7b 100644 --- a/crates/ruff_linter/src/rules/flake8_django/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_django/mod.rs @@ -21,7 +21,7 @@ mod tests { #[test_case(Rule::DjangoUnorderedBodyContentInModel, Path::new("DJ012.py"))] #[test_case(Rule::DjangoNonLeadingReceiverDecorator, Path::new("DJ013.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_django").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/all_with_model_form.rs b/crates/ruff_linter/src/rules/flake8_django/rules/all_with_model_form.rs index f7e9c0f0ec..4512a67f36 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/all_with_model_form.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/all_with_model_form.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_django::helpers::is_model_form; /// ## What it does @@ -38,7 +39,7 @@ use crate::rules::flake8_django::helpers::is_model_form; /// fields = ["title", "content"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Security)] pub(crate) struct DjangoAllWithModelForm; impl Violation for DjangoAllWithModelForm { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/exclude_with_model_form.rs b/crates/ruff_linter/src/rules/flake8_django/rules/exclude_with_model_form.rs index 2b1142f7bb..3a0825be44 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/exclude_with_model_form.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/exclude_with_model_form.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_django::helpers::is_model_form; /// ## What it does @@ -36,7 +37,7 @@ use crate::rules::flake8_django::helpers::is_model_form; /// fields = ["title", "content"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Security)] pub(crate) struct DjangoExcludeWithModelForm; impl Violation for DjangoExcludeWithModelForm { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/locals_in_render_function.rs b/crates/ruff_linter/src/rules/flake8_django/rules/locals_in_render_function.rs index 6f239ffe65..84f3dc0f78 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/locals_in_render_function.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/locals_in_render_function.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of `locals()` in `render` functions. @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// return render(request, "app/index.html", context) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Security)] pub(crate) struct DjangoLocalsInRenderFunction; impl Violation for DjangoLocalsInRenderFunction { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/model_without_dunder_str.rs b/crates/ruff_linter/src/rules/flake8_django/rules/model_without_dunder_str.rs index 72b3841177..427a462857 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/model_without_dunder_str.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/model_without_dunder_str.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{Modules, SemanticModel, analyze}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_django::helpers; @@ -41,7 +42,7 @@ use crate::rules::flake8_django::helpers; /// return f"{self.field}" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Style)] pub(crate) struct DjangoModelWithoutDunderStr; impl Violation for DjangoModelWithoutDunderStr { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/non_leading_receiver_decorator.rs b/crates/ruff_linter/src/rules/flake8_django/rules/non_leading_receiver_decorator.rs index 289b4a45e2..7669b8d400 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/non_leading_receiver_decorator.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/non_leading_receiver_decorator.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that Django's `@receiver` decorator is listed first, prior to @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Suspicious)] pub(crate) struct DjangoNonLeadingReceiverDecorator; impl Violation for DjangoNonLeadingReceiverDecorator { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/nullable_model_string_field.rs b/crates/ruff_linter/src/rules/flake8_django/rules/nullable_model_string_field.rs index 9ff80f0d52..ad41efa25a 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/nullable_model_string_field.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/nullable_model_string_field.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_django::helpers; @@ -41,7 +42,7 @@ use crate::rules::flake8_django::helpers; /// field = models.CharField(max_length=255, default="") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Pedantic)] pub(crate) struct DjangoNullableModelStringField { field_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/unordered_body_content_in_model.rs b/crates/ruff_linter/src/rules/flake8_django/rules/unordered_body_content_in_model.rs index b3a0127343..f2c85434dd 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/unordered_body_content_in_model.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/unordered_body_content_in_model.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_django::helpers; @@ -63,7 +64,7 @@ use crate::rules::flake8_django::helpers; /// /// [Django Style Guide]: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/#model-style #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Style)] pub(crate) struct DjangoUnorderedBodyContentInModel { element_type: ContentType, prev_element_type: ContentType, diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ007_DJ007.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-all-with-model-form_DJ007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ007_DJ007.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-all-with-model-form_DJ007.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ006_DJ006.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-exclude-with-model-form_DJ006.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ006_DJ006.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-exclude-with-model-form_DJ006.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ003_DJ003.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-locals-in-render-function_DJ003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ003_DJ003.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-locals-in-render-function_DJ003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ008_DJ008.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-model-without-dunder-str_DJ008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ008_DJ008.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-model-without-dunder-str_DJ008.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ013_DJ013.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-non-leading-receiver-decorator_DJ013.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ013_DJ013.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-non-leading-receiver-decorator_DJ013.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ001_DJ001.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-nullable-model-string-field_DJ001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ001_DJ001.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-nullable-model-string-field_DJ001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ012_DJ012.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-unordered-body-content-in-model_DJ012.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ012_DJ012.py.snap rename to crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__django-unordered-body-content-in-model_DJ012.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_errmsg/rules/string_in_exception.rs b/crates/ruff_linter/src/rules/flake8_errmsg/rules/string_in_exception.rs index 3362935fe6..8c4a981ca9 100644 --- a/crates/ruff_linter/src/rules/flake8_errmsg/rules/string_in_exception.rs +++ b/crates/ruff_linter/src/rules/flake8_errmsg/rules/string_in_exception.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::fresh_binding_name; use crate::registry::Rule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -54,7 +55,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// - `lint.flake8-errmsg.max-string-length` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.183")] +#[violation_metadata(stable_since = "v0.0.183", category = Category::Pedantic)] pub(crate) struct RawStringInException; impl Violation for RawStringInException { @@ -110,7 +111,7 @@ impl Violation for RawStringInException { /// RuntimeError: 'Some value' is incorrect /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.183")] +#[violation_metadata(stable_since = "v0.0.183", category = Category::Pedantic)] pub(crate) struct FStringInException; impl Violation for FStringInException { @@ -167,7 +168,7 @@ impl Violation for FStringInException { /// RuntimeError: 'Some value' is incorrect /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.183")] +#[violation_metadata(stable_since = "v0.0.183", category = Category::Pedantic)] pub(crate) struct DotFormatInException; impl Violation for DotFormatInException { diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_leading_whitespace.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_leading_whitespace.rs index 16d276b73a..b1f1c440f9 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_leading_whitespace.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_leading_whitespace.rs @@ -5,6 +5,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation: Executable Python Scripts](https://docs.python.org/3/tutorial/appendix.html#executable-python-scripts) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Correctness)] pub(crate) struct ShebangLeadingWhitespace; impl AlwaysFixableViolation for ShebangLeadingWhitespace { diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_executable_file.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_executable_file.rs index c6b0f5958c..4f58cbf665 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_executable_file.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_executable_file.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; #[cfg(target_family = "unix")] use crate::rules::flake8_executable::helpers::{is_executable, is_wsl}; @@ -34,7 +35,7 @@ use crate::rules::flake8_executable::helpers::{is_executable, is_wsl}; /// - [Python documentation: Executable Python Scripts](https://docs.python.org/3/tutorial/appendix.html#executable-python-scripts) /// - [Git documentation: `git update-index --chmod`](https://git-scm.com/docs/git-update-index#Documentation/git-update-index.txt---chmod-x) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.233")] +#[violation_metadata(stable_since = "v0.0.233", category = Category::Correctness)] pub(crate) struct ShebangMissingExecutableFile; impl Violation for ShebangMissingExecutableFile { diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs index b3666c21c6..0539daa706 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs @@ -6,6 +6,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::comments::shebang::ShebangDirective; static UV_RUN_REGEX: LazyLock = LazyLock::new(|| { @@ -51,7 +52,7 @@ static UV_RUN_REGEX: LazyLock = LazyLock::new(|| { /// ## References /// - [Python documentation: Executable Python Scripts](https://docs.python.org/3/tutorial/appendix.html#executable-python-scripts) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Pedantic)] pub(crate) struct ShebangMissingPython; impl Violation for ShebangMissingPython { diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_executable.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_executable.rs index da102aaf61..decd2cd0c8 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_executable.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_executable.rs @@ -5,6 +5,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; #[cfg(target_family = "unix")] use crate::rules::flake8_executable::helpers::{is_executable, is_wsl}; @@ -38,7 +39,7 @@ use crate::rules::flake8_executable::helpers::{is_executable, is_wsl}; /// - [Python documentation: Executable Python Scripts](https://docs.python.org/3/tutorial/appendix.html#executable-python-scripts) /// - [Git documentation: `git update-index --chmod`](https://git-scm.com/docs/git-update-index#Documentation/git-update-index.txt---chmod-x) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.233")] +#[violation_metadata(stable_since = "v0.0.233", category = Category::Suspicious)] pub(crate) struct ShebangNotExecutable; impl Violation for ShebangNotExecutable { diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_first_line.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_first_line.rs index 4d1732cd7c..c77c98b53d 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_first_line.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_not_first_line.rs @@ -5,6 +5,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; /// ## What it does /// Checks for a shebang directive that is not at the beginning of the file. @@ -33,7 +34,7 @@ use crate::checkers::ast::LintContext; /// ## References /// - [Python documentation: Executable Python Scripts](https://docs.python.org/3/tutorial/appendix.html#executable-python-scripts) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Suspicious)] pub(crate) struct ShebangNotFirstLine; impl Violation for ShebangNotFirstLine { diff --git a/crates/ruff_linter/src/rules/flake8_fixme/rules/todos.rs b/crates/ruff_linter/src/rules/flake8_fixme/rules/todos.rs index 5ca86e42b9..fcb0682a29 100644 --- a/crates/ruff_linter/src/rules/flake8_fixme/rules/todos.rs +++ b/crates/ruff_linter/src/rules/flake8_fixme/rules/todos.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::directives::{TodoComment, TodoDirectiveKind}; /// ## What it does @@ -23,7 +24,7 @@ use crate::directives::{TodoComment, TodoDirectiveKind}; /// return f"Hello, {name}!" # TODO: Add support for custom greetings. /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.272")] +#[violation_metadata(stable_since = "v0.0.272", category = Category::Pedantic)] pub(crate) struct LineContainsTodo; impl Violation for LineContainsTodo { #[derive_message_formats] @@ -50,7 +51,7 @@ impl Violation for LineContainsTodo { /// return distance / time # FIXME: Raises ZeroDivisionError for time = 0. /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.272")] +#[violation_metadata(stable_since = "v0.0.272", category = Category::Pedantic)] pub(crate) struct LineContainsFixme; impl Violation for LineContainsFixme { #[derive_message_formats] @@ -74,7 +75,7 @@ impl Violation for LineContainsFixme { /// return distance / time # XXX: Raises ZeroDivisionError for time = 0. /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.272")] +#[violation_metadata(stable_since = "v0.0.272", category = Category::Pedantic)] pub(crate) struct LineContainsXxx; impl Violation for LineContainsXxx { #[derive_message_formats] @@ -110,7 +111,7 @@ impl Violation for LineContainsXxx { /// return False /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.272")] +#[violation_metadata(stable_since = "v0.0.272", category = Category::Pedantic)] pub(crate) struct LineContainsHack; impl Violation for LineContainsHack { #[derive_message_formats] diff --git a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs index 5260c63679..5f4a62bab7 100644 --- a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs @@ -5,6 +5,7 @@ use ruff_python_ast::Expr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; /// ## What it does @@ -49,7 +50,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## Options /// - `target-version` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct FutureRequiredTypeAnnotation { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs index 26ae39ffce..f4a6683ff1 100644 --- a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; /// ## What it does @@ -68,7 +69,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## Options /// - `target-version` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Style)] pub(crate) struct FutureRewritableTypeAnnotation { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_gettext/rules/f_string_in_gettext_func_call.rs b/crates/ruff_linter/src/rules/flake8_gettext/rules/f_string_in_gettext_func_call.rs index da2e4f29f9..b98b9732f0 100644 --- a/crates/ruff_linter/src/rules/flake8_gettext/rules/f_string_in_gettext_func_call.rs +++ b/crates/ruff_linter/src/rules/flake8_gettext/rules/f_string_in_gettext_func_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_gettext::is_ngettext_call; /// ## What it does @@ -46,7 +47,7 @@ use crate::rules::flake8_gettext::is_ngettext_call; /// ## References /// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Suspicious)] pub(crate) struct FStringInGetTextFuncCall { is_plural: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_gettext/rules/format_in_gettext_func_call.rs b/crates/ruff_linter/src/rules/flake8_gettext/rules/format_in_gettext_func_call.rs index 143c76d741..99402539a7 100644 --- a/crates/ruff_linter/src/rules/flake8_gettext/rules/format_in_gettext_func_call.rs +++ b/crates/ruff_linter/src/rules/flake8_gettext/rules/format_in_gettext_func_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_gettext::is_ngettext_call; /// ## What it does @@ -46,7 +47,7 @@ use crate::rules::flake8_gettext::is_ngettext_call; /// ## References /// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Suspicious)] pub(crate) struct FormatInGetTextFuncCall { is_plural: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_gettext/rules/printf_in_gettext_func_call.rs b/crates/ruff_linter/src/rules/flake8_gettext/rules/printf_in_gettext_func_call.rs index 428d1fc937..833c42e30f 100644 --- a/crates/ruff_linter/src/rules/flake8_gettext/rules/printf_in_gettext_func_call.rs +++ b/crates/ruff_linter/src/rules/flake8_gettext/rules/printf_in_gettext_func_call.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_gettext::is_ngettext_call; /// ## What it does @@ -45,7 +46,7 @@ use crate::rules::flake8_gettext::is_ngettext_call; /// ## References /// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Suspicious)] pub(crate) struct PrintfInGetTextFuncCall { is_plural: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs index 086ba8ce4c..91f3566f77 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs @@ -32,12 +32,13 @@ mod tests { Path::new("ISC_syntax_error_2.py") )] #[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC.py"))] + #[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC003_docstring.py"))] #[test_case( Rule::ImplicitStringConcatenationInCollectionLiteral, Path::new("ISC004.py") )] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_implicit_str_concat").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -50,11 +51,7 @@ mod tests { #[test_case(Rule::MultiLineImplicitStringConcatenation, Path::new("ISC.py"))] #[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC.py"))] fn multiline(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "multiline_{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("multiline_{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_implicit_str_concat").join(path).as_path(), &settings::LinterSettings { diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs index bf68c8a671..5ab78a133d 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{Expr, StringLike}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// However, the issue is that you may often want to change semantics /// by adding a missing comma. Thus, the fix is always marked as unsafe. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Suspicious)] pub(crate) struct ImplicitStringConcatenationInCollectionLiteral; impl Violation for ImplicitStringConcatenationInCollectionLiteral { diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/explicit.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/explicit.rs index b9c8c6a7c3..6f89e3c0b8 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/explicit.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/explicit.rs @@ -1,11 +1,13 @@ +use ruff_diagnostics::Applicability; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::token::{TokenKind, parenthesized_range}; -use ruff_python_ast::{self as ast, Expr, Operator}; +use ruff_python_ast::{self as ast, Expr, Operator, Stmt}; use ruff_python_trivia::is_python_whitespace; use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -33,6 +35,10 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ) /// ``` /// +/// ## Fix safety +/// +/// The fix is marked as unsafe when it would create a docstring. +/// /// ## Options /// /// Setting `lint.flake8-implicit-str-concat.allow-multiline = false` will disable this rule because @@ -40,7 +46,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// - `lint.flake8-implicit-str-concat.allow-multiline` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.201")] +#[violation_metadata(stable_since = "v0.0.201", category = Category::Restriction)] pub(crate) struct ExplicitStringConcatenation; impl Violation for ExplicitStringConcatenation { @@ -113,6 +119,42 @@ pub(crate) fn explicit(checker: &Checker, expr: &Expr) { } } +/// Returns `true` if removing the `+` operator would turn the enclosing +/// expression statement into a docstring, which would change the program's +/// behavior (e.g., by setting `__doc__`). See #27979. +fn fix_creates_docstring(checker: &Checker, expr: &ast::ExprBinOp) -> bool { + // Only concatenations of plain string literals can produce a docstring + // after the fix; f-strings, byte strings, and template strings are not + // recognized as docstrings by Python. + if !matches!( + (expr.left.as_ref(), expr.right.as_ref()), + (Expr::StringLiteral(_), Expr::StringLiteral(_)) + ) { + return false; + } + + let semantic = checker.semantic(); + let stmt = semantic.current_statement(); + let Some(ast::StmtExpr { value, .. }) = stmt.as_expr_stmt() else { + return false; + }; + // The concatenation must be the entire expression statement. + if value.range() != expr.range() { + return false; + } + + // A docstring must be the first statement in the body of a module, + // function, or class. + let body = match semantic.current_statement_parent() { + Some(Stmt::FunctionDef(function)) => &function.body, + Some(Stmt::ClassDef(class)) => &class.body, + // No parent statement: the statement is at module level. + None => checker.module.python_ast, + _ => return false, + }; + body.first() == Some(stmt) +} + fn generate_fix(checker: &Checker, expr_bin_op: &ast::ExprBinOp) -> Option { let ast::ExprBinOp { left, right, .. } = expr_bin_op; @@ -141,8 +183,14 @@ fn generate_fix(checker: &Checker, expr_bin_op: &ast::ExprBinOp) -> Option before_plus.trim_end_matches(is_python_whitespace) }; - Some(Fix::safe_edit(Edit::range_replacement( - format!("{before_plus}{after_plus}"), - between_operands_range, - ))) + let applicability = if fix_creates_docstring(checker, expr_bin_op) { + Applicability::Unsafe + } else { + Applicability::Safe + }; + + Some(Fix::applicable_edit( + Edit::range_replacement(format!("{before_plus}{after_plus}"), between_operands_range), + applicability, + )) } diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs index b1639e1f0f..eddda42d25 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/implicit.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -34,7 +35,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// z = "The quick brown fox." /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.201")] +#[violation_metadata(stable_since = "v0.0.201", category = Category::Restriction)] pub(crate) struct SingleLineImplicitStringConcatenation; impl Violation for SingleLineImplicitStringConcatenation { @@ -92,7 +93,7 @@ impl Violation for SingleLineImplicitStringConcatenation { /// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.201")] +#[violation_metadata(stable_since = "v0.0.201", category = Category::Formatting)] pub(crate) struct MultiLineImplicitStringConcatenation; impl Violation for MultiLineImplicitStringConcatenation { diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC003_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__explicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC003_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__explicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__explicit-string-concatenation_ISC003_docstring.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__explicit-string-concatenation_ISC003_docstring.py.snap new file mode 100644 index 0000000000..899ab0fcf8 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__explicit-string-concatenation_ISC003_docstring.py.snap @@ -0,0 +1,152 @@ +--- +source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs +--- +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:5:5 + | +3 | # Module docstring position: fix is unsafe. +4 | ( +5 | / "docstring" +6 | | + "?" + | |_________^ +7 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +5 | "docstring" + - + "?" +6 + "?" +7 | ) + | +note: This is an unsafe fix and may change runtime behavior + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:12:5 + | +10 | x = 1 +11 | ( +12 | / "not" +13 | | + " a docstring" + | |____________________^ +14 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +12 | "not" + - + " a docstring" +13 + " a docstring" +14 | ) + | + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:20:9 + | +18 | # Function docstring position: fix is unsafe. +19 | ( +20 | / "docstring" +21 | | + "?" + | |_____________^ +22 | ) +23 | return __doc__ + | +help: Remove redundant '+' operator to implicitly concatenate + | +20 | "docstring" + - + "?" +21 + "?" +22 | ) + | +note: This is an unsafe fix and may change runtime behavior + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:29:9 + | +27 | # Class docstring position: fix is unsafe. +28 | ( +29 | / "docstring" +30 | | + "?" + | |_____________^ +31 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +29 | "docstring" + - + "?" +30 + "?" +31 | ) + | +note: This is an unsafe fix and may change runtime behavior + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:36:13 + | +34 | # Method docstring position: fix is unsafe. +35 | ( +36 | / "docstring" +37 | | + "?" + | |_________________^ +38 | ) +39 | return self.__doc__ + | +help: Remove redundant '+' operator to implicitly concatenate + | +36 | "docstring" + - + "?" +37 + "?" +38 | ) + | +note: This is an unsafe fix and may change runtime behavior + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:45:9 + | +43 | # F-strings cannot be docstrings: fix is safe. +44 | ( +45 | / f"not" +46 | | + " a docstring" + | |________________________^ +47 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +45 | f"not" + - + " a docstring" +46 + " a docstring" +47 | ) + | + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:53:9 + | +51 | # Byte strings cannot be docstrings: fix is safe. +52 | ( +53 | / b"not" +54 | | + b" a docstring" + | |_________________________^ +55 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +53 | b"not" + - + b" a docstring" +54 + b" a docstring" +55 | ) + | + +ISC003 [*] Explicitly concatenated string should be implicitly concatenated + --> ISC003_docstring.py:62:9 + | +60 | # expression. +61 | print( +62 | / "not" +63 | | + " a docstring" + | |________________________^ +64 | ) + | +help: Remove redundant '+' operator to implicitly concatenate + | +62 | "not" + - + " a docstring" +63 + " a docstring" +64 | ) + | diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC004_ISC004.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__implicit-string-concatenation-in-collection-literal_ISC004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC004_ISC004.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__implicit-string-concatenation-in-collection-literal_ISC004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC_syntax_error_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error_2.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multi-line-implicit-string-concatenation_ISC_syntax_error_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC003_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_explicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC003_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_explicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC002_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_multi-line-implicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC002_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_multi-line-implicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_single-line-implicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_single-line-implicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC001_ISC.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__multiline_ISC001_ISC.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC_syntax_error_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error_2.py.snap rename to crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__single-line-implicit-string-concatenation_ISC_syntax_error_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_alias.rs b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_alias.rs index 080e5f3977..00130619e7 100644 --- a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_alias.rs +++ b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_alias.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_import_conventions::settings::BannedAliases; /// ## What it does @@ -34,7 +35,7 @@ use crate::rules::flake8_import_conventions::settings::BannedAliases; /// ## Options /// - `lint.flake8-import-conventions.banned-aliases` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Pedantic)] pub(crate) struct BannedImportAlias { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_from.rs b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_from.rs index e27123fe71..a9ccb83ed4 100644 --- a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_from.rs +++ b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/banned_import_from.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::Stmt; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -33,7 +34,7 @@ use crate::{Violation, checkers::ast::Checker}; /// ## Options /// - `lint.flake8-import-conventions.banned-from` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.263")] +#[violation_metadata(stable_since = "v0.0.263", category = Category::Pedantic)] pub(crate) struct BannedImportFrom { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/unconventional_import_alias.rs b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/unconventional_import_alias.rs index 6827e99b93..4d4d1180fd 100644 --- a/crates/ruff_linter/src/rules/flake8_import_conventions/rules/unconventional_import_alias.rs +++ b/crates/ruff_linter/src/rules/flake8_import_conventions/rules/unconventional_import_alias.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::{Binding, Imported}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Fix, FixAvailability, Violation}; use crate::renamer::Renamer; @@ -35,7 +36,7 @@ use crate::renamer::Renamer; /// - `lint.flake8-import-conventions.aliases` /// - `lint.flake8-import-conventions.extend-aliases` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.166")] +#[violation_metadata(stable_since = "v0.0.166", category = Category::Pedantic)] pub(crate) struct UnconventionalImportAlias { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/flake8_logging/mod.rs b/crates/ruff_linter/src/rules/flake8_logging/mod.rs index 7c736814b6..1e4363b436 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/mod.rs @@ -24,7 +24,7 @@ mod tests { #[test_case(Rule::ExcInfoOutsideExceptHandler, Path::new("LOG014_1.py"))] #[test_case(Rule::RootLoggerCall, Path::new("LOG015.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_logging").join(path).as_path(), &LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/direct_logger_instantiation.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/direct_logger_instantiation.rs index 29519be884..a1f32fecfa 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/direct_logger_instantiation.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/direct_logger_instantiation.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -42,7 +43,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [Logger Objects]: https://docs.python.org/3/library/logging.html#logger-objects #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Correctness)] pub(crate) struct DirectLoggerInstantiation; impl Violation for DirectLoggerInstantiation { diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/exc_info_outside_except_handler.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/exc_info_outside_except_handler.rs index c6f3bd88d8..d0f645ce9d 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/exc_info_outside_except_handler.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/exc_info_outside_except_handler.rs @@ -6,6 +6,7 @@ use ruff_python_stdlib::logging::LoggingLevel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::rules::flake8_logging::helpers::outside_handlers; use crate::{Fix, FixAvailability, Violation}; @@ -69,7 +70,7 @@ use crate::{Fix, FixAvailability, Violation}; /// /// - `lint.logger-objects` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Suspicious)] pub(crate) struct ExcInfoOutsideExceptHandler; impl Violation for ExcInfoOutsideExceptHandler { diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/exception_without_exc_info.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/exception_without_exc_info.rs index 425c1040bb..d0911af0d8 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/exception_without_exc_info.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/exception_without_exc_info.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `logging.exception()` with `exc_info` set to `False`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// /// - `lint.logger-objects` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Pedantic)] pub(crate) struct ExceptionWithoutExcInfo; impl Violation for ExceptionWithoutExcInfo { diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/invalid_get_logger_argument.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/invalid_get_logger_argument.rs index 948d55f16d..60d2c29ac8 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/invalid_get_logger_argument.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/invalid_get_logger_argument.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -45,7 +46,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [logging documentation]: https://docs.python.org/3/library/logging.html#logger-objects #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct InvalidGetLoggerArgument; impl Violation for InvalidGetLoggerArgument { diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/log_exception_outside_except_handler.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/log_exception_outside_except_handler.rs index 1e974df0d0..e4306983b7 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/log_exception_outside_except_handler.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/log_exception_outside_except_handler.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::logging; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_logging::helpers::outside_handlers; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -69,7 +70,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [The documentation]: https://docs.python.org/3/library/logging.html#logging.exception #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Pedantic)] pub(crate) struct LogExceptionOutsideExceptHandler; impl Violation for LogExceptionOutsideExceptHandler { diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/root_logger_call.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/root_logger_call.rs index c888c1da59..5a62fa4f1c 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/root_logger_call.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/root_logger_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_logging::helpers::is_logger_method_name; /// ## What it does @@ -30,7 +31,7 @@ use crate::rules::flake8_logging::helpers::is_logger_method_name; /// logger.info("Foobar") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Style)] pub(crate) struct RootLoggerCall { attr: String, } diff --git a/crates/ruff_linter/src/rules/flake8_logging/rules/undocumented_warn.rs b/crates/ruff_linter/src/rules/flake8_logging/rules/undocumented_warn.rs index 213e9f7c60..22dcf530ab 100644 --- a/crates/ruff_linter/src/rules/flake8_logging/rules/undocumented_warn.rs +++ b/crates/ruff_linter/src/rules/flake8_logging/rules/undocumented_warn.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -33,7 +34,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// logging.basicConfig(level=logging.WARNING) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct UndocumentedWarn; impl Violation for UndocumentedWarn { diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG001_LOG001.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__direct-logger-instantiation_LOG001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG001_LOG001.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__direct-logger-instantiation_LOG001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG014_LOG014_0.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exc-info-outside-except-handler_LOG014_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG014_LOG014_0.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exc-info-outside-except-handler_LOG014_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG014_LOG014_1.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exc-info-outside-except-handler_LOG014_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG014_LOG014_1.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exc-info-outside-except-handler_LOG014_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG007_LOG007.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exception-without-exc-info_LOG007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG007_LOG007.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__exception-without-exc-info_LOG007.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG002_LOG002.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__invalid-get-logger-argument_LOG002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG002_LOG002.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__invalid-get-logger-argument_LOG002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG004_LOG004_0.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__log-exception-outside-except-handler_LOG004_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG004_LOG004_0.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__log-exception-outside-except-handler_LOG004_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG004_LOG004_1.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__log-exception-outside-except-handler_LOG004_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG004_LOG004_1.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__log-exception-outside-except-handler_LOG004_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG015_LOG015.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__root-logger-call_LOG015.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG015_LOG015.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__root-logger-call_LOG015.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG009_LOG009.py.snap b/crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__undocumented-warn_LOG009.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__LOG009_LOG009.py.snap rename to crates/ruff_linter/src/rules/flake8_logging/snapshots/ruff_linter__rules__flake8_logging__tests__undocumented-warn_LOG009.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/mod.rs b/crates/ruff_linter/src/rules/flake8_logging_format/mod.rs index 77c62b4861..95ac618787 100644 --- a/crates/ruff_linter/src/rules/flake8_logging_format/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_logging_format/mod.rs @@ -55,11 +55,7 @@ mod tests { #[test_case(Rule::LoggingFString, Path::new("G004_arg_order.py"))] #[test_case(Rule::LoggingFString, Path::new("G004_implicit_concat.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_logging_format").join(path).as_path(), &settings::LinterSettings { diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004.py.snap b/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004.py.snap rename to crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004_arg_order.py.snap b/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004_arg_order.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004_arg_order.py.snap rename to crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004_arg_order.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004_implicit_concat.py.snap b/crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004_implicit_concat.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__G004_G004_implicit_concat.py.snap rename to crates/ruff_linter/src/rules/flake8_logging_format/snapshots/ruff_linter__rules__flake8_logging_format__tests__preview__logging-f-string_G004_implicit_concat.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/violations.rs b/crates/ruff_linter/src/rules/flake8_logging_format/violations.rs index 9a2c378e57..8880405e0e 100644 --- a/crates/ruff_linter/src/rules/flake8_logging_format/violations.rs +++ b/crates/ruff_linter/src/rules/flake8_logging_format/violations.rs @@ -1,5 +1,6 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Violation}; /// ## What it does @@ -75,7 +76,7 @@ use crate::{AlwaysFixableViolation, Violation}; /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) /// - [Python documentation: Optimization](https://docs.python.org/3/howto/logging.html#optimization) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct LoggingStringFormat; impl Violation for LoggingStringFormat { @@ -160,7 +161,7 @@ impl Violation for LoggingStringFormat { /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) /// - [Python documentation: Optimization](https://docs.python.org/3/howto/logging.html#optimization) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct LoggingPercentFormat; impl Violation for LoggingPercentFormat { @@ -244,7 +245,7 @@ impl Violation for LoggingPercentFormat { /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) /// - [Python documentation: Optimization](https://docs.python.org/3/howto/logging.html#optimization) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct LoggingStringConcat; impl Violation for LoggingStringConcat { @@ -327,7 +328,7 @@ impl Violation for LoggingStringConcat { /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) /// - [Python documentation: Optimization](https://docs.python.org/3/howto/logging.html#optimization) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct LoggingFString; impl Violation for LoggingFString { @@ -385,7 +386,7 @@ impl Violation for LoggingFString { /// - [Python documentation: `logging.warning`](https://docs.python.org/3/library/logging.html#logging.warning) /// - [Python documentation: `logging.Logger.warning`](https://docs.python.org/3/library/logging.html#logging.Logger.warning) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Suspicious)] pub(crate) struct LoggingWarn; impl AlwaysFixableViolation for LoggingWarn { @@ -453,7 +454,7 @@ impl AlwaysFixableViolation for LoggingWarn { /// ## References /// - [Python documentation: LogRecord attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Correctness)] pub(crate) struct LoggingExtraAttrClash(pub String); impl Violation for LoggingExtraAttrClash { @@ -516,7 +517,7 @@ impl Violation for LoggingExtraAttrClash { /// - [Python documentation: `logging.error`](https://docs.python.org/3/library/logging.html#logging.error) /// - [Python documentation: `error`](https://docs.python.org/3/library/logging.html#logging.Logger.error) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Complexity)] pub(crate) struct LoggingExcInfo; impl Violation for LoggingExcInfo { @@ -579,7 +580,7 @@ impl Violation for LoggingExcInfo { /// - [Python documentation: `logging.error`](https://docs.python.org/3/library/logging.html#logging.error) /// - [Python documentation: `error`](https://docs.python.org/3/library/logging.html#logging.Logger.error) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Style)] pub(crate) struct LoggingRedundantExcInfo; impl Violation for LoggingRedundantExcInfo { diff --git a/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs b/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs index 7419c63bb1..ebdc95fd03 100644 --- a/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs +++ b/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs @@ -9,6 +9,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::comments::shebang::ShebangDirective; use crate::fs; use crate::package::PackageRoot; @@ -32,7 +33,7 @@ use crate::package::PackageRoot; /// ## Options /// - `namespace-packages` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.225")] +#[violation_metadata(stable_since = "v0.0.225", category = Category::Pedantic)] pub(crate) struct ImplicitNamespacePackage { filename: String, parent: Option, diff --git a/crates/ruff_linter/src/rules/flake8_pie/mod.rs b/crates/ruff_linter/src/rules/flake8_pie/mod.rs index ec1697ba2f..bd4cb93e3e 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/mod.rs @@ -22,7 +22,7 @@ mod tests { #[test_case(Rule::NonUniqueEnums, Path::new("PIE796.py"))] #[test_case(Rule::NonUniqueEnums, Path::new("PIE796.pyi"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_pie").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/duplicate_class_field_definition.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/duplicate_class_field_definition.rs index cfda53cc04..0e653de795 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/duplicate_class_field_definition.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/duplicate_class_field_definition.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Fix}; @@ -35,7 +36,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// This fix is always marked as unsafe since we cannot know /// for certain which assignment was intended. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct DuplicateClassFieldDefinition { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/multiple_starts_ends_with.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/multiple_starts_ends_with.rs index c73bca7c31..957d93a2f4 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/multiple_starts_ends_with.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/multiple_starts_ends_with.rs @@ -12,6 +12,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::AlwaysFixableViolation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix}; /// ## What it does @@ -49,7 +50,7 @@ use crate::{Edit, Fix}; /// - [Python documentation: `str.startswith`](https://docs.python.org/3/library/stdtypes.html#str.startswith) /// - [Python documentation: `str.endswith`](https://docs.python.org/3/library/stdtypes.html#str.endswith) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.243")] +#[violation_metadata(stable_since = "v0.0.243", category = Category::Complexity)] pub(crate) struct MultipleStartsEndsWith { attr: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/non_unique_enums.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/non_unique_enums.rs index c36234283b..9643158f6e 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/non_unique_enums.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/non_unique_enums.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for enums that contain duplicate values. @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `enum.Enum`](https://docs.python.org/3/library/enum.html#enum.Enum) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.224")] +#[violation_metadata(stable_since = "v0.0.224", category = Category::Suspicious)] pub(crate) struct NonUniqueEnums { value: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/reimplemented_container_builtin.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/reimplemented_container_builtin.rs index f74cfd788f..8a3a0f1156 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/reimplemented_container_builtin.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/reimplemented_container_builtin.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix}; use crate::{FixAvailability, Violation}; @@ -38,7 +39,7 @@ use crate::{FixAvailability, Violation}; /// ## References /// - [Python documentation: `list`](https://docs.python.org/3/library/functions.html#func-list) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Complexity)] pub(crate) struct ReimplementedContainerBuiltin { container: Container, } diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_dict_kwargs.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_dict_kwargs.rs index 2fb4ca6401..2d0388a553 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_dict_kwargs.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_dict_kwargs.rs @@ -8,6 +8,7 @@ use ruff_python_stdlib::identifiers::is_identifier; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -70,7 +71,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: Dictionary displays](https://docs.python.org/3/reference/expressions.html#dictionary-displays) /// - [Python documentation: Calls](https://docs.python.org/3/reference/expressions.html#calls) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Style)] pub(crate) struct UnnecessaryDictKwargs; impl Violation for UnnecessaryDictKwargs { diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_placeholder.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_placeholder.rs index 6ae6f0fc6d..1adb2ad19a 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_placeholder.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_placeholder.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{Expr, ExprStringLiteral, Stmt, StmtExpr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Applicability}; use crate::{Edit, Fix}; @@ -57,7 +58,7 @@ use crate::{Edit, Fix}; /// ## References /// - [Python documentation: The `pass` statement](https://docs.python.org/3/reference/simple_stmts.html#the-pass-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Style)] pub(crate) struct UnnecessaryPlaceholder { kind: Placeholder, } diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_range_start.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_range_start.rs index e12af6e069..3eb2822903 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_range_start.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_range_start.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{AlwaysFixableViolation, Fix}; @@ -27,7 +28,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## References /// - [Python documentation: `range`](https://docs.python.org/3/library/stdtypes.html#range) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.286")] +#[violation_metadata(stable_since = "v0.0.286", category = Category::Complexity)] pub(crate) struct UnnecessaryRangeStart; impl AlwaysFixableViolation for UnnecessaryRangeStart { diff --git a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_spread.rs b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_spread.rs index a5faff18d8..de8dff49c8 100644 --- a/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_spread.rs +++ b/crates/ruff_linter/src/rules/flake8_pie/rules/unnecessary_spread.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::{Ranged, TextLen, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -28,7 +29,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Dictionary displays](https://docs.python.org/3/reference/expressions.html#dictionary-displays) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Complexity)] pub(crate) struct UnnecessarySpread; impl Violation for UnnecessarySpread { diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE794_PIE794.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__duplicate-class-field-definition_PIE794.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE794_PIE794.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__duplicate-class-field-definition_PIE794.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE810_PIE810.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__multiple-starts-ends-with_PIE810.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE810_PIE810.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__multiple-starts-ends-with_PIE810.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE796_PIE796.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__non-unique-enums_PIE796.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE796_PIE796.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__non-unique-enums_PIE796.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE796_PIE796.pyi.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__non-unique-enums_PIE796.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE796_PIE796.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__non-unique-enums_PIE796.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE807_PIE807.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__reimplemented-container-builtin_PIE807.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE807_PIE807.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__reimplemented-container-builtin_PIE807.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE804_PIE804.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-dict-kwargs_PIE804.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE804_PIE804.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-dict-kwargs_PIE804.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE790_PIE790.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-placeholder_PIE790.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE790_PIE790.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-placeholder_PIE790.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE808_PIE808.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-range-start_PIE808.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE808_PIE808.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-range-start_PIE808.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE800_PIE800.py.snap b/crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-spread_PIE800.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__PIE800_PIE800.py.snap rename to crates/ruff_linter/src/rules/flake8_pie/snapshots/ruff_linter__rules__flake8_pie__tests__unnecessary-spread_PIE800.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_print/mod.rs b/crates/ruff_linter/src/rules/flake8_print/mod.rs index 2469a484a9..eba0474c66 100644 --- a/crates/ruff_linter/src/rules/flake8_print/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_print/mod.rs @@ -15,7 +15,7 @@ mod tests { #[test_case(Rule::Print, Path::new("T201.py"))] #[test_case(Rule::PPrint, Path::new("T203.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_print").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_print/rules/print_call.rs b/crates/ruff_linter/src/rules/flake8_print/rules/print_call.rs index 870d43405b..fb4804aa98 100644 --- a/crates/ruff_linter/src/rules/flake8_print/rules/print_call.rs +++ b/crates/ruff_linter/src/rules/flake8_print/rules/print_call.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::delete_stmt; use crate::{Fix, FixAvailability, Violation}; @@ -53,7 +54,7 @@ use crate::{Fix, FixAvailability, Violation}; /// This rule's fix is marked as unsafe, as it will remove `print` statements /// that are used beyond debugging purposes. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.57")] +#[violation_metadata(stable_since = "v0.0.57", category = Category::Restriction)] pub(crate) struct Print; impl Violation for Print { @@ -103,7 +104,7 @@ impl Violation for Print { /// This rule's fix is marked as unsafe, as it will remove `pprint` statements /// that are used beyond debugging purposes. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.57")] +#[violation_metadata(stable_since = "v0.0.57", category = Category::Restriction)] pub(crate) struct PPrint; impl Violation for PPrint { diff --git a/crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__T203_T203.py.snap b/crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__p-print_T203.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__T203_T203.py.snap rename to crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__p-print_T203.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__T201_T201.py.snap b/crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__print_T201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__T201_T201.py.snap rename to crates/ruff_linter/src/rules/flake8_print/snapshots/ruff_linter__rules__flake8_print__tests__print_T201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/mod.rs b/crates/ruff_linter/src/rules/flake8_pyi/mod.rs index 92b2cc156b..3d376c299d 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/mod.rs @@ -131,7 +131,7 @@ mod tests { #[test_case(Rule::RedundantNoneLiteral, Path::new("PYI061.py"))] #[test_case(Rule::RedundantNoneLiteral, Path::new("PYI061.pyi"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_pyi").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -147,11 +147,7 @@ mod tests { #[test_case(Rule::RedundantNumericUnion, Path::new("PYI041_4.py"))] #[test_case(Rule::LegacyTypeComment, Path::new("PYI033.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview_{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview_{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, Path::new("flake8_pyi").join(path).as_path(), @@ -165,7 +161,7 @@ mod tests { #[test_case(Rule::CustomTypeVarForSelf, Path::new("PYI019_0.pyi"))] #[test_case(Rule::CustomTypeVarForSelf, Path::new("PYI019_1.pyi"))] fn custom_classmethod_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_pyi").join(path).as_path(), &settings::LinterSettings { @@ -185,7 +181,7 @@ mod tests { #[test_case(Rule::RedundantNoneLiteral, Path::new("PYI061.py"))] #[test_case(Rule::RedundantNoneLiteral, Path::new("PYI061.pyi"))] fn py38(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("py38_{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("py38_{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_pyi").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY38), diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/any_eq_ne_annotation.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/any_eq_ne_annotation.rs index aeff638af2..c9c8d66ff8 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/any_eq_ne_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/any_eq_ne_annotation.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: The `Any` type](https://docs.python.org/3/library/typing.html#the-any-type) /// - [Mypy documentation: Any vs. object](https://mypy.readthedocs.io/en/latest/dynamic_typing.html#any-vs-object) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Style)] pub(crate) struct AnyEqNeAnnotation { method_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_generator_return_type.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_generator_return_type.rs index b836c4de7f..b10debc897 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_generator_return_type.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_generator_return_type.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -59,7 +60,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// unsafe for any `__iter__` or `__aiter__` method in a `.py` file that has /// more than two statements (including docstrings) in its body. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Style)] pub(crate) struct GeneratorReturnFromIterMethod { return_type: Iterator, method: Method, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_version_info_comparison.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_version_info_comparison.rs index ec9a8a0e9d..c081b09b6b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_version_info_comparison.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/bad_version_info_comparison.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_bad_version_info_in_non_stub_enabled; use crate::registry::Rule; @@ -51,7 +52,7 @@ use crate::registry::Rule; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.254")] +#[violation_metadata(stable_since = "v0.0.254", category = Category::Suspicious)] pub(crate) struct BadVersionInfoComparison; impl Violation for BadVersionInfoComparison { @@ -101,7 +102,7 @@ impl Violation for BadVersionInfoComparison { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Style)] pub(crate) struct BadVersionInfoOrder; impl Violation for BadVersionInfoOrder { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs index 417b34fb9e..e3e86f8410 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{FixAvailability, Violation}; /// ## What it does @@ -28,7 +29,7 @@ use crate::{FixAvailability, Violation}; /// ## References /// - [Python documentation: The `ByteString` type](https://docs.python.org/3/library/typing.html#typing.ByteString) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct ByteStringUsage { origin: ByteStringOrigin, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/collections_named_tuple.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/collections_named_tuple.rs index e6735a6921..6e97bf5e67 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/collections_named_tuple.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/collections_named_tuple.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `collections.namedtuple` in stub files. @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// age: int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Pedantic)] pub(crate) struct CollectionsNamedTuple; impl Violation for CollectionsNamedTuple { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_assignment_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_assignment_in_stub.rs index f5dd22a140..48243cbe4c 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_assignment_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_assignment_in_stub.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for assignments with multiple or non-name targets in stub files. @@ -42,7 +43,7 @@ use crate::checkers::ast::Checker; /// X: TypeAlias = int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Suspicious)] pub(crate) struct ComplexAssignmentInStub; impl Violation for ComplexAssignmentInStub { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_if_statement_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_if_statement_in_stub.rs index d097df3b34..5f52fb0109 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_if_statement_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/complex_if_statement_in_stub.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `if` statements with complex conditionals in stubs. @@ -32,7 +33,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Typing documentation: Version and platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct ComplexIfStatementInStub; impl Violation for ComplexIfStatementInStub { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs index ef7e47ceb2..cb63c42061 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs @@ -11,6 +11,7 @@ use ruff_python_semantic::{Binding, ResolvedReference, ScopeId, SemanticModel}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::{Checker, TypingImporter}; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -89,7 +90,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// [typing_TypeVar]: https://docs.python.org/3/library/typing.html#typing.TypeVar /// [typing_extensions]: https://typing-extensions.readthedocs.io/en/latest/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.283")] +#[violation_metadata(stable_since = "v0.0.283", category = Category::Complexity)] pub(crate) struct CustomTypeVarForSelf { typevar_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/docstring_in_stubs.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/docstring_in_stubs.rs index abaf7035b5..40ea928093 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/docstring_in_stubs.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/docstring_in_stubs.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{ExprStringLiteral, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::{Checker, DocstringState, ExpectedDocstringKind}; +use crate::codes::Category; use crate::docstrings::extraction::docstring_from; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -27,7 +28,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def func(param: int) -> str: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Pedantic)] pub(crate) struct DocstringInStub; impl AlwaysFixableViolation for DocstringInStub { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_literal_member.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_literal_member.rs index 86e8ecc03b..3c220c28cd 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_literal_member.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_literal_member.rs @@ -9,6 +9,7 @@ use ruff_python_semantic::analyze::typing::traverse_literal; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation: `typing.Literal`](https://docs.python.org/3/library/typing.html#typing.Literal) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct DuplicateLiteralMember { duplicate_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs index 65018f5e16..304327886f 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use super::generate_union_fix; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Correctness)] pub(crate) struct DuplicateUnionMember { duplicate_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/ellipsis_in_non_empty_class_body.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/ellipsis_in_non_empty_class_body.rs index 02b6afac17..3ea38a61dd 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/ellipsis_in_non_empty_class_body.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/ellipsis_in_non_empty_class_body.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{Stmt, StmtExpr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -28,7 +29,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// value: int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.270")] +#[violation_metadata(stable_since = "v0.0.270", category = Category::Correctness)] pub(crate) struct EllipsisInNonEmptyClassBody; impl Violation for EllipsisInNonEmptyClassBody { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs index 670ad6b891..a11c30ecef 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs @@ -12,6 +12,7 @@ use ruff_python_semantic::{SemanticModel, analyze::visibility::is_overload}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -47,7 +48,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ) -> None: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Suspicious)] pub(crate) struct BadExitAnnotation { func_kind: FuncKind, error_kind: ErrorKind, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/future_annotations_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/future_annotations_in_stub.rs index 18febc7403..47b4774ed7 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/future_annotations_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/future_annotations_in_stub.rs @@ -2,6 +2,7 @@ use ruff_python_ast::StmtImportFrom; use ruff_macros::{ViolationMetadata, derive_message_formats}; +use crate::codes::Category; use crate::{Fix, FixAvailability, Violation}; use crate::{checkers::ast::Checker, fix}; @@ -18,7 +19,7 @@ use crate::{checkers::ast::Checker, fix}; /// ## References /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#language-features) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Correctness)] pub(crate) struct FutureAnnotationsInStub; impl Violation for FutureAnnotationsInStub { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs index 0ecc916b24..35073565e1 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, helpers::map_subscript}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, add_argument, remove_argument}; use crate::{Fix, FixAvailability, Violation}; @@ -84,7 +85,7 @@ use crate::{Fix, FixAvailability, Violation}; /// [1]: https://github.com/python/cpython/issues/106102 /// [MRO]: https://docs.python.org/3/glossary.html#term-method-resolution-order #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Correctness)] pub(crate) struct GenericNotLastBaseClass; impl Violation for GenericNotLastBaseClass { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/iter_method_return_iterable.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/iter_method_return_iterable.rs index b6f6359014..ddbda45434 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/iter_method_return_iterable.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/iter_method_return_iterable.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{Definition, Member, MemberKind}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__iter__` methods in stubs that return `Iterable[T]` instead @@ -69,7 +70,7 @@ use crate::checkers::ast::Checker; /// def __iter__(self) -> collections.abc.Iterator[str]: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct IterMethodReturnIterable { is_async: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/legacy_type_comment.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/legacy_type_comment.rs index 765aaddf2b..46cf2575ce 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/legacy_type_comment.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/legacy_type_comment.rs @@ -9,6 +9,7 @@ use ruff_python_trivia::CommentRanges; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::preview::is_legacy_type_comment_in_non_stub_enabled; /// ## What it does @@ -35,7 +36,7 @@ use crate::preview::is_legacy_type_comment_in_non_stub_enabled; /// x: int = 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.254")] +#[violation_metadata(stable_since = "v0.0.254", category = Category::Suspicious)] pub(crate) struct LegacyTypeComment; impl Violation for LegacyTypeComment { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/no_return_argument_annotation.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/no_return_argument_annotation.rs index 1fa1d645ba..688c18c9e4 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/no_return_argument_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/no_return_argument_annotation.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use ruff_python_ast::PythonVersion; /// ## What it does @@ -41,7 +42,7 @@ use ruff_python_ast::PythonVersion; /// /// [bottom type]: https://en.wikipedia.org/wiki/Bottom_type #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.272")] +#[violation_metadata(stable_since = "v0.0.272", category = Category::Style)] pub(crate) struct NoReturnArgumentAnnotationInStub { module: TypingModule, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/non_empty_stub_body.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/non_empty_stub_body.rs index 36d2fc11b5..c19e8aaf67 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/non_empty_stub_body.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/non_empty_stub_body.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -28,7 +29,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Typing documentation - Writing and Maintaining Stub Files](https://typing.python.org/en/latest/guides/writing_stubs.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Correctness)] pub(crate) struct NonEmptyStubBody; impl AlwaysFixableViolation for NonEmptyStubBody { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/non_self_return_type.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/non_self_return_type.rs index 77d9d049a8..d017f87418 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/non_self_return_type.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/non_self_return_type.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::{Checker, TypingImporter}; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast as ast; @@ -114,7 +115,7 @@ use ruff_text_size::Ranged; /// /// [PEP 673]: https://peps.python.org/pep-0673/#valid-locations-for-self #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Suspicious)] pub(crate) struct NonSelfReturnType { class_name: String, method_name: String, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/numeric_literal_too_long.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/numeric_literal_too_long.rs index 83b48e677a..94e1b2bf82 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/numeric_literal_too_long.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/numeric_literal_too_long.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextSize}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def foo(arg: int = ...) -> None: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Pedantic)] pub(crate) struct NumericLiteralTooLong; impl AlwaysFixableViolation for NumericLiteralTooLong { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_in_class_body.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_in_class_body.rs index 336d2664f7..ac55d1790a 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_in_class_body.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_in_class_body.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Fix}; @@ -27,7 +28,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// x: int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Correctness)] pub(crate) struct PassInClassBody; impl AlwaysFixableViolation for PassInClassBody { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_statement_stub_body.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_statement_stub_body.rs index 6aa512ca15..28bf6b6092 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_statement_stub_body.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/pass_statement_stub_body.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Stmt; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -25,7 +26,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Typing documentation - Writing and Maintaining Stub Files](https://typing.python.org/en/latest/guides/writing_stubs.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Style)] pub(crate) struct PassStatementStubBody; impl AlwaysFixableViolation for PassStatementStubBody { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/pre_pep570_positional_argument.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/pre_pep570_positional_argument.rs index 507a1b9808..ab48e3240b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/pre_pep570_positional_argument.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/pre_pep570_positional_argument.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::function_type; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use ruff_python_ast::PythonVersion; /// ## What it does @@ -40,7 +41,7 @@ use ruff_python_ast::PythonVersion; /// [PEP 484]: https://peps.python.org/pep-0484/#positional-only-arguments /// [PEP 570]: https://peps.python.org/pep-0570 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Style)] pub(crate) struct Pep484StylePositionalOnlyParameter; impl Violation for Pep484StylePositionalOnlyParameter { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/prefix_type_params.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/prefix_type_params.rs index 22f14f043e..0ca0c0db27 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/prefix_type_params.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/prefix_type_params.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq, Copy, Clone)] pub(crate) enum VarKind { @@ -46,7 +47,7 @@ impl fmt::Display for VarKind { /// _T = TypeVar("_T") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Style)] pub(crate) struct UnprefixedTypeParam { kind: VarKind, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/quoted_annotation_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/quoted_annotation_in_stub.rs index 86bdf32a98..26d5e26b90 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/quoted_annotation_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/quoted_annotation_in_stub.rs @@ -3,6 +3,7 @@ use ruff_text_size::TextRange; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -29,7 +30,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Typing documentation - Writing and Maintaining Stub Files](https://typing.python.org/en/latest/guides/writing_stubs.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.265")] +#[violation_metadata(stable_since = "v0.0.265", category = Category::Style)] pub(crate) struct QuotedAnnotationInStub; impl AlwaysFixableViolation for QuotedAnnotationInStub { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_final_literal.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_final_literal.rs index 8b44eab1ce..b4a87bed78 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_final_literal.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_final_literal.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextSize}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -34,7 +35,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// y: Final = 42 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Complexity)] pub(crate) struct RedundantFinalLiteral { literal: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_literal_union.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_literal_union.rs index b62d5dc223..a3684197c7 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_literal_union.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_literal_union.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; /// ## What it does @@ -43,7 +44,7 @@ use crate::fix::snippet::SourceCodeSnippet; /// non-type-checking purpose. In those cases, disabling this rule for the /// affected annotations may be reasonable. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.283")] +#[violation_metadata(stable_since = "v0.0.283", category = Category::Pedantic)] pub(crate) struct RedundantLiteralUnion { literal: SourceCodeSnippet, builtin_type: ExprType, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs index 46ff524888..3cded41158 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs @@ -13,6 +13,7 @@ use ruff_text_size::{Ranged, TextRange}; use smallvec::SmallVec; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -53,7 +54,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Typing documentation: Legal parameters for `Literal` at type check time](https://typing.python.org/en/latest/spec/literal.html#legal-parameters-for-literal-at-type-check-time) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Style)] pub(crate) struct RedundantNoneLiteral { union_kind: UnionKind, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_numeric_union.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_numeric_union.rs index 7f2e72ff2d..306b8a0b22 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_numeric_union.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_numeric_union.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::typing::traverse_union; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_resolve_string_annotation_pyi041_enabled; use crate::rules::flake8_type_checking::helpers::is_singledispatch_implementation; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -55,7 +56,7 @@ use super::generate_union_fix; /// /// [typing specification]: https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Complexity)] pub(crate) struct RedundantNumericUnion { redundancy: Redundancy, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/simple_defaults.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/simple_defaults.rs index 52105f8756..729e8cfade 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/simple_defaults.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/simple_defaults.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pyi::rules::TypingModule; use crate::{AlwaysFixableViolation, Edit, Fix, Violation}; @@ -41,7 +42,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, Violation}; /// ## References /// - [`flake8-pyi`](https://github.com/PyCQA/flake8-pyi/blob/main/ERRORCODES.md) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Pedantic)] pub(crate) struct TypedArgumentDefaultInStub; impl AlwaysFixableViolation for TypedArgumentDefaultInStub { @@ -88,7 +89,7 @@ impl AlwaysFixableViolation for TypedArgumentDefaultInStub { /// ## References /// - [`flake8-pyi`](https://github.com/PyCQA/flake8-pyi/blob/main/ERRORCODES.md) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Pedantic)] pub(crate) struct ArgumentDefaultInStub; impl AlwaysFixableViolation for ArgumentDefaultInStub { @@ -133,7 +134,7 @@ impl AlwaysFixableViolation for ArgumentDefaultInStub { /// ## References /// - [`flake8-pyi`](https://github.com/PyCQA/flake8-pyi/blob/main/ERRORCODES.md) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.260")] +#[violation_metadata(stable_since = "v0.0.260", category = Category::Style)] pub(crate) struct AssignmentDefaultInStub; impl AlwaysFixableViolation for AssignmentDefaultInStub { @@ -154,7 +155,7 @@ impl AlwaysFixableViolation for AssignmentDefaultInStub { /// Stub files exist to provide type hints, and are never executed. As such, /// all assignments in stub files should be annotated with a type. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Suspicious)] pub(crate) struct UnannotatedAssignmentInStub { name: String, } @@ -186,7 +187,7 @@ impl Violation for UnannotatedAssignmentInStub { /// __all__: list[str] = ["foo", "bar"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct UnassignedSpecialVariableInStub { name: String, } @@ -235,7 +236,7 @@ impl Violation for UnassignedSpecialVariableInStub { /// /// - `lint.typing-extensions` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Style)] pub(crate) struct TypeAliasWithoutAnnotation { module: TypingModule, name: String, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/str_or_repr_defined_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/str_or_repr_defined_in_stub.rs index 0bc8f55eec..087c384ad0 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/str_or_repr_defined_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/str_or_repr_defined_in_stub.rs @@ -6,6 +6,7 @@ use ruff_python_ast::identifier::Identifier; use ruff_python_semantic::analyze::visibility::is_abstract; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::delete_stmt; use crate::{AlwaysFixableViolation, Fix}; @@ -24,7 +25,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// def __repr__(self) -> str: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Style)] pub(crate) struct StrOrReprDefinedInStub { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs index d4fd623c4d..774fc59dda 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def foo(arg: str = ...) -> None: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Pedantic)] pub(crate) struct StringOrBytesTooLong; impl AlwaysFixableViolation for StringOrBytesTooLong { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/stub_body_multiple_statements.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/stub_body_multiple_statements.rs index e0e26b055d..6d2fe417ad 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/stub_body_multiple_statements.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/stub_body_multiple_statements.rs @@ -4,6 +4,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for functions in stub (`.pyi`) files that contain multiple @@ -29,7 +30,7 @@ use crate::checkers::ast::Checker; /// def function(): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct StubBodyMultipleStatements; impl Violation for StubBodyMultipleStatements { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/type_alias_naming.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/type_alias_naming.rs index a7d5d4b387..85b0eadeab 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/type_alias_naming.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/type_alias_naming.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for type aliases that do not use the CamelCase naming convention. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// TypeAliasName: TypeAlias = int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.265")] +#[violation_metadata(stable_since = "v0.0.265", category = Category::Style)] pub(crate) struct SnakeCaseTypeAlias { name: String, } @@ -66,7 +67,7 @@ impl Violation for SnakeCaseTypeAlias { /// ## References /// - [PEP 484: Type Aliases](https://peps.python.org/pep-0484/#type-aliases) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.265")] +#[violation_metadata(stable_since = "v0.0.265", category = Category::Style)] pub(crate) struct TSuffixedTypeAlias { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unaliased_collections_abc_set_import.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unaliased_collections_abc_set_import.rs index 185b6fd4de..af6a742cf1 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unaliased_collections_abc_set_import.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unaliased_collections_abc_set_import.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::{Binding, BindingKind, Scope}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::renamer::Renamer; use crate::{Applicability, Fix, FixAvailability, Violation}; @@ -40,7 +41,7 @@ use crate::{Applicability, Fix, FixAvailability, Violation}; /// `import foo as foo` alias, or are imported via a `*` import. As such, the /// fix is marked as safe in more cases for `.pyi` files. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Style)] pub(crate) struct UnaliasedCollectionsAbcSetImport; impl Violation for UnaliasedCollectionsAbcSetImport { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs index cacbd8a6dc..fe588b186d 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::typing::traverse_union; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -47,7 +48,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `typing.Literal`](https://docs.python.org/3/library/typing.html#typing.Literal) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Complexity)] pub(crate) struct UnnecessaryLiteralUnion { members: Vec, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_type_union.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_type_union.rs index a82d9a1d56..8ed920eea6 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_type_union.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_type_union.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::traverse_union; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -32,7 +33,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// Note that while the fix may flatten nested unions into a single top-level union, /// the semantics of the annotation will remain unchanged. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.283")] +#[violation_metadata(stable_since = "v0.0.283", category = Category::Complexity)] pub(crate) struct UnnecessaryTypeUnion { members: Vec, union_kind: UnionKind, diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_platform.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_platform.rs index d7a22d0940..6ed2b09ee9 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_platform.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_platform.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; /// ## What it does @@ -46,7 +47,7 @@ use crate::registry::Rule; /// ## References /// - [Typing documentation: Version and Platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Suspicious)] pub(crate) struct UnrecognizedPlatformCheck; impl Violation for UnrecognizedPlatformCheck { @@ -85,7 +86,7 @@ impl Violation for UnrecognizedPlatformCheck { /// ## References /// - [Typing documentation: Version and Platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Suspicious)] pub(crate) struct UnrecognizedPlatformName { platform: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_version_info.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_version_info.rs index 3fe1fa58ff..73806e025e 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_version_info.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unrecognized_version_info.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; /// ## What it does @@ -33,7 +34,7 @@ use crate::registry::Rule; /// ## References /// - [Typing documentation: Version and Platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct UnrecognizedVersionInfoCheck; impl Violation for UnrecognizedVersionInfoCheck { @@ -73,7 +74,7 @@ impl Violation for UnrecognizedVersionInfoCheck { /// ## References /// - [Typing documentation: Version and Platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct PatchVersionComparison; impl Violation for PatchVersionComparison { @@ -110,7 +111,7 @@ impl Violation for PatchVersionComparison { /// ## References /// - [Typing documentation: Version and Platform checking](https://typing.python.org/en/latest/spec/directives.html#version-and-platform-checks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct WrongTupleLengthVersionComparison { expected_length: usize, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unsupported_method_call_on_all.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unsupported_method_call_on_all.rs index fb55360533..3e22b1b9ff 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unsupported_method_call_on_all.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unsupported_method_call_on_all.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks that `append`, `extend` and `remove` methods are not called on @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// __all__ += ["C"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Pedantic)] pub(crate) struct UnsupportedMethodCallOnAll { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unused_private_type_definition.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unused_private_type_definition.rs index 49889d9fa9..ded87729b5 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unused_private_type_definition.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unused_private_type_definition.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::{Scope, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{Fix, FixAvailability, Violation}; @@ -30,7 +31,7 @@ use crate::{Fix, FixAvailability, Violation}; /// The fix is always marked as unsafe, as it would break your code if the type /// variable is imported by another module. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct UnusedPrivateTypeVar { type_var_like_name: String, type_var_like_kind: String, @@ -87,7 +88,7 @@ impl Violation for UnusedPrivateTypeVar { /// def func(arg: _PrivateProtocol) -> None: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct UnusedPrivateProtocol { name: String, } @@ -126,7 +127,7 @@ impl Violation for UnusedPrivateProtocol { /// def func(arg: _UsedTypeAlias) -> _UsedTypeAlias: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct UnusedPrivateTypeAlias { name: String, } @@ -167,7 +168,7 @@ impl Violation for UnusedPrivateTypeAlias { /// def func(arg: _UsedPrivateTypedDict) -> _UsedPrivateTypedDict: ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct UnusedPrivateTypedDict { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI032_PYI032.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__any-eq-ne-annotation_PYI032.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI032_PYI032.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__any-eq-ne-annotation_PYI032.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI032_PYI032.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__any-eq-ne-annotation_PYI032.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI032_PYI032.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__any-eq-ne-annotation_PYI032.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI001_PYI001.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__argument-default-in-stub_PYI014.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI001_PYI001.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__argument-default-in-stub_PYI014.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI014_PYI014.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__argument-default-in-stub_PYI014.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI014_PYI014.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__argument-default-in-stub_PYI014.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI002_PYI002.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__assignment-default-in-stub_PYI015.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI002_PYI002.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__assignment-default-in-stub_PYI015.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI015_PYI015.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__assignment-default-in-stub_PYI015.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI015_PYI015.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__assignment-default-in-stub_PYI015.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI036_PYI036.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-exit-annotation_PYI036.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI036_PYI036.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-exit-annotation_PYI036.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-exit-annotation_PYI036.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-exit-annotation_PYI036.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI006_PYI006.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-comparison_PYI006.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI006_PYI006.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-comparison_PYI006.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI006_PYI006.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-comparison_PYI006.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI006_PYI006.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-comparison_PYI006.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI003_PYI003.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-order_PYI066.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI003_PYI003.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-order_PYI066.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI066_PYI066.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-order_PYI066.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI066_PYI066.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__bad-version-info-order_PYI066.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI057_PYI057.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__byte-string-usage_PYI057.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI057_PYI057.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__byte-string-usage_PYI057.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI057_PYI057.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__byte-string-usage_PYI057.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI057_PYI057.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__byte-string-usage_PYI057.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI024_PYI024.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__collections-named-tuple_PYI024.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI024_PYI024.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__collections-named-tuple_PYI024.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI024_PYI024.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__collections-named-tuple_PYI024.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI024_PYI024.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__collections-named-tuple_PYI024.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI004_PYI004.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-assignment-in-stub_PYI017.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI004_PYI004.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-assignment-in-stub_PYI017.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI017_PYI017.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-assignment-in-stub_PYI017.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI017_PYI017.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-assignment-in-stub_PYI017.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI005_PYI005.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-if-statement-in-stub_PYI002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI005_PYI005.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-if-statement-in-stub_PYI002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI002_PYI002.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-if-statement-in-stub_PYI002.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI002_PYI002.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__complex-if-statement-in-stub_PYI002.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_0.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_0.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_0.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_0.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_0.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_0.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI019_PYI019_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__custom-type-var-for-self_PYI019_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI007_PYI007.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__docstring-in-stub_PYI021.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI007_PYI007.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__docstring-in-stub_PYI021.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI021_PYI021.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__docstring-in-stub_PYI021.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI021_PYI021.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__docstring-in-stub_PYI021.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI062_PYI062.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-literal-member_PYI062.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI062_PYI062.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-literal-member_PYI062.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI062_PYI062.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-literal-member_PYI062.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI062_PYI062.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-literal-member_PYI062.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-union-member_PYI016.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-union-member_PYI016.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-union-member_PYI016.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__duplicate-union-member_PYI016.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI013_PYI013.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__ellipsis-in-non-empty-class-body_PYI013.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI013_PYI013.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__ellipsis-in-non-empty-class-body_PYI013.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI013_PYI013.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__ellipsis-in-non-empty-class-body_PYI013.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI013_PYI013.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__ellipsis-in-non-empty-class-body_PYI013.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI008_PYI008.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__future-annotations-in-stub_PYI044.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI008_PYI008.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__future-annotations-in-stub_PYI044.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI044_PYI044.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__future-annotations-in-stub_PYI044.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI044_PYI044.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__future-annotations-in-stub_PYI044.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI058_PYI058.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generator-return-from-iter-method_PYI058.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI058_PYI058.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generator-return-from-iter-method_PYI058.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI058_PYI058.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generator-return-from-iter-method_PYI058.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI058_PYI058.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generator-return-from-iter-method_PYI058.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generic-not-last-base-class_PYI059.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generic-not-last-base-class_PYI059.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generic-not-last-base-class_PYI059.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__generic-not-last-base-class_PYI059.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI045_PYI045.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__iter-method-return-iterable_PYI045.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI045_PYI045.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__iter-method-return-iterable_PYI045.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI045_PYI045.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__iter-method-return-iterable_PYI045.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI045_PYI045.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__iter-method-return-iterable_PYI045.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI009_PYI009.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__legacy-type-comment_PYI033.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI009_PYI009.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__legacy-type-comment_PYI033.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI033_PYI033.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__legacy-type-comment_PYI033.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI033_PYI033.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__legacy-type-comment_PYI033.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI050_PYI050.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__no-return-argument-annotation-in-stub_PYI050.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI050_PYI050.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__no-return-argument-annotation-in-stub_PYI050.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI050_PYI050.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__no-return-argument-annotation-in-stub_PYI050.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI050_PYI050.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__no-return-argument-annotation-in-stub_PYI050.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI010_PYI010.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-empty-stub-body_PYI010.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI010_PYI010.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-empty-stub-body_PYI010.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI010_PYI010.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-empty-stub-body_PYI010.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI010_PYI010.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-empty-stub-body_PYI010.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI034_PYI034.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-self-return-type_PYI034.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI034_PYI034.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-self-return-type_PYI034.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI034_PYI034.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-self-return-type_PYI034.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI034_PYI034.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__non-self-return-type_PYI034.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI011_PYI011.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__numeric-literal-too-long_PYI054.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI011_PYI011.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__numeric-literal-too-long_PYI054.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI054_PYI054.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__numeric-literal-too-long_PYI054.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI054_PYI054.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__numeric-literal-too-long_PYI054.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI012_PYI012.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-in-class-body_PYI012.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI012_PYI012.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-in-class-body_PYI012.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI012_PYI012.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-in-class-body_PYI012.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI012_PYI012.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-in-class-body_PYI012.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI014_PYI014.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-statement-stub-body_PYI009.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI014_PYI014.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-statement-stub-body_PYI009.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI009_PYI009.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-statement-stub-body_PYI009.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI009_PYI009.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pass-statement-stub-body_PYI009.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI015_PYI015.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__patch-version-comparison_PYI004.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI015_PYI015.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__patch-version-comparison_PYI004.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI004_PYI004.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__patch-version-comparison_PYI004.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI004_PYI004.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__patch-version-comparison_PYI004.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI063_PYI063.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pep484-style-positional-only-parameter_PYI063.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI063_PYI063.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pep484-style-positional-only-parameter_PYI063.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI063_PYI063.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pep484-style-positional-only-parameter_PYI063.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI063_PYI063.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__pep484-style-positional-only-parameter_PYI063.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI033_PYI033.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_legacy-type-comment_PYI033.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI033_PYI033.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_legacy-type-comment_PYI033.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_1.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_1.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_2.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_2.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_3.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_3.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_4.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_PYI041_PYI041_4.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview_redundant-numeric-union_PYI041_4.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_redundant-none-literal_PYI061.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_redundant-none-literal_PYI061.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_redundant-none-literal_PYI061.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_redundant-none-literal_PYI061.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI017_PYI017.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_type-alias-without-annotation_PYI026.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI017_PYI017.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_type-alias-without-annotation_PYI026.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI026_PYI026.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_type-alias-without-annotation_PYI026.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI026_PYI026.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_type-alias-without-annotation_PYI026.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI020_PYI020.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__quoted-annotation-in-stub_PYI020.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI020_PYI020.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__quoted-annotation-in-stub_PYI020.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI020_PYI020.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__quoted-annotation-in-stub_PYI020.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI020_PYI020.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__quoted-annotation-in-stub_PYI020.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI064_PYI064.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-final-literal_PYI064.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI064_PYI064.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-final-literal_PYI064.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI064_PYI064.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-final-literal_PYI064.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI064_PYI064.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-final-literal_PYI064.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI051_PYI051.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-literal-union_PYI051.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI051_PYI051.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-literal-union_PYI051.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI051_PYI051.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-literal-union_PYI051.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI051_PYI051.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-literal-union_PYI051.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-none-literal_PYI061.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-none-literal_PYI061.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-none-literal_PYI061.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-none-literal_PYI061.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_1.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_1.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_2.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_2.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI021_PYI021.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI021_PYI021.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI026_PYI026.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI026_PYI026.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__redundant-numeric-union_PYI041_4.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI042_PYI042.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__snake-case-type-alias_PYI042.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI042_PYI042.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__snake-case-type-alias_PYI042.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI042_PYI042.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__snake-case-type-alias_PYI042.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI042_PYI042.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__snake-case-type-alias_PYI042.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI029_PYI029.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__str-or-repr-defined-in-stub_PYI029.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI029_PYI029.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__str-or-repr-defined-in-stub_PYI029.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI029_PYI029.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__str-or-repr-defined-in-stub_PYI029.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI029_PYI029.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__str-or-repr-defined-in-stub_PYI029.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI033_PYI033.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__string-or-bytes-too-long_PYI053.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI033_PYI033.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__string-or-bytes-too-long_PYI053.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI053_PYI053.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__string-or-bytes-too-long_PYI053.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI053_PYI053.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__string-or-bytes-too-long_PYI053.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI035_PYI035.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__stub-body-multiple-statements_PYI048.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI035_PYI035.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__stub-body-multiple-statements_PYI048.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI048_PYI048.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__stub-body-multiple-statements_PYI048.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI048_PYI048.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__stub-body-multiple-statements_PYI048.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI043_PYI043.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__t-suffixed-type-alias_PYI043.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI043_PYI043.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__t-suffixed-type-alias_PYI043.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI043_PYI043.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__t-suffixed-type-alias_PYI043.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI043_PYI043.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__t-suffixed-type-alias_PYI043.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_3.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__type-alias-without-annotation_PYI026.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_3.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__type-alias-without-annotation_PYI026.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI026_PYI026.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__type-alias-without-annotation_PYI026.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI026_PYI026.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__type-alias-without-annotation_PYI026.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_4.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__typed-argument-default-in-stub_PYI011.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI041_PYI041_4.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__typed-argument-default-in-stub_PYI011.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI011_PYI011.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__typed-argument-default-in-stub_PYI011.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI011_PYI011.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__typed-argument-default-in-stub_PYI011.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_1.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_1.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_1.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_1.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_1.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_1.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_2.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_2.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_2.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_2.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_2.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_2.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_3.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_3.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_3.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_3.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI025_PYI025_3.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unaliased-collections-abc-set-import_PYI025_3.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI044_PYI044.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unannotated-assignment-in-stub_PYI052.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI044_PYI044.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unannotated-assignment-in-stub_PYI052.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI052_PYI052.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unannotated-assignment-in-stub_PYI052.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI052_PYI052.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unannotated-assignment-in-stub_PYI052.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI048_PYI048.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unassigned-special-variable-in-stub_PYI035.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI048_PYI048.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unassigned-special-variable-in-stub_PYI035.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI035_PYI035.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unassigned-special-variable-in-stub_PYI035.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI035_PYI035.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unassigned-special-variable-in-stub_PYI035.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-literal-union_PYI030.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-literal-union_PYI030.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-literal-union_PYI030.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-literal-union_PYI030.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI055_PYI055.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-type-union_PYI055.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI055_PYI055.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-type-union_PYI055.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI055_PYI055.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-type-union_PYI055.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI055_PYI055.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unnecessary-type-union_PYI055.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI052_PYI052.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unprefixed-type-param_PYI001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI052_PYI052.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unprefixed-type-param_PYI001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI001_PYI001.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unprefixed-type-param_PYI001.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI001_PYI001.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unprefixed-type-param_PYI001.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI053_PYI053.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-check_PYI007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI053_PYI053.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-check_PYI007.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI007_PYI007.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-check_PYI007.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI007_PYI007.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-check_PYI007.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI054_PYI054.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-name_PYI008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI054_PYI054.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-name_PYI008.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI008_PYI008.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-name_PYI008.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI008_PYI008.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-platform-name_PYI008.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI066_PYI066.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-version-info-check_PYI003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI066_PYI066.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-version-info-check_PYI003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI003_PYI003.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-version-info-check_PYI003.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI003_PYI003.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unrecognized-version-info-check_PYI003.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI056_PYI056.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unsupported-method-call-on-all_PYI056.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI056_PYI056.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unsupported-method-call-on-all_PYI056.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI056_PYI056.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unsupported-method-call-on-all_PYI056.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI056_PYI056.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unsupported-method-call-on-all_PYI056.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI046_PYI046.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-protocol_PYI046.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI046_PYI046.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-protocol_PYI046.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI046_PYI046.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-protocol_PYI046.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI046_PYI046.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-protocol_PYI046.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI047_PYI047.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-alias_PYI047.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI047_PYI047.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-alias_PYI047.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI047_PYI047.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-alias_PYI047.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI047_PYI047.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-alias_PYI047.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI018_PYI018.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-var_PYI018.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI018_PYI018.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-var_PYI018.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI018_PYI018.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-var_PYI018.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI018_PYI018.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-type-var_PYI018.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-typed-dict_PYI049.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-typed-dict_PYI049.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-typed-dict_PYI049.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__unused-private-typed-dict_PYI049.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI026_PYI026.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__wrong-tuple-length-version-comparison_PYI005.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI026_PYI026.py.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__wrong-tuple-length-version-comparison_PYI005.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI005_PYI005.pyi.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__wrong-tuple-length-version-comparison_PYI005.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI005_PYI005.pyi.snap rename to crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__wrong-tuple-length-version-comparison_PYI005.pyi.snap diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs index e97adff100..c3118f6fbb 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs @@ -225,6 +225,18 @@ mod tests { Settings::default(), "PT020" )] + #[test_case( + Rule::PytestDeprecatedYieldFixture, + Path::new("PT020_1.py"), + Settings::default(), + "PT020_1" + )] + #[test_case( + Rule::PytestDeprecatedYieldFixture, + Path::new("PT020_2.py"), + Settings::default(), + "PT020_2" + )] #[test_case( Rule::PytestFixtureFinalizerCallback, Path::new("PT021.py"), @@ -380,12 +392,11 @@ mod tests { #[test_case(Rule::PytestExtraneousScopeFunction, Path::new("PT003.py"))] #[test_case(Rule::PytestCompositeAssertion, Path::new("PT018.py"))] + #[test_case(Rule::PytestDeprecatedYieldFixture, Path::new("PT020.py"))] + #[test_case(Rule::PytestDeprecatedYieldFixture, Path::new("PT020_1.py"))] + #[test_case(Rule::PytestDeprecatedYieldFixture, Path::new("PT020_2.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, Path::new("flake8_pytest_style").join(path).as_path(), diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs index b529d8485c..52261b2409 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs @@ -23,6 +23,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::helpers::negate; use crate::cst::matchers::match_indented_block; use crate::cst::matchers::match_module; @@ -70,7 +71,7 @@ use super::unittest_assert::UnittestAssert; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestCompositeAssertion; impl Violation for PytestCompositeAssertion { @@ -128,7 +129,7 @@ impl Violation for PytestCompositeAssertion { /// ## References /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestAssertInExcept { name: String, } @@ -170,7 +171,7 @@ impl Violation for PytestAssertInExcept { /// ## References /// - [`pytest` documentation: `pytest.fail`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fail) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestAssertAlwaysFalse; impl Violation for PytestAssertAlwaysFalse { @@ -210,7 +211,7 @@ impl Violation for PytestAssertAlwaysFalse { /// ## References /// - [`pytest` documentation: Assertion introspection details](https://docs.pytest.org/en/7.1.x/how-to/assert.html#assertion-introspection-details) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestUnittestAssertion { assertion: String, } @@ -234,7 +235,11 @@ impl Violation for PytestUnittestAssertion { /// the exception name. struct ExceptionHandlerVisitor<'a, 'b> { exception_name: &'a str, - current_assert: Option<&'a Stmt>, + /// The `assert` statement that is currently being visited, if it has not been reported yet. + /// + /// This is set to `None` as soon as a diagnostic is reported, so that an `assert` that refers + /// to the exception more than once is only reported once. + pending_assert: Option<&'a Stmt>, checker: &'a Checker<'b>, } @@ -242,7 +247,7 @@ impl<'a, 'b> ExceptionHandlerVisitor<'a, 'b> { const fn new(checker: &'a Checker<'b>, exception_name: &'a str) -> Self { Self { exception_name, - current_assert: None, + pending_assert: None, checker, } } @@ -252,9 +257,9 @@ impl<'a> Visitor<'a> for ExceptionHandlerVisitor<'a, '_> { fn visit_stmt(&mut self, stmt: &'a Stmt) { match stmt { Stmt::Assert(_) => { - self.current_assert = Some(stmt); + self.pending_assert = Some(stmt); visitor::walk_stmt(self, stmt); - self.current_assert = None; + self.pending_assert = None; } _ => visitor::walk_stmt(self, stmt), } @@ -263,15 +268,16 @@ impl<'a> Visitor<'a> for ExceptionHandlerVisitor<'a, '_> { fn visit_expr(&mut self, expr: &'a Expr) { match expr { Expr::Name(ast::ExprName { id, .. }) => { - if let Some(current_assert) = self.current_assert { - if id.as_str() == self.exception_name { - self.checker.report_diagnostic( - PytestAssertInExcept { - name: id.to_string(), - }, - current_assert.range(), - ); - } + if let Some(pending_assert) = self.pending_assert + && id.as_str() == self.exception_name + { + self.checker.report_diagnostic( + PytestAssertInExcept { + name: id.to_string(), + }, + pending_assert.range(), + ); + self.pending_assert = None; } } _ => visitor::walk_expr(self, expr), @@ -365,7 +371,7 @@ pub(crate) fn unittest_assertion( /// ## References /// - [`pytest` documentation: Assertions about expected exceptions](https://docs.pytest.org/en/latest/how-to/assert.html#assertions-about-expected-exceptions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.285")] +#[violation_metadata(stable_since = "v0.0.285", category = Category::Pedantic)] pub(crate) struct PytestUnittestRaisesAssertion { assertion: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fail.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fail.rs index e30fcee3bc..f028af0a73 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fail.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fail.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pytest_style::helpers::{is_empty_or_null_string, is_pytest_fail}; @@ -46,7 +47,7 @@ use crate::rules::flake8_pytest_style::helpers::{is_empty_or_null_string, is_pyt /// ## References /// - [`pytest` documentation: `pytest.fail`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fail) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestFailWithoutMessage; impl Violation for PytestFailWithoutMessage { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs index e56b830a3c..e03533c9d2 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs @@ -13,9 +13,12 @@ use ruff_text_size::{TextLen, TextRange}; use rustc_hash::FxHashSet; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; +use crate::importer::ImportRequest; +use crate::preview::is_pt020_fix_enabled; use crate::registry::Rule; -use crate::{AlwaysFixableViolation, Violation}; +use crate::{AlwaysFixableViolation, FixAvailability, Violation}; use crate::{Edit, Fix}; use crate::rules::flake8_pytest_style::helpers::{ @@ -79,7 +82,7 @@ use crate::rules::flake8_pytest_style::helpers::{ /// ## References /// - [`pytest` documentation: API Reference: Fixtures](https://docs.pytest.org/en/latest/reference/reference.html#fixtures-api) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestFixtureIncorrectParenthesesStyle { expected: Parentheses, actual: Parentheses, @@ -131,7 +134,7 @@ impl AlwaysFixableViolation for PytestFixtureIncorrectParenthesesStyle { /// ## References /// - [`pytest` documentation: `@pytest.fixture` functions](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fixture) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestFixturePositionalArgs { function: String, } @@ -173,7 +176,7 @@ impl Violation for PytestFixturePositionalArgs { /// ## References /// - [`pytest` documentation: `@pytest.fixture` functions](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fixture) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestExtraneousScopeFunction; impl AlwaysFixableViolation for PytestExtraneousScopeFunction { @@ -237,7 +240,7 @@ impl AlwaysFixableViolation for PytestExtraneousScopeFunction { /// - [`pytest` documentation: `@pytest.fixture` functions](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fixture) #[derive(ViolationMetadata)] #[deprecated(note = "PT004 has been removed")] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct PytestMissingFixtureNameUnderscore; #[expect(deprecated)] @@ -303,7 +306,7 @@ impl Violation for PytestMissingFixtureNameUnderscore { /// - [`pytest` documentation: `@pytest.fixture` functions](https://docs.pytest.org/en/latest/reference/reference.html#pytest-fixture) #[derive(ViolationMetadata)] #[deprecated(note = "PT005 has been removed")] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct PytestIncorrectFixtureNameUnderscore; #[expect(deprecated)] @@ -363,7 +366,7 @@ impl Violation for PytestIncorrectFixtureNameUnderscore { /// ## References /// - [`pytest` documentation: `pytest.mark.usefixtures`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-mark-usefixtures) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestFixtureParamWithoutValue { name: String, } @@ -383,7 +386,13 @@ impl Violation for PytestFixtureParamWithoutValue { /// Checks for `pytest.yield_fixture` usage. /// /// ## Why is this bad? -/// `pytest.yield_fixture` is deprecated. `pytest.fixture` should be used instead. +/// `pytest.fixture` has supported `yield` since pytest 3.0, which left +/// `pytest.yield_fixture` as a plain alias for it. The alias has been +/// deprecated since pytest 6.2, now raises a `PytestRemovedIn10Warning`, and +/// will be removed in pytest 10. +/// +/// The two are the same function, so switching to `pytest.fixture` is a +/// rename with no other consequence. /// /// ## Example /// ```python @@ -409,17 +418,29 @@ impl Violation for PytestFixtureParamWithoutValue { /// obj.cleanup() /// ``` /// +/// ## Fix safety +/// This rule's fix is marked as safe, unless the decorator contains comments +/// that the fix would remove. `pytest.yield_fixture` and `pytest.fixture` are +/// the same function; the only behavioral difference is the deprecation +/// warning that `pytest.yield_fixture` emits. +/// /// ## References -/// - [`pytest` documentation: `yield_fixture` functions](https://docs.pytest.org/en/latest/yieldfixture.html) +/// - [`pytest` documentation: the `yield_fixture` function/decorator](https://docs.pytest.org/en/stable/deprecations.html#the-yield-fixture-function-decorator) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Suspicious)] pub(crate) struct PytestDeprecatedYieldFixture; impl Violation for PytestDeprecatedYieldFixture { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { "`@pytest.yield_fixture` is deprecated, use `@pytest.fixture`".to_string() } + + fn fix_title(&self) -> Option { + Some("Replace with `pytest.fixture`".to_string()) + } } /// ## What it does @@ -472,7 +493,7 @@ impl Violation for PytestDeprecatedYieldFixture { /// - [`pytest` documentation: Adding finalizers directly](https://docs.pytest.org/en/latest/how-to/fixtures.html#adding-finalizers-directly) /// - [`pytest` documentation: Factories as fixtures](https://docs.pytest.org/en/latest/how-to/fixtures.html#factories-as-fixtures) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestFixtureFinalizerCallback; impl Violation for PytestFixtureFinalizerCallback { @@ -526,7 +547,7 @@ impl Violation for PytestFixtureFinalizerCallback { /// ## References /// - [`pytest` documentation: Teardown/Cleanup](https://docs.pytest.org/en/latest/how-to/fixtures.html#teardown-cleanup-aka-fixture-finalization) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestUselessYieldFixture { name: String, } @@ -584,7 +605,7 @@ impl AlwaysFixableViolation for PytestUselessYieldFixture { /// ## References /// - [`pytest` documentation: `pytest.mark.usefixtures`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-mark-usefixtures) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct PytestErroneousUseFixturesOnFixture; impl AlwaysFixableViolation for PytestErroneousUseFixturesOnFixture { @@ -628,7 +649,7 @@ impl AlwaysFixableViolation for PytestErroneousUseFixturesOnFixture { /// ## References /// - [PyPI: `pytest-asyncio`](https://pypi.org/project/pytest-asyncio/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct PytestUnnecessaryAsyncioMarkOnFixture; impl AlwaysFixableViolation for PytestUnnecessaryAsyncioMarkOnFixture { @@ -916,6 +937,33 @@ fn check_fixture_decorator_name(checker: &Checker, decorator: &Decorator) { let mut diagnostic = checker.report_diagnostic(PytestDeprecatedYieldFixture, decorator.range()); diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); + + if !is_pt020_fix_enabled(checker.settings()) { + return; + } + + let reference = map_callable(&decorator.expression); + + // Mark the fix as unsafe when comments are in range, as replacing the + // reference would remove them. + let applicability = if checker.comment_ranges().intersects(reference.range()) { + Applicability::Unsafe + } else { + Applicability::Safe + }; + + diagnostic.try_set_fix(|| { + let (import_edit, binding) = checker.importer().get_or_import_symbol( + &ImportRequest::import("pytest", "fixture"), + reference.start(), + checker.semantic(), + )?; + Ok(Fix::applicable_edits( + import_edit, + [Edit::range_replacement(binding, reference.range())], + applicability, + )) + }); } } diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/imports.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/imports.rs index e1cff65749..def61be5f5 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/imports.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/imports.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Stmt; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -23,7 +24,7 @@ use crate::{Violation, checkers::ast::Checker}; /// import pytest /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestIncorrectPytestImport; impl Violation for PytestIncorrectPytestImport { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/marks.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/marks.rs index 234c7d2a9c..38541eccd6 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/marks.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/marks.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -64,7 +65,7 @@ use crate::rules::flake8_pytest_style::helpers::{Parentheses, get_mark_decorator /// ## References /// - [`pytest` documentation: Marks](https://docs.pytest.org/en/latest/reference/reference.html#marks) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestIncorrectMarkParenthesesStyle { mark_name: String, expected_parens: Parentheses, @@ -120,7 +121,7 @@ impl AlwaysFixableViolation for PytestIncorrectMarkParenthesesStyle { /// ## References /// - [`pytest` documentation: `pytest.mark.usefixtures`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-mark-usefixtures) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct PytestUseFixturesWithoutParameters; impl AlwaysFixableViolation for PytestUseFixturesWithoutParameters { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/parametrize.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/parametrize.rs index 43ab1ac855..94b4c12efb 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/parametrize.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/parametrize.rs @@ -9,6 +9,7 @@ use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -65,7 +66,7 @@ use crate::rules::flake8_pytest_style::types; /// ## References /// - [`pytest` documentation: How to parametrize fixtures and test functions](https://docs.pytest.org/en/latest/how-to/parametrize.html#pytest-mark-parametrize) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestParametrizeNamesWrongType { single_argument: bool, expected: types::ParametrizeNameType, @@ -200,7 +201,7 @@ impl Violation for PytestParametrizeNamesWrongType { /// ## References /// - [`pytest` documentation: How to parametrize fixtures and test functions](https://docs.pytest.org/en/latest/how-to/parametrize.html#pytest-mark-parametrize) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestParametrizeValuesWrongType { values: types::ParametrizeValuesType, row: types::ParametrizeValuesRowType, @@ -282,7 +283,7 @@ impl Violation for PytestParametrizeValuesWrongType { /// ## References /// - [`pytest` documentation: How to parametrize fixtures and test functions](https://docs.pytest.org/en/latest/how-to/parametrize.html#pytest-mark-parametrize) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.285")] +#[violation_metadata(stable_since = "v0.0.285", category = Category::Correctness)] pub(crate) struct PytestDuplicateParametrizeTestCases { index: usize, } diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/patch.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/patch.rs index 746aa1a61b..3b0ee30e60 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/patch.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/patch.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for mocked calls that use a dummy `lambda` function instead of @@ -42,7 +43,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `unittest.mock.patch`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) /// - [PyPI: `pytest-mock`](https://pypi.org/project/pytest-mock/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Complexity)] pub(crate) struct PytestPatchWithLambda; impl Violation for PytestPatchWithLambda { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs index 6d3db53bab..ce3864a265 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_pytest_style::helpers::is_empty_or_null_string; @@ -51,7 +52,7 @@ use crate::rules::flake8_pytest_style::helpers::is_empty_or_null_string; /// ## References /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Suspicious)] pub(crate) struct PytestRaisesWithMultipleStatements; impl Violation for PytestRaisesWithMultipleStatements { @@ -114,7 +115,7 @@ impl Violation for PytestRaisesWithMultipleStatements { /// ## References /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Pedantic)] pub(crate) struct PytestRaisesTooBroad { exception: String, } @@ -163,7 +164,7 @@ impl Violation for PytestRaisesTooBroad { /// ## References /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct PytestRaisesWithoutException; impl Violation for PytestRaisesWithoutException { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/test_functions.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/test_functions.rs index f092b67ff7..fefd09f36f 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/test_functions.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/test_functions.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pytest_style::helpers::is_likely_pytest_test; use crate::{Edit, Fix, Violation}; use ruff_macros::{ViolationMetadata, derive_message_formats}; @@ -31,7 +32,7 @@ use ruff_text_size::Ranged; /// ## References /// - [Original Pytest issue](https://github.com/pytest-dev/pytest/issues/12693) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Suspicious)] pub(crate) struct PytestParameterWithDefaultArgument { parameter_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs index 455249e949..43009daa09 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_pytest_style::helpers::is_empty_or_null_string; @@ -50,7 +51,7 @@ use crate::rules::flake8_pytest_style::helpers::is_empty_or_null_string; /// ## References /// - [`pytest` documentation: `pytest.warns`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-warns) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Suspicious)] pub(crate) struct PytestWarnsWithMultipleStatements; impl Violation for PytestWarnsWithMultipleStatements { @@ -102,7 +103,7 @@ impl Violation for PytestWarnsWithMultipleStatements { /// ## References /// - [`pytest` documentation: `pytest.warns`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-warns) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Pedantic)] pub(crate) struct PytestWarnsTooBroad { warning: String, } @@ -148,7 +149,7 @@ impl Violation for PytestWarnsTooBroad { /// ## References /// - [`pytest` documentation: `pytest.warns`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-warns) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.2")] +#[violation_metadata(preview_since = "0.9.2", category = Category::Pedantic)] pub(crate) struct PytestWarnsWithoutWarning; impl Violation for PytestWarnsWithoutWarning { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT017.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT017.snap index 19129ac463..0e49f5340a 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT017.snap +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT017.snap @@ -8,3 +8,11 @@ PT017 Found assertion on exception `e` in `except` block, use `pytest.raises()` 18 | except Exception as e: 19 | assert e.message, "blah blah" | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PT017 Found assertion on exception `e` in `except` block, use `pytest.raises()` instead + --> PT017.py:26:9 + | +24 | something() +25 | except Exception as e: +26 | assert len(e.args) == 1, e.args + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020.snap index f792f81672..a34aa34a8e 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020.snap +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020.snap @@ -9,6 +9,7 @@ PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` 15 | def error_without_parens(): 16 | return 0 | +help: Replace with `pytest.fixture` PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` --> PT020.py:19:1 @@ -18,3 +19,49 @@ PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` 20 | def error_with_parens(): 21 | return 0 | +help: Replace with `pytest.fixture` + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:24:1 + | +24 | @pytest.yield_fixture(scope="module", name="my_fixture") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +25 | def error_with_arguments(): +26 | return 0 + | +help: Replace with `pytest.fixture` + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:29:1 + | +29 | @pytest.yield_fixture() # comment + | ^^^^^^^^^^^^^^^^^^^^^^^ +30 | def error_with_comment(): +31 | return 0 + | +help: Replace with `pytest.fixture` + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:35:5 + | +34 | class TestClass: +35 | @pytest.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^ +36 | def error_in_class(self): +37 | return 0 + | +help: Replace with `pytest.fixture` + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:40:1 + | +40 | / @( +41 | | pytest +42 | | # comment +43 | | .yield_fixture +44 | | ) + | |_^ +45 | def error_with_comment_in_reference(): +46 | return 0 + | +help: Replace with `pytest.fixture` diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_1.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_1.snap new file mode 100644 index 0000000000..dd3a353355 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_1.snap @@ -0,0 +1,22 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:5:1 + | +5 | @yield_fixture() + | ^^^^^^^^^^^^^^^^ +6 | def error_member_import(): +7 | return 0 + | +help: Replace with `pytest.fixture` + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:10:1 + | +10 | @aliased() + | ^^^^^^^^^^ +11 | def error_aliased_member_import(): +12 | return 0 + | +help: Replace with `pytest.fixture` diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_2.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_2.snap new file mode 100644 index 0000000000..0d2ae14e9e --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT020_2.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_2.py:4:1 + | +4 | @other_name.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +5 | def error_aliased_module(): +6 | return 0 + | +help: Replace with `pytest.fixture` diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__PT018_PT018.py.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-composite-assertion_PT018.py.snap similarity index 99% rename from crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__PT018_PT018.py.snap rename to crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-composite-assertion_PT018.py.snap index 1a299ba99a..cc63dc60ae 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__PT018_PT018.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-composite-assertion_PT018.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs -assertion_line: 389 --- --- Linter settings --- -linter.preview = disabled diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020.py.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020.py.snap new file mode 100644 index 0000000000..54a5e25898 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020.py.snap @@ -0,0 +1,193 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 6 +Added: 6 + +--- Removed --- +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:14:1 + | +14 | @pytest.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^ +15 | def error_without_parens(): +16 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:19:1 + | +19 | @pytest.yield_fixture + | ^^^^^^^^^^^^^^^^^^^^^ +20 | def error_with_parens(): +21 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:24:1 + | +24 | @pytest.yield_fixture(scope="module", name="my_fixture") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +25 | def error_with_arguments(): +26 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:29:1 + | +29 | @pytest.yield_fixture() # comment + | ^^^^^^^^^^^^^^^^^^^^^^^ +30 | def error_with_comment(): +31 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:35:5 + | +34 | class TestClass: +35 | @pytest.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^ +36 | def error_in_class(self): +37 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:40:1 + | +40 | / @( +41 | | pytest +42 | | # comment +43 | | .yield_fixture +44 | | ) + | |_^ +45 | def error_with_comment_in_reference(): +46 | return 0 + | +help: Replace with `pytest.fixture` + + + +--- Added --- +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:14:1 + | +14 | @pytest.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^ +15 | def error_without_parens(): +16 | return 0 + | +help: Replace with `pytest.fixture` + | +13 | + - @pytest.yield_fixture() +14 + @pytest.fixture() +15 | def error_without_parens(): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:19:1 + | +19 | @pytest.yield_fixture + | ^^^^^^^^^^^^^^^^^^^^^ +20 | def error_with_parens(): +21 | return 0 + | +help: Replace with `pytest.fixture` + | +18 | + - @pytest.yield_fixture +19 + @pytest.fixture +20 | def error_with_parens(): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:24:1 + | +24 | @pytest.yield_fixture(scope="module", name="my_fixture") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +25 | def error_with_arguments(): +26 | return 0 + | +help: Replace with `pytest.fixture` + | +23 | + - @pytest.yield_fixture(scope="module", name="my_fixture") +24 + @pytest.fixture(scope="module", name="my_fixture") +25 | def error_with_arguments(): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:29:1 + | +29 | @pytest.yield_fixture() # comment + | ^^^^^^^^^^^^^^^^^^^^^^^ +30 | def error_with_comment(): +31 | return 0 + | +help: Replace with `pytest.fixture` + | +28 | + - @pytest.yield_fixture() # comment +29 + @pytest.fixture() # comment +30 | def error_with_comment(): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:35:5 + | +34 | class TestClass: +35 | @pytest.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^ +36 | def error_in_class(self): +37 | return 0 + | +help: Replace with `pytest.fixture` + | +34 | class TestClass: + - @pytest.yield_fixture() +35 + @pytest.fixture() +36 | def error_in_class(self): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020.py:40:1 + | +40 | / @( +41 | | pytest +42 | | # comment +43 | | .yield_fixture +44 | | ) + | |_^ +45 | def error_with_comment_in_reference(): +46 | return 0 + | +help: Replace with `pytest.fixture` + | +40 | @( + - pytest + - # comment + - .yield_fixture +41 + pytest.fixture +42 | ) + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_1.py.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_1.py.snap new file mode 100644 index 0000000000..de29f1c5c5 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_1.py.snap @@ -0,0 +1,77 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 2 +Added: 2 + +--- Removed --- +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:5:1 + | +5 | @yield_fixture() + | ^^^^^^^^^^^^^^^^ +6 | def error_member_import(): +7 | return 0 + | +help: Replace with `pytest.fixture` + + +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:10:1 + | +10 | @aliased() + | ^^^^^^^^^^ +11 | def error_aliased_member_import(): +12 | return 0 + | +help: Replace with `pytest.fixture` + + + +--- Added --- +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:5:1 + | +5 | @yield_fixture() + | ^^^^^^^^^^^^^^^^ +6 | def error_member_import(): +7 | return 0 + | +help: Replace with `pytest.fixture` + | +1 | from pytest import yield_fixture + - from pytest import yield_fixture as aliased +2 + from pytest import yield_fixture as aliased, fixture +3 | +4 | + - @yield_fixture() +5 + @fixture() +6 | def error_member_import(): + | + + +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_1.py:10:1 + | +10 | @aliased() + | ^^^^^^^^^^ +11 | def error_aliased_member_import(): +12 | return 0 + | +help: Replace with `pytest.fixture` + | +1 | from pytest import yield_fixture + - from pytest import yield_fixture as aliased +2 + from pytest import yield_fixture as aliased, fixture +3 | +-------------------------------------------------------------------------------- +9 | + - @aliased() +10 + @fixture() +11 | def error_aliased_member_import(): + | diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_2.py.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_2.py.snap new file mode 100644 index 0000000000..1016a30c5e --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-deprecated-yield-fixture_PT020_2.py.snap @@ -0,0 +1,40 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 1 +Added: 1 + +--- Removed --- +PT020 `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_2.py:4:1 + | +4 | @other_name.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +5 | def error_aliased_module(): +6 | return 0 + | +help: Replace with `pytest.fixture` + + + +--- Added --- +PT020 [*] `@pytest.yield_fixture` is deprecated, use `@pytest.fixture` + --> PT020_2.py:4:1 + | +4 | @other_name.yield_fixture() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +5 | def error_aliased_module(): +6 | return 0 + | +help: Replace with `pytest.fixture` + | +3 | + - @other_name.yield_fixture() +4 + @other_name.fixture() +5 | def error_aliased_module(): + | diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__PT003_PT003.py.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-extraneous-scope-function_PT003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__PT003_PT003.py.snap rename to crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__preview__pytest-extraneous-scope-function_PT003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/avoidable_escaped_quote.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/avoidable_escaped_quote.rs index a10c38b5c9..61d35426c4 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/avoidable_escaped_quote.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/avoidable_escaped_quote.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, AnyStringFlags, PythonVersion, StringFlags, S use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_quotes; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.88")] +#[violation_metadata(stable_since = "v0.0.88", category = Category::Formatting)] pub(crate) struct AvoidableEscapedQuote; impl AlwaysFixableViolation for AvoidableEscapedQuote { diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs index c646029590..aed686435c 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; @@ -37,7 +38,7 @@ use crate::rules::flake8_quotes::settings::Quote; /// /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.88")] +#[violation_metadata(stable_since = "v0.0.88", category = Category::Formatting)] pub(crate) struct BadQuotesInlineString { preferred_quote: Quote, } @@ -95,7 +96,7 @@ impl Violation for BadQuotesInlineString { /// /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.88")] +#[violation_metadata(stable_since = "v0.0.88", category = Category::Formatting)] pub(crate) struct BadQuotesMultilineString { preferred_quote: Quote, } @@ -151,7 +152,7 @@ impl AlwaysFixableViolation for BadQuotesMultilineString { /// /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.88")] +#[violation_metadata(stable_since = "v0.0.88", category = Category::Formatting)] pub(crate) struct BadQuotesDocstring { preferred_quote: Quote, } diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs index 6b76f4b617..8bd227b82c 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/unnecessary_escaped_quote.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::rules::flake8_quotes::helpers::{contains_escaped_quote, raw_contents, unescape_string}; @@ -33,7 +34,7 @@ use crate::rules::flake8_quotes::helpers::{contains_escaped_quote, raw_contents, /// /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Formatting)] pub(crate) struct UnnecessaryEscapedQuote; impl AlwaysFixableViolation for UnnecessaryEscapedQuote { diff --git a/crates/ruff_linter/src/rules/flake8_raise/rules/unnecessary_paren_on_raise_exception.rs b/crates/ruff_linter/src/rules/flake8_raise/rules/unnecessary_paren_on_raise_exception.rs index e7ea72c686..e8e019775f 100644 --- a/crates/ruff_linter/src/rules/flake8_raise/rules/unnecessary_paren_on_raise_exception.rs +++ b/crates/ruff_linter/src/rules/flake8_raise/rules/unnecessary_paren_on_raise_exception.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::BindingKind; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -43,7 +44,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation: The `raise` statement](https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.239")] +#[violation_metadata(stable_since = "v0.0.239", category = Category::Pedantic)] pub(crate) struct UnnecessaryParenOnRaiseException; impl AlwaysFixableViolation for UnnecessaryParenOnRaiseException { diff --git a/crates/ruff_linter/src/rules/flake8_return/mod.rs b/crates/ruff_linter/src/rules/flake8_return/mod.rs index f8370dd8cc..97a5b04989 100644 --- a/crates/ruff_linter/src/rules/flake8_return/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_return/mod.rs @@ -27,7 +27,7 @@ mod tests { #[test_case(Rule::SuperfluousElseContinue, Path::new("RET507.py"))] #[test_case(Rule::SuperfluousElseBreak, Path::new("RET508.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_return").join(path).as_path(), &LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_return/rules/function.rs b/crates/ruff_linter/src/rules/flake8_return/rules/function.rs index af6b9259a6..d4a2e948d0 100644 --- a/crates/ruff_linter/src/rules/flake8_return/rules/function.rs +++ b/crates/ruff_linter/src/rules/flake8_return/rules/function.rs @@ -15,6 +15,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::fix::edits::adjust_indentation; use crate::registry::Rule; @@ -62,7 +63,7 @@ use crate::rules::flake8_return::visitor::{ReturnVisitor, Stack}; /// /// - `lint.pydocstyle.property-decorators` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Style)] pub(crate) struct UnnecessaryReturnNone; impl AlwaysFixableViolation for UnnecessaryReturnNone { @@ -104,7 +105,7 @@ impl AlwaysFixableViolation for UnnecessaryReturnNone { /// return 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct ImplicitReturnValue; impl AlwaysFixableViolation for ImplicitReturnValue { @@ -143,7 +144,7 @@ impl AlwaysFixableViolation for ImplicitReturnValue { /// return None /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct ImplicitReturn; impl AlwaysFixableViolation for ImplicitReturn { @@ -179,7 +180,7 @@ impl AlwaysFixableViolation for ImplicitReturn { /// return 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct UnnecessaryAssign { name: String, } @@ -222,7 +223,7 @@ impl AlwaysFixableViolation for UnnecessaryAssign { /// return baz /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct SuperfluousElseReturn { branch: Branch, } @@ -267,7 +268,7 @@ impl Violation for SuperfluousElseReturn { /// raise Exception(baz) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct SuperfluousElseRaise { branch: Branch, } @@ -314,7 +315,7 @@ impl Violation for SuperfluousElseRaise { /// x = 0 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct SuperfluousElseContinue { branch: Branch, } @@ -361,7 +362,7 @@ impl Violation for SuperfluousElseContinue { /// x = 0 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.154")] +#[violation_metadata(stable_since = "v0.0.154", category = Category::Pedantic)] pub(crate) struct SuperfluousElseBreak { branch: Branch, } diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET502_RET502.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__implicit-return-value_RET502.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET502_RET502.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__implicit-return-value_RET502.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET503_RET503.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__implicit-return_RET503.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET503_RET503.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__implicit-return_RET503.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET508_RET508.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-break_RET508.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET508_RET508.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-break_RET508.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET507_RET507.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-continue_RET507.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET507_RET507.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-continue_RET507.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET506_RET506.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-raise_RET506.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET506_RET506.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-raise_RET506.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET505_RET505.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-return_RET505.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET505_RET505.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__superfluous-else-return_RET505.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET504_RET504.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__unnecessary-assign_RET504.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET504_RET504.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__unnecessary-assign_RET504.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET501_RET501.py.snap b/crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__unnecessary-return-none_RET501.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__RET501_RET501.py.snap rename to crates/ruff_linter/src/rules/flake8_return/snapshots/ruff_linter__rules__flake8_return__tests__unnecessary-return-none_RET501.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_self/rules/private_member_access.rs b/crates/ruff_linter/src/rules/flake8_self/rules/private_member_access.rs index 162b9b8760..cd3e76614c 100644 --- a/crates/ruff_linter/src/rules/flake8_self/rules/private_member_access.rs +++ b/crates/ruff_linter/src/rules/flake8_self/rules/private_member_access.rs @@ -10,7 +10,10 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; -use crate::rules::pylint::helpers::is_dunder_operator_method; +use crate::codes::Category; +use crate::rules::pylint::helpers::{ + is_dunder_operator_method, is_underscore_prefixed_public_member, +}; /// ## What it does /// Checks for accesses on "private" class members. @@ -56,7 +59,7 @@ use crate::rules::pylint::helpers::is_dunder_operator_method; /// ## References /// - [_What is the meaning of single or double underscores before an object name?_](https://stackoverflow.com/questions/1301346/what-is-the-meaning-of-single-and-double-underscore-before-an-object-name) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.240")] +#[violation_metadata(stable_since = "v0.0.240", category = Category::Pedantic)] pub(crate) struct PrivateMemberAccess { access: String, } @@ -104,7 +107,7 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) { // Allow some public functions whose names start with an underscore, like `os._exit()`. if let Some(qualified_name) = semantic.resolve_qualified_name(expr) { - if matches!(qualified_name.segments(), ["os", "_exit"]) { + if is_underscore_prefixed_public_member(&qualified_name) { return; } } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/mod.rs b/crates/ruff_linter/src/rules/flake8_simplify/mod.rs index 093cd0ffd0..209479f8be 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/mod.rs @@ -55,7 +55,7 @@ mod tests { #[test_case(Rule::DictGetWithNoneDefault, Path::new("SIM910.py"))] #[test_case(Rule::ZipDictKeysAndValues, Path::new("SIM911.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_simplify").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -66,11 +66,7 @@ mod tests { #[test_case(Rule::EnumerateForLoop, Path::new("SIM113.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_simplify").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -81,7 +77,7 @@ mod tests { #[test_case(Rule::SuppressibleException, Path::new("SIM105_5.py"))] fn version_specific_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("diff_{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("diff_{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, Path::new("flake8_simplify").join(path).as_path(), diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs index f773648fc6..9e5259bbb0 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs @@ -14,6 +14,7 @@ use ruff_python_codegen::Generator; use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `isinstance`](https://docs.python.org/3/library/functions.html#isinstance) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.212")] +#[violation_metadata(stable_since = "v0.0.212", category = Category::Complexity)] pub(crate) struct DuplicateIsinstanceCall { name: Option, } @@ -93,7 +94,7 @@ impl Violation for DuplicateIsinstanceCall { /// ## References /// - [Python documentation: Membership test operations](https://docs.python.org/3/reference/expressions.html#membership-test-operations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Pedantic)] pub(crate) struct CompareWithTuple { replacement: String, } @@ -127,7 +128,7 @@ impl AlwaysFixableViolation for CompareWithTuple { /// ## References /// - [Python documentation: Boolean operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Correctness)] pub(crate) struct ExprAndNotExpr { name: String, } @@ -160,7 +161,7 @@ impl AlwaysFixableViolation for ExprAndNotExpr { /// ## References /// - [Python documentation: Boolean operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Correctness)] pub(crate) struct ExprOrNotExpr { name: String, } @@ -213,7 +214,7 @@ pub(crate) enum ContentAround { /// a = x or [1] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct ExprOrTrue { expr: String, remove: ContentAround, @@ -266,7 +267,7 @@ impl AlwaysFixableViolation for ExprOrTrue { /// a = x and [] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.208")] +#[violation_metadata(stable_since = "v0.0.208", category = Category::Correctness)] pub(crate) struct ExprAndFalse { expr: String, remove: ContentAround, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_expr.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_expr.rs index c01dfe22fb..29359a6c83 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_expr.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_expr.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::Modules; use ruff_python_semantic::analyze::typing::is_dict; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `os.environ`](https://docs.python.org/3/library/os.html#os.environ) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.218")] +#[violation_metadata(stable_since = "v0.0.218", category = Category::Pedantic)] pub(crate) struct UncapitalizedEnvironmentVariables { expected: SourceCodeSnippet, actual: SourceCodeSnippet, @@ -100,7 +101,7 @@ impl Violation for UncapitalizedEnvironmentVariables { /// ## References /// - [Python documentation: `dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.261")] +#[violation_metadata(stable_since = "v0.0.261", category = Category::Pedantic)] pub(crate) struct DictGetWithNoneDefault { expected: SourceCodeSnippet, actual: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_ifexp.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_ifexp.rs index d73e8dd60d..e40f44c785 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_ifexp.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_ifexp.rs @@ -7,6 +7,7 @@ use ruff_python_ast::name::Name; use ruff_python_ast::token::parenthesized_range; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Truth Value Testing](https://docs.python.org/3/library/stdtypes.html#truth-value-testing) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.214")] +#[violation_metadata(stable_since = "v0.0.214", category = Category::Complexity)] pub(crate) struct IfExprWithTrueFalse { is_compare: bool, } @@ -86,7 +87,7 @@ impl Violation for IfExprWithTrueFalse { /// ## References /// - [Python documentation: Truth Value Testing](https://docs.python.org/3/library/stdtypes.html#truth-value-testing) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.214")] +#[violation_metadata(stable_since = "v0.0.214", category = Category::Complexity)] pub(crate) struct IfExprWithFalseTrue; impl AlwaysFixableViolation for IfExprWithFalseTrue { @@ -120,7 +121,7 @@ impl AlwaysFixableViolation for IfExprWithFalseTrue { /// ## References /// - [Python documentation: Truth Value Testing](https://docs.python.org/3/library/stdtypes.html#truth-value-testing) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.214")] +#[violation_metadata(stable_since = "v0.0.214", category = Category::Pedantic)] pub(crate) struct IfExprWithTwistedArms { expr_body: String, expr_else: String, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs index a81028be87..2bb0aa9dee 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs @@ -6,6 +6,7 @@ use ruff_python_ast::name::Name; use ruff_python_semantic::ScopeKind; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -33,7 +34,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Complexity)] pub(crate) struct NegateEqualOp { left: String, right: String, @@ -76,7 +77,7 @@ impl AlwaysFixableViolation for NegateEqualOp { /// ## References /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Complexity)] pub(crate) struct NegateNotEqualOp { left: String, right: String, @@ -114,7 +115,7 @@ impl AlwaysFixableViolation for NegateNotEqualOp { /// ## References /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Complexity)] pub(crate) struct DoubleNegation { expr: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_with.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_with.rs index 0acb66b45e..abb4d7d6e3 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_with.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_with.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextRange}; use super::fix_with; use crate::Fix; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::fits; use crate::{FixAvailability, Violation}; @@ -56,7 +57,7 @@ use crate::{FixAvailability, Violation}; /// ## References /// - [Python documentation: The `with` statement](https://docs.python.org/3/reference/compound_stmts.html#the-with-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Complexity)] pub(crate) struct MultipleWithStatements; impl Violation for MultipleWithStatements { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/collapsible_if.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/collapsible_if.rs index c8d649dcd5..87135f4575 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/collapsible_if.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/collapsible_if.rs @@ -15,6 +15,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::helpers::space; use crate::cst::matchers::{match_function_def, match_if, match_indented_block, match_statement}; use crate::fix::codemods::CodegenStylist; @@ -63,7 +64,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: The `if` statement](https://docs.python.org/3/reference/compound_stmts.html#the-if-statement) /// - [Python documentation: Boolean operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Complexity)] pub(crate) struct CollapsibleIf; impl Violation for CollapsibleIf { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/enumerate_for_loop.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/enumerate_for_loop.rs index 400b5d87bb..08bf418d42 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/enumerate_for_loop.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/enumerate_for_loop.rs @@ -1,3 +1,4 @@ +use crate::codes::Category; use crate::preview::is_enumerate_for_loop_int_index_enabled; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt}; @@ -43,7 +44,7 @@ use crate::checkers::ast::Checker; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Complexity)] pub(crate) struct EnumerateForLoop { index: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs index fa95bba1f9..41f3703a57 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::analyze::typing::{ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::fits; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -61,7 +62,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Mapping Types](https://docs.python.org/3/library/stdtypes.html#mapping-types-dict) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.219")] +#[violation_metadata(stable_since = "v0.0.219", category = Category::Complexity)] pub(crate) struct IfElseBlockInsteadOfDictGet { contents: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs index ac051ae002..6d5d387002 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs @@ -9,6 +9,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for three or more consecutive if-statements with direct returns @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// return phrases.get(x, "Goodnight") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.250")] +#[violation_metadata(stable_since = "v0.0.250", category = Category::Pedantic)] pub(crate) struct IfElseBlockInsteadOfDictLookup; impl Violation for IfElseBlockInsteadOfDictLookup { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_if_exp.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_if_exp.rs index b5f7440116..f303829a56 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_if_exp.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_if_exp.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::typing::{is_sys_version_block, is_type_checki use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::fits; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -66,7 +67,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// [code coverage]: https://github.com/nedbat/coveragepy/issues/509 /// [pycodestyle.max-line-length]: https://docs.astral.sh/ruff/settings/#lint_pycodestyle_max-line-length #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.213")] +#[violation_metadata(stable_since = "v0.0.213", category = Category::Pedantic)] pub(crate) struct IfElseBlockInsteadOfIfExp { /// The ternary or binary expression to replace the `if`-`else`-block. contents: String, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs index a19c389a05..06a3b5f410 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs @@ -13,6 +13,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// print("Hello") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.246")] +#[violation_metadata(stable_since = "v0.0.246", category = Category::Suspicious)] pub(crate) struct IfWithSameArms; impl Violation for IfWithSameArms { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/key_in_dict.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/key_in_dict.rs index 19591a51cc..b9f37263d6 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/key_in_dict.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/key_in_dict.rs @@ -7,6 +7,7 @@ use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use crate::{Applicability, Edit}; @@ -38,7 +39,7 @@ use crate::{Applicability, Edit}; /// ## References /// - [Python documentation: Mapping Types](https://docs.python.org/3/library/stdtypes.html#mapping-types-dict) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.176")] +#[violation_metadata(stable_since = "v0.0.176", category = Category::Complexity)] pub(crate) struct InDictKeys { operator: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs index 90300f5e0f..08db5e741a 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::typing::{is_sys_version_block, is_type_checki use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -56,7 +57,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Truth Value Testing](https://docs.python.org/3/library/stdtypes.html#truth-value-testing) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.214")] +#[violation_metadata(stable_since = "v0.0.214", category = Category::Complexity)] pub(crate) struct NeedlessBool { condition: Option, negate: bool, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/open_file_with_context_handler.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/open_file_with_context_handler.rs index bc20cda849..c385f6644c 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/open_file_with_context_handler.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/open_file_with_context_handler.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for cases where files are opened (e.g., using the builtin `open()` function) @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `open`](https://docs.python.org/3/library/functions.html#open) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.219")] +#[violation_metadata(stable_since = "v0.0.219", category = Category::Suspicious)] pub(crate) struct OpenFileWithContextHandler; impl Violation for OpenFileWithContextHandler { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs index 327e929625..20bc7e42f8 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs @@ -10,6 +10,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::fits; use crate::line_width::LineWidthBuilder; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -52,7 +53,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `any`](https://docs.python.org/3/library/functions.html#any) /// - [Python documentation: `all`](https://docs.python.org/3/library/functions.html#all) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Complexity)] pub(crate) struct ReimplementedBuiltin { replacement: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/return_in_try_except_finally.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/return_in_try_except_finally.rs index e574f51adc..ef53ee49b9 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/return_in_try_except_finally.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/return_in_try_except_finally.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `return` statements in `try`-`except` and `finally` blocks. @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Defining Clean-up Actions](https://docs.python.org/3/tutorial/errors.html#defining-clean-up-actions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Correctness)] pub(crate) struct ReturnInTryExceptFinally; impl Violation for ReturnInTryExceptFinally { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs index c512927ee1..0546ccd38f 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/split_static_string.rs @@ -9,6 +9,7 @@ use std::cmp::Ordering; use std::fmt::{Display, Formatter}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -45,7 +46,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `str.split`](https://docs.python.org/3/library/stdtypes.html#str.split) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Complexity)] pub(crate) struct SplitStaticString { method: Method, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/suppressible_exception.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/suppressible_exception.rs index 8e1cb4bbbe..745f8c2262 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/suppressible_exception.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/suppressible_exception.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use ruff_text_size::{TextLen, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -43,7 +44,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `try` statement](https://docs.python.org/3/reference/compound_stmts.html#the-try-statement) /// - [a simpler `try`/`except` (and why maybe shouldn't)](https://www.youtube.com/watch?v=MZAJ8qnC7mk) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Pedantic)] pub(crate) struct SuppressibleException { exception: String, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs index 84aff5ce5e..2cf102c870 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs @@ -11,6 +11,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::helpers::or_space; use crate::cst::matchers::{match_comparison, transform_expression}; use crate::fix::edits::pad; @@ -48,7 +49,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) /// - [Python documentation: Assignment statements](https://docs.python.org/3/reference/simple_stmts.html#assignment-statements) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.207")] +#[violation_metadata(stable_since = "v0.0.207", category = Category::Pedantic)] pub(crate) struct YodaConditions { suggestion: Option, } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/zip_dict_keys_and_values.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/zip_dict_keys_and_values.rs index f84c7eb2a6..905454dc40 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/zip_dict_keys_and_values.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/zip_dict_keys_and_values.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, Arguments, Expr}; use ruff_python_semantic::analyze::typing::is_dict; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::fix::edits; use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::{checkers::ast::Checker, fix::snippet::SourceCodeSnippet}; @@ -37,7 +38,7 @@ use crate::{checkers::ast::Checker, fix::snippet::SourceCodeSnippet}; /// ## References /// - [Python documentation: `dict.items`](https://docs.python.org/3/library/stdtypes.html#dict.items) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Complexity)] pub(crate) struct ZipDictKeysAndValues { expected: SourceCodeSnippet, actual: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM102_SIM102.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__collapsible-if_SIM102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM102_SIM102.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__collapsible-if_SIM102.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM102_if_let_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__collapsible-if_if_let_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM102_if_let_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__collapsible-if_if_let_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM109_SIM109.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__compare-with-tuple_SIM109.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM109_SIM109.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__compare-with-tuple_SIM109.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM910_SIM910.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__dict-get-with-none-default_SIM910.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM910_SIM910.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__dict-get-with-none-default_SIM910.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__diff_SIM105_SIM105_5.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__diff_suppressible-exception_SIM105_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__diff_SIM105_SIM105_5.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__diff_suppressible-exception_SIM105_5.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM208_SIM208.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__double-negation_SIM208.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM208_SIM208.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__double-negation_SIM208.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM101_SIM101.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__duplicate-isinstance-call_SIM101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM101_SIM101.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__duplicate-isinstance-call_SIM101.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM113_SIM113.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__enumerate-for-loop_SIM113.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM113_SIM113.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__enumerate-for-loop_SIM113.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM223_SIM223.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-false_SIM223.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM223_SIM223.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-false_SIM223.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM220_SIM220.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-not-expr_SIM220.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM220_SIM220.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-not-expr_SIM220.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM221_SIM221.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-not-expr_SIM221.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM221_SIM221.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-not-expr_SIM221.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-true_SIM222.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-true_SIM222.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM401_SIM401.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-dict-get_SIM401.py.snap similarity index 84% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM401_SIM401.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-dict-get_SIM401.py.snap index eb00ced8af..02d33f2bd2 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM401_SIM401.py.snap +++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-dict-get_SIM401.py.snap @@ -157,3 +157,27 @@ help: Replace with `a_dict.get(key, "default-1")` 153 | | note: This is an unsafe fix and may change runtime behavior + +SIM401 [*] Use `var = a_dict.get(key, lambda x=0, /, y=1, *, z=2: (x, y, z))` instead of an `if` block + --> SIM401.py:162:1 + | +161 | # SIM401: literal defaults have no side effects. +162 | / if key in a_dict: +163 | | var = a_dict[key] +164 | | else: +165 | | var = lambda x=0, /, y=1, *, z=2: (x, y, z) + | |_______________________________________________^ +166 | +167 | # OK: dict.get would evaluate the lambda's default even when the key exists. + | +help: Replace with `var = a_dict.get(key, lambda x=0, /, y=1, *, z=2: (x, y, z))` + | +161 | # SIM401: literal defaults have no side effects. + - if key in a_dict: + - var = a_dict[key] + - else: + - var = lambda x=0, /, y=1, *, z=2: (x, y, z) +162 + var = a_dict.get(key, lambda x=0, /, y=1, *, z=2: (x, y, z)) +163 | + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM116_SIM116.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-dict-lookup_SIM116.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM116_SIM116.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-dict-lookup_SIM116.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM108_SIM108.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-if-exp_SIM108.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM108_SIM108.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-if-exp_SIM108.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM108_if_let_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-if-exp_if_let_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM108_if_let_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-else-block-instead-of-if-exp_if_let_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM211_SIM211.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-false-true_SIM211.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM211_SIM211.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-false-true_SIM211.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM210_SIM210.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-true-false_SIM210.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM210_SIM210.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-true-false_SIM210.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM212_SIM212.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-twisted-arms_SIM212.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM212_SIM212.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-expr-with-twisted-arms_SIM212.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM114_SIM114.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-with-same-arms_SIM114.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM114_SIM114.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-with-same-arms_SIM114.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM114_if_let_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-with-same-arms_if_let_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM114_if_let_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__if-with-same-arms_if_let_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM118_SIM118.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__in-dict-keys_SIM118.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM118_SIM118.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__in-dict-keys_SIM118.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM117_SIM117.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__multiple-with-statements_SIM117.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM117_SIM117.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__multiple-with-statements_SIM117.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM103_SIM103.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__needless-bool_SIM103.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM103_SIM103.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__needless-bool_SIM103.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM103_if_let_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__needless-bool_if_let_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM103_if_let_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__needless-bool_if_let_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM201_SIM201.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__negate-equal-op_SIM201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM201_SIM201.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__negate-equal-op_SIM201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM202_SIM202.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__negate-not-equal-op_SIM202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM202_SIM202.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__negate-not-equal-op_SIM202.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM115_SIM115.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__open-file-with-context-handler_SIM115.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM115_SIM115.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__open-file-with-context-handler_SIM115.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__preview__SIM113_SIM113.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__preview__enumerate-for-loop_SIM113.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__preview__SIM113_SIM113.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__preview__enumerate-for-loop_SIM113.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM110_SIM110.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__reimplemented-builtin_SIM110.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM110_SIM110.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__reimplemented-builtin_SIM110.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM110_SIM111.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__reimplemented-builtin_SIM111.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM110_SIM111.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__reimplemented-builtin_SIM111.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM107_SIM107.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__return-in-try-except-finally_SIM107.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM107_SIM107.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__return-in-try-except-finally_SIM107.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__split-static-string_SIM905.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM905_SIM905.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__split-static-string_SIM905.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_0.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_0.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_0.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_1.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_1.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_2.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_2.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_3.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_3.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_3.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_4.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM105_SIM105_4.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__suppressible-exception_SIM105_4.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM112_SIM112.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__uncapitalized-environment-variables_SIM112.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM112_SIM112.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__uncapitalized-environment-variables_SIM112.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM300_SIM300.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__yoda-conditions_SIM300.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM300_SIM300.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__yoda-conditions_SIM300.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM911_SIM911.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__zip-dict-keys-and-values_SIM911.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM911_SIM911.py.snap rename to crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__zip-dict-keys-and-values_SIM911.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_slots/mod.rs b/crates/ruff_linter/src/rules/flake8_slots/mod.rs index 99bd9daa8b..7f7c25e09b 100644 --- a/crates/ruff_linter/src/rules/flake8_slots/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_slots/mod.rs @@ -17,7 +17,7 @@ mod tests { #[test_case(Rule::NoSlotsInTupleSubclass, Path::new("SLOT001.py"))] #[test_case(Rule::NoSlotsInNamedtupleSubclass, Path::new("SLOT002.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_slots").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_namedtuple_subclass.rs b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_namedtuple_subclass.rs index 142233a4bf..64f4ac8303 100644 --- a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_namedtuple_subclass.rs +++ b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_namedtuple_subclass.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::SemanticModel; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_slots::helpers::has_slots; /// ## What it does @@ -47,7 +48,7 @@ use crate::rules::flake8_slots::helpers::has_slots; /// ## References /// - [Python documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Pedantic)] pub(crate) struct NoSlotsInNamedtupleSubclass(NamedTupleKind); impl Violation for NoSlotsInNamedtupleSubclass { diff --git a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_str_subclass.rs b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_str_subclass.rs index 6ab13988e7..8f35f2a181 100644 --- a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_str_subclass.rs +++ b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_str_subclass.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::{SemanticModel, analyze::class::is_enumeration}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_slots::helpers::has_slots; /// ## What it does @@ -39,7 +40,7 @@ use crate::rules::flake8_slots::helpers::has_slots; /// ## References /// - [Python documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Pedantic)] pub(crate) struct NoSlotsInStrSubclass; impl Violation for NoSlotsInStrSubclass { diff --git a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_tuple_subclass.rs b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_tuple_subclass.rs index addc7bd421..88d2c3165c 100644 --- a/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_tuple_subclass.rs +++ b/crates/ruff_linter/src/rules/flake8_slots/rules/no_slots_in_tuple_subclass.rs @@ -6,6 +6,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_slots::helpers::has_slots; /// ## What it does @@ -40,7 +41,7 @@ use crate::rules::flake8_slots::helpers::has_slots; /// ## References /// - [Python documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Pedantic)] pub(crate) struct NoSlotsInTupleSubclass; impl Violation for NoSlotsInTupleSubclass { diff --git a/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT002_SLOT002.py.snap b/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-namedtuple-subclass_SLOT002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT002_SLOT002.py.snap rename to crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-namedtuple-subclass_SLOT002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT000_SLOT000.py.snap b/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-str-subclass_SLOT000.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT000_SLOT000.py.snap rename to crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-str-subclass_SLOT000.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT001_SLOT001.py.snap b/crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-tuple-subclass_SLOT001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__SLOT001_SLOT001.py.snap rename to crates/ruff_linter/src/rules/flake8_slots/snapshots/ruff_linter__rules__flake8_slots__tests__no-slots-in-tuple-subclass_SLOT001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_api.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_api.rs index 6379304d5c..8a392ebd9a 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_api.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_api.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_tidy_imports::matchers::NameMatchPolicy; /// ## What it does @@ -25,7 +26,7 @@ use crate::rules::flake8_tidy_imports::matchers::NameMatchPolicy; /// ## Options /// - `lint.flake8-tidy-imports.banned-api` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.201")] +#[violation_metadata(stable_since = "v0.0.201", category = Category::Restriction)] pub(crate) struct BannedApi { name: String, message: String, diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs index 23d250f7a9..b3d043ab0d 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs @@ -6,6 +6,7 @@ use std::borrow::Cow; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_tidy_imports::matchers::{MatchName, MatchNameOrParent, NameMatchPolicy}; /// ## What it does @@ -43,7 +44,7 @@ use crate::rules::flake8_tidy_imports::matchers::{MatchName, MatchNameOrParent, /// ## Options /// - `lint.flake8-tidy-imports.banned-module-level-imports` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.285")] +#[violation_metadata(stable_since = "v0.0.285", category = Category::Restriction)] pub(crate) struct BannedModuleLevelImports { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs index 3b16f2a34d..0061d17278 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs @@ -7,6 +7,8 @@ use ruff_python_semantic::{Binding, BindingKind, GeneratorKind, ScopeKind, Seman use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; +use crate::rules::flake8_tidy_imports::rules::BannedModuleImportPolicies; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -45,8 +47,14 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// The fix is only available when the lazy import statement imports a single /// member, since removing `lazy` from a multi-member import would make every /// imported member eager, including names that may not be resolved immediately. +/// +/// ## Options +/// +/// The rule ignores imports required to be lazy by the following setting: +/// +/// - [`lint.flake8-tidy-imports.require-lazy`] #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.13")] +#[violation_metadata(preview_since = "0.15.13", category = Category::Correctness)] pub(crate) struct LazyImportImmediatelyResolved { name: String, fixable: bool, @@ -89,6 +97,19 @@ pub(crate) fn lazy_import_immediately_resolved(checker: &Checker, name: &ExprNam return; }; + // Ignore imports that are required to be lazy. + let require_lazy = &checker.settings().flake8_tidy_imports.require_lazy; + for (policy, node) in &BannedModuleImportPolicies::new(import, checker) { + // An `all` selector matches a `from` import's module, not its individual members. + if require_lazy.includes_all() && import.is_import_from_stmt() && node.is_alias() { + break; + } + + if node.range().contains_range(binding.range()) && require_lazy.find(&policy).is_some() { + return; + } + } + let fix_range = if is_single_member_import(import) { lazy_import_prefix_range(import, checker.source_tokens()) } else { diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs index 09f016caa6..1aae71b8de 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{PythonVersion, Stmt, StmtImport, StmtImportFrom}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_tidy_imports::rules::BannedModuleImportPolicies; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -36,7 +37,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - `lint.flake8-tidy-imports.require-lazy` /// - `lint.flake8-tidy-imports.ban-lazy` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.6")] +#[violation_metadata(preview_since = "0.15.6", category = Category::Restriction)] pub(crate) struct LazyImportMismatch { policy: LazyImportPolicy, name: Option, diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/relative_imports.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/relative_imports.rs index 5611163e3b..6e54dc38b8 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/relative_imports.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/relative_imports.rs @@ -7,6 +7,7 @@ use ruff_python_codegen::Generator; use ruff_python_stdlib::identifiers::is_identifier; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_tidy_imports::settings::Strictness; @@ -54,7 +55,7 @@ use crate::rules::flake8_tidy_imports::settings::Strictness; /// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.169")] +#[violation_metadata(stable_since = "v0.0.169", category = Category::Restriction)] pub(crate) struct RelativeImports { strictness: Strictness, } diff --git a/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs b/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs index 8735e72a5f..c9dec5cfa8 100644 --- a/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs +++ b/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs @@ -8,6 +8,7 @@ use ruff_text_size::{TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::directives::{TodoComment, TodoDirective, TodoDirectiveKind}; use crate::{AlwaysFixableViolation, Edit, Fix, Violation}; @@ -31,7 +32,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, Violation}; /// # TODO(ruff): this is now fixed! /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct InvalidTodoTag { pub tag: String, } @@ -62,7 +63,7 @@ impl Violation for InvalidTodoTag { /// # TODO(charlie): now an author is assigned /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MissingTodoAuthor; impl Violation for MissingTodoAuthor { @@ -104,7 +105,7 @@ impl Violation for MissingTodoAuthor { /// # SIXCHR-003 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MissingTodoLink; impl Violation for MissingTodoLink { @@ -134,7 +135,7 @@ impl Violation for MissingTodoLink { /// # TODO(charlie): colon fixed /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MissingTodoColon; impl Violation for MissingTodoColon { @@ -162,7 +163,7 @@ impl Violation for MissingTodoColon { /// # TODO(charlie): fix some issue /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MissingTodoDescription; impl Violation for MissingTodoDescription { @@ -190,7 +191,7 @@ impl Violation for MissingTodoDescription { /// # TODO(charlie): this is capitalized /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct InvalidTodoCapitalization { tag: String, } @@ -228,7 +229,7 @@ impl AlwaysFixableViolation for InvalidTodoCapitalization { /// # TODO(charlie): fix this /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MissingSpaceAfterTodoColon; impl Violation for MissingSpaceAfterTodoColon { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs index a8f47a5527..52107d083e 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs @@ -84,7 +84,10 @@ mod tests { #[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future.py"))] #[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future_present.py"))] fn add_future_import(rules: &[Rule], path: &Path) -> Result<()> { - let name = rules.iter().map(Rule::noqa_code).join("-"); + let name = rules + .iter() + .map(|rule| rule.name().as_str().strip_prefix("typing-only-").unwrap()) + .join("-"); let snapshot = format!("add_future_import__{}_{}", name, path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_type_checking").join(path).as_path(), @@ -107,7 +110,7 @@ mod tests { fn add_future_import_dataclass_kw_only_py313(rule: Rule, path: &Path) -> Result<()> { let snapshot = format!( "add_future_import_kw_only__{}_{}", - rule.noqa_code(), + rule.name(), path.to_string_lossy() ); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/empty_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/empty_type_checking_block.rs index 4299a91804..21e2c3db8d 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/empty_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/empty_type_checking_block.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Fix}; @@ -33,7 +34,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Suspicious)] pub(crate) struct EmptyTypeCheckingBlock; impl AlwaysFixableViolation for EmptyTypeCheckingBlock { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_cast_value.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_cast_value.rs index 40c210dee1..4758207c31 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_cast_value.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_cast_value.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_type_checking::helpers::quote_type_expression; use crate::{AlwaysFixableViolation, Fix}; @@ -43,7 +44,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// This fix is safe as long as the type expression doesn't span multiple /// lines and includes comments on any of the lines apart from the last one. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Pedantic)] pub(crate) struct RuntimeCastValue; impl AlwaysFixableViolation for RuntimeCastValue { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs index e695c55fa1..456b12b3ed 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs @@ -8,7 +8,7 @@ use ruff_python_semantic::{Imported, NodeId, Scope, ScopeId}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -use crate::codes::Rule; +use crate::codes::{Category, Rule}; use crate::fix; use crate::importer::ImportedMembers; use crate::rules::flake8_type_checking::helpers::{filter_contained, quote_annotation}; @@ -54,7 +54,7 @@ use crate::{Fix, FixAvailability, Violation}; /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Correctness)] pub(crate) struct RuntimeImportInTypeCheckingBlock { qualified_name: String, strategy: Strategy, diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs index 8aa8f2292c..b3d43128c3 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the presence of string literals in `X | Y`-style union types. @@ -53,7 +54,7 @@ use crate::checkers::ast::Checker; /// /// [PEP 604]: https://peps.python.org/pep-0604/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Correctness)] pub(crate) struct RuntimeStringUnion; impl Violation for RuntimeStringUnion { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs index 397ca62d74..657bba9877 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs @@ -7,6 +7,7 @@ use ruff_python_stdlib::typing::{is_pep_593_generic_type, is_standard_library_li use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::flake8_type_checking::helpers::quote_type_expression; use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation}; @@ -49,7 +50,7 @@ use ruff_python_ast::token::parenthesized_range; /// /// [PEP 613]: https://peps.python.org/pep-0613/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Correctness)] pub(crate) struct UnquotedTypeAlias; impl Violation for UnquotedTypeAlias { @@ -134,7 +135,7 @@ impl Violation for UnquotedTypeAlias { /// [PYI020]: https://docs.astral.sh/ruff/rules/quoted-annotation-in-stub/ /// [UP037]: https://docs.astral.sh/ruff/rules/quoted-annotation/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.1")] +#[violation_metadata(preview_since = "0.8.1", category = Category::Pedantic)] pub(crate) struct QuotedTypeAlias; impl AlwaysFixableViolation for QuotedTypeAlias { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs index b8ebb8278e..a9ae462ecd 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs @@ -8,7 +8,7 @@ use ruff_python_semantic::{Binding, Imported, NodeId, Scope}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::{Checker, DiagnosticGuard}; -use crate::codes::Rule; +use crate::codes::{Category, Rule}; use crate::fix; use crate::importer::ImportedMembers; use crate::rules::flake8_type_checking::helpers::{ @@ -82,7 +82,7 @@ use crate::{Fix, FixAvailability, Violation}; /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct TypingOnlyFirstPartyImport { qualified_name: String, } @@ -165,7 +165,7 @@ impl Violation for TypingOnlyFirstPartyImport { /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct TypingOnlyThirdPartyImport { qualified_name: String, } @@ -248,7 +248,7 @@ impl Violation for TypingOnlyThirdPartyImport { /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct TypingOnlyStandardLibraryImport { qualified_name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import-third-party-import-standard-library-import_TC001-3_future.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import-third-party-import-standard-library-import_TC001-3_future.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001_future.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001_future.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001_future_present.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__first-party-import_TC001_future_present.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__standard-library-import_TC003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__standard-library-import_TC003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__third-party-import_TC002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__third-party-import_TC002.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import_kw_only__TC003_TC003.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import_kw_only__typing-only-standard-library-import_TC003.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import_kw_only__TC003_TC003.py.snap rename to crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import_kw_only__typing-only-standard-library-import_TC003.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/mod.rs b/crates/ruff_linter/src/rules/flake8_unused_arguments/mod.rs index 7069da653e..0da07ea379 100644 --- a/crates/ruff_linter/src/rules/flake8_unused_arguments/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_unused_arguments/mod.rs @@ -20,7 +20,7 @@ mod tests { #[test_case(Rule::UnusedLambdaArgument, Path::new("ARG.py"))] #[test_case(Rule::UnusedFunctionArgument, Path::new("ARG_basedpython.by"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_unused_arguments").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs b/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs index e5dde39669..3578274bb7 100644 --- a/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs +++ b/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs @@ -8,6 +8,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; /// ## What it does @@ -36,7 +37,7 @@ use crate::registry::Rule; /// ## Options /// - `lint.dummy-variable-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.168")] +#[violation_metadata(stable_since = "v0.0.168", category = Category::Pedantic)] pub(crate) struct UnusedFunctionArgument { name: String, } @@ -89,7 +90,7 @@ impl Violation for UnusedFunctionArgument { /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.168")] +#[violation_metadata(stable_since = "v0.0.168", category = Category::Pedantic)] pub(crate) struct UnusedMethodArgument { name: String, } @@ -144,7 +145,7 @@ impl Violation for UnusedMethodArgument { /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.168")] +#[violation_metadata(stable_since = "v0.0.168", category = Category::Pedantic)] pub(crate) struct UnusedClassMethodArgument { name: String, } @@ -199,7 +200,7 @@ impl Violation for UnusedClassMethodArgument { /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.168")] +#[violation_metadata(stable_since = "v0.0.168", category = Category::Pedantic)] pub(crate) struct UnusedStaticMethodArgument { name: String, } @@ -239,7 +240,7 @@ impl Violation for UnusedStaticMethodArgument { /// ## Options /// - `lint.dummy-variable-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.168")] +#[violation_metadata(stable_since = "v0.0.168", category = Category::Pedantic)] pub(crate) struct UnusedLambdaArgument { name: String, } diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG003_ARG.py.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-class-method-argument_ARG.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG003_ARG.py.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-class-method-argument_ARG.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG001_ARG.py.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-function-argument_ARG.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG001_ARG.py.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-function-argument_ARG.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG001_ARG_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-function-argument_ARG_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG001_ARG_basedpython.by.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-function-argument_ARG_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG005_ARG.py.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-lambda-argument_ARG.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG005_ARG.py.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-lambda-argument_ARG.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG002_ARG.py.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-method-argument_ARG.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG002_ARG.py.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-method-argument_ARG.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG004_ARG.py.snap b/crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-static-method-argument_ARG.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__ARG004_ARG.py.snap rename to crates/ruff_linter/src/rules/flake8_unused_arguments/snapshots/ruff_linter__rules__flake8_unused_arguments__tests__unused-static-method-argument_ARG.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs index 23c0fd4498..90ce3390ea 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs @@ -1,5 +1,5 @@ //! Rules from [flake8-use-pathlib](https://pypi.org/project/flake8-use-pathlib/). -mod helpers; +pub(crate) mod helpers; pub(crate) mod rules; pub(crate) mod violations; @@ -73,7 +73,7 @@ mod tests { #[test_case(Rule::InvalidPathlibWithSuffix, Path::new("PTH210_1.py"))] #[test_case(Rule::OsSymlink, Path::new("PTH211.py"))] fn rules_pypath(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_use_pathlib").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -151,11 +151,7 @@ mod tests { #[test_case(Rule::OsPathGetmtime, Path::new("PTH204.py"))] #[test_case(Rule::OsPathGetctime, Path::new("PTH205.py"))] fn preview_flake8_use_pathlib(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_use_pathlib").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -166,11 +162,7 @@ mod tests { #[test_case(Rule::InvalidPathlibWithSuffix, Path::new("PTH210_2.py"))] fn pathlib_with_suffix_py314(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "py314__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("py314__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flake8_use_pathlib").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code) diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/builtin_open.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/builtin_open.rs index d3ea3b5256..7396953e13 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/builtin_open.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/builtin_open.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{ArgOrKeyword, Expr, ExprBooleanLiteral, ExprCall}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_builtin_open_enabled; use crate::rules::flake8_use_pathlib::helpers::{ @@ -50,7 +51,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct BuiltinOpen; impl Violation for BuiltinOpen { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/glob_rule.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/glob_rule.rs index b131679ff4..ebf9b46077 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/glob_rule.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/glob_rule.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for the use of `glob.glob()` and `glob.iglob()`. @@ -51,7 +52,7 @@ use crate::Violation; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Pedantic)] pub(crate) struct Glob { pub function: String, } diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/invalid_pathlib_with_suffix.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/invalid_pathlib_with_suffix.rs index 2c3fa0707d..de59448773 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/invalid_pathlib_with_suffix.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/invalid_pathlib_with_suffix.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, PythonVersion, StringFlags}; @@ -57,7 +58,7 @@ use ruff_text_size::Ranged; /// /// No fix is offered if the suffix `"."` is given, since the intent is unclear. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Correctness)] pub(crate) struct InvalidPathlibWithSuffix { single_dot: bool, } diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/mod.rs index 339d2c6a9e..2a262807a2 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/mod.rs @@ -25,6 +25,7 @@ pub(crate) use os_rename::*; pub(crate) use os_replace::*; pub(crate) use os_rmdir::*; pub(crate) use os_sep_split::*; +pub(crate) use os_stat::*; pub(crate) use os_symlink::*; pub(crate) use os_unlink::*; pub(crate) use path_constructor_current_directory::*; @@ -57,6 +58,7 @@ mod os_rename; mod os_replace; mod os_rmdir; mod os_sep_split; +mod os_stat; mod os_symlink; mod os_unlink; mod path_constructor_current_directory; diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_chmod.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_chmod.rs index 3ed3febc1e..5c3c50779c 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_chmod.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_chmod.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{ArgOrKeyword, ExprCall}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_os_chmod_enabled; use crate::rules::flake8_use_pathlib::helpers::{ @@ -51,7 +52,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsChmod; impl Violation for OsChmod { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_getcwd.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_getcwd.rs index d27884b58c..7aa54a989d 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_getcwd.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_getcwd.rs @@ -4,6 +4,7 @@ use ruff_python_ast::ExprCall; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_os_getcwd_enabled; use crate::rules::flake8_use_pathlib::helpers::is_top_level_expression_in_statement; @@ -51,7 +52,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsGetcwd; impl Violation for OsGetcwd { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_makedirs.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_makedirs.rs index 27aec3e66c..b25ba3902f 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_makedirs.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_makedirs.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{ArgOrKeyword, ExprCall}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_os_makedirs_enabled; use crate::rules::flake8_use_pathlib::helpers::{ @@ -50,7 +51,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsMakedirs; impl Violation for OsMakedirs { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_mkdir.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_mkdir.rs index 86eec34df9..1cdf2044b1 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_mkdir.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_mkdir.rs @@ -4,6 +4,7 @@ use ruff_python_ast::ExprCall; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_os_mkdir_enabled; use crate::rules::flake8_use_pathlib::helpers::{ @@ -51,7 +52,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsMkdir; impl Violation for OsMkdir { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_abspath.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_abspath.rs index b51ce5cc6d..8bd53ab515 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_abspath.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_abspath.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_abspath_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_single_arg_calls, has_unknown_keywords_or_starred_expr, @@ -57,7 +58,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathAbspath; impl Violation for OsPathAbspath { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_basename.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_basename.rs index c11c0ac114..d45ba451b0 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_basename.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_basename.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_basename_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -55,7 +56,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathBasename; impl Violation for OsPathBasename { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_dirname.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_dirname.rs index 69b44738f4..58df0a3456 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_dirname.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_dirname.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_dirname_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -59,7 +60,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathDirname; impl Violation for OsPathDirname { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_exists.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_exists.rs index 2b130c72d0..0d36a872bb 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_exists.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_exists.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_exists_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathExists; impl Violation for OsPathExists { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_expanduser.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_expanduser.rs index 2b1fdb8980..fbabd3e032 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_expanduser.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_expanduser.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_expanduser_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -53,7 +54,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathExpanduser; impl Violation for OsPathExpanduser { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getatime.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getatime.rs index eb8fd1f989..9278b35c8e 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getatime.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getatime.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_getatime_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct OsPathGetatime; impl Violation for OsPathGetatime { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getctime.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getctime.rs index 3739391711..cc137c2f29 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getctime.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getctime.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_getctime_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct OsPathGetctime; impl Violation for OsPathGetctime { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getmtime.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getmtime.rs index 2853a83986..40881f0ca3 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getmtime.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getmtime.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_getmtime_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct OsPathGetmtime; impl Violation for OsPathGetmtime { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getsize.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getsize.rs index 7c17e687df..8333313edf 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getsize.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_getsize.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_getsize_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct OsPathGetsize; impl Violation for OsPathGetsize { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isabs.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isabs.rs index 0fcbdf3f06..3397834f51 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isabs.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isabs.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_isabs_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -45,7 +46,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathIsabs; impl Violation for OsPathIsabs { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isdir.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isdir.rs index 9f0de09476..1201c9374c 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isdir.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isdir.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_isdir_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathIsdir; impl Violation for OsPathIsdir { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isfile.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isfile.rs index fc723cbd2f..ef625d51b2 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isfile.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isfile.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_isfile_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathIsfile; impl Violation for OsPathIsfile { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_islink.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_islink.rs index f64aa7713b..39a8c8784f 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_islink.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_islink.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_islink_enabled; use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_single_arg_calls; use crate::{FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathIslink; impl Violation for OsPathIslink { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_samefile.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_samefile.rs index af4ee0b605..2fddf804f2 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_samefile.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_samefile.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_path_samefile_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_two_arg_calls, has_unknown_keywords_or_starred_expr, @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathSamefile; impl Violation for OsPathSamefile { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs index eb99acf024..11d20c5dd3 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{ExprCall, PythonVersion}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_readlink_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_single_arg_calls, is_keyword_only_argument_non_default, @@ -51,7 +52,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsReadlink; impl Violation for OsReadlink { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_remove.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_remove.rs index c25d52de21..9a5085adcc 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_remove.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_remove.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_remove_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_single_arg_calls, is_keyword_only_argument_non_default, @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsRemove; impl Violation for OsRemove { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rename.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rename.rs index 25871defde..9ace932fa5 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rename.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rename.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_rename_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_two_arg_calls, has_unknown_keywords_or_starred_expr, @@ -51,7 +52,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsRename; impl Violation for OsRename { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_replace.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_replace.rs index 3235138d31..be49cacfb4 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_replace.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_replace.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_replace_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_two_arg_calls, has_unknown_keywords_or_starred_expr, @@ -54,7 +55,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsReplace; impl Violation for OsReplace { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rmdir.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rmdir.rs index 7d7a72812d..cf3e556592 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rmdir.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_rmdir.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_rmdir_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_single_arg_calls, is_keyword_only_argument_non_default, @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsRmdir; impl Violation for OsRmdir { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_sep_split.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_sep_split.rs index 8a74dd1106..3266ad8ba9 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_sep_split.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_sep_split.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `.split(os.sep)` @@ -53,7 +54,7 @@ use crate::checkers::ast::Checker; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Pedantic)] pub(crate) struct OsSepSplit; impl Violation for OsSepSplit { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_stat.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_stat.rs new file mode 100644 index 0000000000..011c6e789b --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_stat.rs @@ -0,0 +1,211 @@ +use std::fmt; + +use ruff_diagnostics::{Edit, Fix}; +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, ArgOrKeyword, Arguments, Expr, ExprCall, PythonVersion}; +use ruff_text_size::Ranged; + +use crate::codes::Category; +use crate::{ + FixAvailability, Violation, + checkers::ast::Checker, + importer::ImportRequest, + preview::is_fix_os_stat_enabled, + rules::flake8_use_pathlib::helpers::{ + has_unknown_keywords_or_starred_expr, is_file_descriptor, + is_keyword_only_argument_non_default, is_pathlib_path_call, + }, +}; + +/// ## What it does +/// Checks for uses of `os.stat`. +/// +/// ## Why is this bad? +/// `pathlib` offers a high-level API for path manipulation, as compared to +/// the lower-level API offered by `os`. When possible, using `Path` object +/// methods such as `Path.stat()` can improve readability over the `os` +/// module's counterparts (e.g., `os.path.stat()`). +/// +/// ## Examples +/// ```python +/// import os +/// from pwd import getpwuid +/// from grp import getgrgid +/// +/// stat = os.stat(file_name) +/// owner_name = getpwuid(stat.st_uid).pw_name +/// group_name = getgrgid(stat.st_gid).gr_name +/// ``` +/// +/// Use instead: +/// ```python +/// from pathlib import Path +/// +/// file_path = Path(file_name) +/// stat = file_path.stat() +/// owner_name = file_path.owner() +/// group_name = file_path.group() +/// ``` +/// +/// ## Known issues +/// While using `pathlib` can improve the readability and type safety of your code, +/// it can be less performant than the lower-level alternatives that work directly with strings, +/// especially on older versions of Python. +/// +/// ## Fix Safety +/// This rule's fix is always marked as unsafe because `pathlib.Path` and `os.stat` differ in their +/// handling of `bytes` paths and file descriptors. +/// +/// ## References +/// - [Python documentation: `Path.stat`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) +/// - [Python documentation: `Path.group`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.group) +/// - [Python documentation: `Path.owner`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.owner) +/// - [Python documentation: `os.stat`](https://docs.python.org/3/library/os.html#os.stat) +/// - [PEP 428 – The pathlib module – object-oriented filesystem paths](https://peps.python.org/pep-0428/) +/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#corresponding-tools) +/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +#[derive(ViolationMetadata)] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] +pub(crate) struct OsStat { + method: Option, +} + +impl Violation for OsStat { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + "`os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()`" + .to_string() + } + + fn fix_title(&self) -> Option { + self.method + .map(|method| format!("Replace with `Path(...).{method}()`")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StatMethod { + Stat, + LStat, +} + +impl StatMethod { + fn as_str(self) -> &'static str { + match self { + StatMethod::Stat => "stat", + StatMethod::LStat => "lstat", + } + } +} + +impl fmt::Display for StatMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +// PTH116 +pub(crate) fn os_stat(checker: &Checker, call: &ExprCall, segment: &[&str]) { + if segment != ["os", "stat"] { + return; + } + + // `dir_fd` is not supported by pathlib, so check if it's set to non-default values. + // Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.stat) + // ```text + // 0 1 2 + // os.stat(path, *, dir_fd=None, follow_symlinks=True) + // ``` + if is_keyword_only_argument_non_default(&call.arguments, "dir_fd") { + return; + } + + let Some(path_args) = call.arguments.find_argument_value("path", 0) else { + return; + }; + + if is_file_descriptor(path_args, checker.semantic()) { + return; + } + + let method = if checker.target_version() >= PythonVersion::PY310 { + Some(StatMethod::Stat) + } else { + match is_boolean_literal_or_default(&call.arguments, "follow_symlinks") { + Some(true) => Some(StatMethod::Stat), + Some(false) => Some(StatMethod::LStat), + None => None, + } + }; + + let range = call.range(); + let mut diagnostic = checker.report_diagnostic(OsStat { method }, call.func.range()); + + if !is_fix_os_stat_enabled(checker.settings()) { + return; + } + + if has_unknown_keywords_or_starred_expr(&call.arguments, &["path", "dir_fd", "follow_symlinks"]) + { + return; + } + + let Some(method) = method else { + return; + }; + + diagnostic.try_set_fix(|| { + let (import_edit, binding) = checker.importer().get_or_import_symbol( + &ImportRequest::import("pathlib", "Path"), + call.start(), + checker.semantic(), + )?; + + let locator = checker.locator(); + let path_code = locator.slice(path_args.range()); + + let args = |arg: ArgOrKeyword| match arg { + ArgOrKeyword::Arg(expr) if expr.range() != path_args.range() => { + Some(locator.slice(expr.range())) + } + ArgOrKeyword::Keyword(kw) + if matches!(kw.arg.as_deref(), Some("follow_symlinks")) + && checker.target_version() >= PythonVersion::PY310 => + { + Some(locator.slice(kw.range())) + } + _ => None, + }; + + let stat_args = itertools::join(call.arguments.iter_source_order().filter_map(args), ", "); + + let replacement = if is_pathlib_path_call(checker, path_args) { + format!("{path_code}.{method}({stat_args})") + } else { + format!("{binding}({path_code}).{method}({stat_args})") + }; + + Ok(Fix::unsafe_edits( + Edit::range_replacement(replacement, range), + [import_edit], + )) + }); +} + +/// Returns the value of the given boolean keyword argument. +/// +/// If the keyword is omitted, returns `Some(true)` (its default value). +/// Returns `None` if the keyword argument is present but not a boolean literal. +fn is_boolean_literal_or_default(argument: &Arguments, name: &str) -> Option { + let Some(kw) = argument.find_keyword(name) else { + return Some(true); + }; + + match &kw.value { + Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => Some(*value), + _ => None, + } +} diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_symlink.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_symlink.rs index 6e54acabb4..2be1e363c4 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_symlink.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_symlink.rs @@ -4,6 +4,7 @@ use ruff_python_ast::ExprCall; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_fix_os_symlink_enabled; use crate::rules::flake8_use_pathlib::helpers::{ @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct OsSymlink; impl Violation for OsSymlink { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_unlink.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_unlink.rs index 28568cf479..62d4324133 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_unlink.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_unlink.rs @@ -3,6 +3,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::ExprCall; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_os_unlink_enabled; use crate::rules::flake8_use_pathlib::helpers::{ check_os_pathlib_single_arg_calls, is_keyword_only_argument_non_default, @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsUnlink; impl Violation for OsUnlink { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs index 840befca76..3d80caa53e 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::rules::flake8_use_pathlib::helpers::is_pure_path_subclass_with_preview; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation: `Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct PathConstructorCurrentDirectory; impl AlwaysFixableViolation for PathConstructorCurrentDirectory { diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs index 5ecef9398c..f7001f43e2 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs @@ -1,14 +1,17 @@ +use ruff_diagnostics::Applicability; use ruff_python_ast::{Expr, ExprCall}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::importer::ImportRequest; use crate::rules::flake8_use_pathlib::helpers::{ is_file_descriptor, is_keyword_only_argument_non_default, }; use crate::rules::flake8_use_pathlib::{ rules::Glob, - violations::{Joiner, OsListdir, OsPathJoin, OsPathSplitext, OsStat, PyPath}, + violations::{Joiner, OsListdir, OsPathJoin, OsPathSplitext, PyPath}, }; +use crate::{Edit, Fix}; pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) { let Some(qualified_name) = checker.semantic().resolve_qualified_name(&call.func) else { @@ -17,53 +20,30 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) { let range = call.func.range(); match qualified_name.segments() { - // PTH116 - ["os", "stat"] => { - // `dir_fd` is not supported by pathlib, so check if it's set to non-default values. - // Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.stat) - // ```text - // 0 1 2 - // os.stat(path, *, dir_fd=None, follow_symlinks=True) - // ``` - if call - .arguments - .find_argument_value("path", 0) - .is_some_and(|expr| is_file_descriptor(expr, checker.semantic())) - || is_keyword_only_argument_non_default(&call.arguments, "dir_fd") - { - return; - } - checker.report_diagnostic_if_enabled(OsStat, range) - } // PTH118 - ["os", "path", "join"] => checker.report_diagnostic_if_enabled( - OsPathJoin { - module: "path".to_string(), - joiner: if call.arguments.args.iter().any(Expr::is_starred_expr) { - Joiner::Joinpath - } else { - Joiner::Slash - }, - }, - range, - ), - ["os", "sep", "join"] => checker.report_diagnostic_if_enabled( - OsPathJoin { - module: "sep".to_string(), - joiner: if call.arguments.args.iter().any(Expr::is_starred_expr) { - Joiner::Joinpath - } else { - Joiner::Slash + ["os", module @ ("path" | "sep"), "join"] => { + checker.report_diagnostic_if_enabled( + OsPathJoin { + module: module.to_string(), + joiner: if call.arguments.args.iter().any(Expr::is_starred_expr) { + Joiner::Joinpath + } else { + Joiner::Slash + }, }, - }, - range, - ), + range, + ); + } // PTH122 - ["os", "path", "splitext"] => checker.report_diagnostic_if_enabled(OsPathSplitext, range), + ["os", "path", "splitext"] => { + checker.report_diagnostic_if_enabled(OsPathSplitext, range); + } // PTH124 - ["py", "path", "local"] => checker.report_diagnostic_if_enabled(PyPath, range), + ["py", "path", "local"] => { + checker.report_diagnostic_if_enabled(PyPath, range); + } // PTH207 - ["glob", "glob"] => { + ["glob", function @ ("glob" | "iglob")] => { // `dir_fd` is not supported by pathlib, so check if it's set to non-default values. // Signature as of Python 3.13 (https://docs.python.org/3/library/glob.html#glob.glob) // ```text @@ -76,42 +56,40 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) { checker.report_diagnostic_if_enabled( Glob { - function: "glob".to_string(), + function: function.to_string(), }, range, - ) - } - - ["glob", "iglob"] => { - // `dir_fd` is not supported by pathlib, so check if it's set to non-default values. - // Signature as of Python 3.13 (https://docs.python.org/3/library/glob.html#glob.iglob) - // ```text - // 0 1 2 3 4 - // glob.iglob(pathname, *, root_dir=None, dir_fd=None, recursive=False, include_hidden=False) - // ``` - if is_keyword_only_argument_non_default(&call.arguments, "dir_fd") { - return; - } - - checker.report_diagnostic_if_enabled( - Glob { - function: "iglob".to_string(), - }, - range, - ) + ); } // PTH208 ["os", "listdir"] => { - if call - .arguments - .find_argument_value("path", 0) - .is_some_and(|expr| is_file_descriptor(expr, checker.semantic())) - { + let path = call.arguments.find_argument_value("path", 0); + if path.is_some_and(|expr| is_file_descriptor(expr, checker.semantic())) { return; } - checker.report_diagnostic_if_enabled(OsListdir, range) + + if let Some(mut diagnostic) = checker.report_diagnostic_if_enabled(OsListdir, range) { + if let Some(path) = path { + diagnostic.try_set_fix(|| { + let (import_edit, binding) = checker.importer().get_or_import_symbol( + &ImportRequest::import("pathlib", "Path"), + call.start(), + checker.semantic(), + )?; + + Ok(Fix::applicable_edits( + Edit::range_replacement( + format!("{binding}({}).iterdir()", checker.locator().slice(path)), + call.range(), + ), + [import_edit], + Applicability::DisplayOnly, + )) + }); + } + } } - _ => return, - }; + _ => {} + } } diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH208_PTH208.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH208_PTH208.py.snap deleted file mode 100644 index 9aeb2ced0d..0000000000 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH208_PTH208.py.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs ---- -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:3:1 - | -1 | import os -2 | -3 | os.listdir('.') - | ^^^^^^^^^^ -4 | os.listdir(b'.') - | - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:4:1 - | -3 | os.listdir('.') -4 | os.listdir(b'.') - | ^^^^^^^^^^ -5 | -6 | string_path = '.' - | - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:7:1 - | -6 | string_path = '.' -7 | os.listdir(string_path) - | ^^^^^^^^^^ -8 | -9 | bytes_path = b'.' - | - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:10:1 - | - 9 | bytes_path = b'.' -10 | os.listdir(bytes_path) - | ^^^^^^^^^^ - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:16:1 - | -15 | path_path = Path('.') -16 | os.listdir(path_path) - | ^^^^^^^^^^ - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:19:4 - | -19 | if os.listdir("dir"): - | ^^^^^^^^^^ -20 | ... - | - -PTH208 Use `pathlib.Path.iterdir()` instead. - --> PTH208.py:22:14 - | -20 | ... -21 | -22 | if "file" in os.listdir("dir"): - | ^^^^^^^^^^ -23 | ... - | diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH123_PTH123.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__builtin-open_PTH123.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH123_PTH123.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__builtin-open_PTH123.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap index bc4e59683c..2b0cad24a1 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap @@ -202,6 +202,7 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 24 | os.path.isabs(p) 25 | os.path.join(p, q) | +help: Replace with `Path(...).stat()` PTH117 `os.path.isabs()` should be replaced by `Path.is_absolute()` --> full_name.py:24:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH207_PTH207.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__glob_PTH207.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH207_PTH207.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__glob_PTH207.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_as.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_as.py.snap index 89653f3915..5c6884fdd6 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_as.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_as.py.snap @@ -202,6 +202,7 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 24 | foo_p.isabs(p) 25 | foo_p.join(p, q) | +help: Replace with `Path(...).stat()` PTH117 `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_as.py:24:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from.py.snap index 3482761ca3..3f1937c0f5 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from.py.snap @@ -202,6 +202,7 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 26 | isabs(p) 27 | join(p, q) | +help: Replace with `Path(...).stat()` PTH117 `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_from.py:26:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from_as.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from_as.py.snap index 224ed57fba..383e6cf8c2 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from_as.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__import_from_as.py.snap @@ -202,6 +202,7 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 31 | xisabs(p) 32 | xjoin(p, q) | +help: Replace with `Path(...).stat()` PTH117 `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_from_as.py:31:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH210_PTH210.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__invalid-pathlib-with-suffix_PTH210.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH210_PTH210.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__invalid-pathlib-with-suffix_PTH210.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH210_PTH210_1.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__invalid-pathlib-with-suffix_PTH210_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH210_PTH210_1.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__invalid-pathlib-with-suffix_PTH210_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-listdir_PTH208.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-listdir_PTH208.py.snap new file mode 100644 index 0000000000..e88620ed4c --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-listdir_PTH208.py.snap @@ -0,0 +1,133 @@ +--- +source: crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs +--- +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:3:1 + | +1 | import os +2 | +3 | os.listdir('.') + | ^^^^^^^^^^ +4 | os.listdir(b'.') + | +help: Replace with `Path(...).iterdir()` + | +1 | import os +2 + import pathlib +3 | + - os.listdir('.') +4 + pathlib.Path('.').iterdir() +5 | os.listdir(b'.') + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:4:1 + | +3 | os.listdir('.') +4 | os.listdir(b'.') + | ^^^^^^^^^^ +5 | +6 | string_path = '.' + | +help: Replace with `Path(...).iterdir()` + | +1 | import os +2 + import pathlib +3 | +4 | os.listdir('.') + - os.listdir(b'.') +5 + pathlib.Path(b'.').iterdir() +6 | + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:7:1 + | +6 | string_path = '.' +7 | os.listdir(string_path) + | ^^^^^^^^^^ +8 | +9 | bytes_path = b'.' + | +help: Replace with `Path(...).iterdir()` + | +1 | import os +2 + import pathlib +3 | +-------------------------------------------------------------------------------- +7 | string_path = '.' + - os.listdir(string_path) +8 + pathlib.Path(string_path).iterdir() +9 | + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:10:1 + | + 9 | bytes_path = b'.' +10 | os.listdir(bytes_path) + | ^^^^^^^^^^ +help: Replace with `Path(...).iterdir()` + | +1 | import os +2 + import pathlib +3 | +-------------------------------------------------------------------------------- +10 | bytes_path = b'.' + - os.listdir(bytes_path) +11 + pathlib.Path(bytes_path).iterdir() +12 | + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:16:1 + | +15 | path_path = Path('.') +16 | os.listdir(path_path) + | ^^^^^^^^^^ +help: Replace with `Path(...).iterdir()` + | +15 | path_path = Path('.') + - os.listdir(path_path) +16 + Path(path_path).iterdir() +17 | + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:19:4 + | +19 | if os.listdir("dir"): + | ^^^^^^^^^^ +20 | ... + | +help: Replace with `Path(...).iterdir()` + | +18 | + - if os.listdir("dir"): +19 + if Path("dir").iterdir(): +20 | ... + | +note: This is a display-only fix and is likely to be incorrect + +PTH208 [*] Use `pathlib.Path.iterdir()` instead. + --> PTH208.py:22:14 + | +20 | ... +21 | +22 | if "file" in os.listdir("dir"): + | ^^^^^^^^^^ +23 | ... + | +help: Replace with `Path(...).iterdir()` + | +21 | + - if "file" in os.listdir("dir"): +22 + if "file" in Path("dir").iterdir(): +23 | ... + | +note: This is a display-only fix and is likely to be incorrect diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH203_PTH203.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getatime_PTH203.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH203_PTH203.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getatime_PTH203.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH205_PTH205.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getctime_PTH205.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH205_PTH205.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getctime_PTH205.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH204_PTH204.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getmtime_PTH204.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH204_PTH204.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getmtime_PTH204.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH202_PTH202.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getsize_PTH202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH202_PTH202.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getsize_PTH202.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH202_PTH202_2.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getsize_PTH202_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH202_PTH202_2.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-path-getsize_PTH202_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH206_PTH206.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-sep-split_PTH206.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH206_PTH206.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-sep-split_PTH206.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH211_PTH211.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-symlink_PTH211.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH211_PTH211.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__os-symlink_PTH211.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__path-constructor-current-directory_PTH201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__path-constructor-current-directory_PTH201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH123_PTH123.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__builtin-open_PTH123.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH123_PTH123.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__builtin-open_PTH123.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH203_PTH203.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getatime_PTH203.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH203_PTH203.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getatime_PTH203.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH205_PTH205.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getctime_PTH205.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH205_PTH205.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getctime_PTH205.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH204_PTH204.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getmtime_PTH204.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH204_PTH204.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getmtime_PTH204.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH202_PTH202.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getsize_PTH202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH202_PTH202.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getsize_PTH202.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH202_PTH202_2.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getsize_PTH202_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH202_PTH202_2.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__os-path-getsize_PTH202_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH201_PTH201.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__path-constructor-current-directory_PTH201.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__PTH201_PTH201.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview__path-constructor-current-directory_PTH201.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_full_name.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_full_name.py.snap index 968f293563..b66c9d1543 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_full_name.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_full_name.py.snap @@ -315,7 +315,7 @@ help: Replace with `Path(...).readlink()` 24 | os.stat(p) | -PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` +PTH116 [*] `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` --> full_name.py:23:1 | 21 | bbbbb = os.path.islink(p) @@ -325,6 +325,18 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 24 | os.path.isabs(p) 25 | os.path.join(p, q) | +help: Replace with `Path(...).stat()` + | +2 | import os.path +3 + import pathlib +4 | +-------------------------------------------------------------------------------- +23 | os.readlink(p) + - os.stat(p) +24 + pathlib.Path(p).stat() +25 | os.path.isabs(p) + | +note: This is an unsafe fix and may change runtime behavior PTH117 [*] `os.path.isabs()` should be replaced by `Path.is_absolute()` --> full_name.py:24:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_as.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_as.py.snap index 327e1a1bc0..3e79328059 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_as.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_as.py.snap @@ -315,7 +315,7 @@ help: Replace with `Path(...).readlink()` 24 | foo.stat(p) | -PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` +PTH116 [*] `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` --> import_as.py:23:1 | 21 | bbbbb = foo_p.islink(p) @@ -325,6 +325,18 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 24 | foo_p.isabs(p) 25 | foo_p.join(p, q) | +help: Replace with `Path(...).stat()` + | +2 | import os.path as foo_p +3 + import pathlib +4 | +-------------------------------------------------------------------------------- +23 | foo.readlink(p) + - foo.stat(p) +24 + pathlib.Path(p).stat() +25 | foo_p.isabs(p) + | +note: This is an unsafe fix and may change runtime behavior PTH117 [*] `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_as.py:24:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from.py.snap index 0f889292ca..8098596c97 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from.py.snap @@ -315,7 +315,7 @@ help: Replace with `Path(...).readlink()` 26 | stat(p) | -PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` +PTH116 [*] `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` --> import_from.py:25:1 | 23 | bbbbb = islink(p) @@ -325,6 +325,18 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 26 | isabs(p) 27 | join(p, q) | +help: Replace with `Path(...).stat()` + | +4 | from os.path import isabs, join, basename, dirname, samefile, splitext +5 + import pathlib +6 | +-------------------------------------------------------------------------------- +25 | readlink(p) + - stat(p) +26 + pathlib.Path(p).stat() +27 | isabs(p) + | +note: This is an unsafe fix and may change runtime behavior PTH117 [*] `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_from.py:26:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from_as.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from_as.py.snap index 2e5f5bcf1a..bc50264039 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from_as.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__preview_import_from_as.py.snap @@ -315,7 +315,7 @@ help: Replace with `Path(...).readlink()` 31 | xstat(p) | -PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` +PTH116 [*] `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` --> import_from_as.py:30:1 | 28 | bbbbb = xislink(p) @@ -325,6 +325,18 @@ PTH116 `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path 31 | xisabs(p) 32 | xjoin(p, q) | +help: Replace with `Path(...).stat()` + | +9 | from os.path import samefile as xsamefile, splitext as xsplitext +10 + import pathlib +11 | +-------------------------------------------------------------------------------- +30 | xreadlink(p) + - xstat(p) +31 + pathlib.Path(p).stat() +32 | xisabs(p) + | +note: This is an unsafe fix and may change runtime behavior PTH117 [*] `os.path.isabs()` should be replaced by `Path.is_absolute()` --> import_from_as.py:31:1 diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH124_py_path_1.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py-path_py_path_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH124_py_path_1.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py-path_py_path_1.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH124_py_path_2.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py-path_py_path_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH124_py_path_2.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py-path_py_path_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py314__PTH210_PTH210_2.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py314__invalid-pathlib-with-suffix_PTH210_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py314__PTH210_PTH210_2.py.snap rename to crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__py314__invalid-pathlib-with-suffix_PTH210_2.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/violations.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/violations.rs index b5bcfdb1e3..dd8bedd3d4 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/violations.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/violations.rs @@ -1,62 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; -use crate::Violation; - -/// ## What it does -/// Checks for uses of `os.stat`. -/// -/// ## Why is this bad? -/// `pathlib` offers a high-level API for path manipulation, as compared to -/// the lower-level API offered by `os`. When possible, using `Path` object -/// methods such as `Path.stat()` can improve readability over the `os` -/// module's counterparts (e.g., `os.path.stat()`). -/// -/// ## Examples -/// ```python -/// import os -/// from pwd import getpwuid -/// from grp import getgrgid -/// -/// stat = os.stat(file_name) -/// owner_name = getpwuid(stat.st_uid).pw_name -/// group_name = getgrgid(stat.st_gid).gr_name -/// ``` -/// -/// Use instead: -/// ```python -/// from pathlib import Path -/// -/// file_path = Path(file_name) -/// stat = file_path.stat() -/// owner_name = file_path.owner() -/// group_name = file_path.group() -/// ``` -/// -/// ## Known issues -/// While using `pathlib` can improve the readability and type safety of your code, -/// it can be less performant than the lower-level alternatives that work directly with strings, -/// especially on older versions of Python. -/// -/// ## References -/// - [Python documentation: `Path.stat`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) -/// - [Python documentation: `Path.group`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.group) -/// - [Python documentation: `Path.owner`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.owner) -/// - [Python documentation: `os.stat`](https://docs.python.org/3/library/os.html#os.stat) -/// - [PEP 428 – The pathlib module – object-oriented filesystem paths](https://peps.python.org/pep-0428/) -/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#corresponding-tools) -/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) -/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) -#[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] -pub(crate) struct OsStat; - -impl Violation for OsStat { - #[derive_message_formats] - fn message(&self) -> String { - "`os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()`" - .to_string() - } -} +use crate::codes::Category; +use crate::{FixAvailability, Violation}; /// ## What it does /// Checks for uses of `os.path.join`. @@ -94,7 +39,7 @@ impl Violation for OsStat { /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathJoin { pub(crate) module: String, pub(crate) joiner: Joiner, @@ -166,7 +111,7 @@ pub(crate) enum Joiner { /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Pedantic)] pub(crate) struct OsPathSplitext; impl Violation for OsPathSplitext { @@ -203,7 +148,7 @@ impl Violation for OsPathSplitext { /// - [Python documentation: `Pathlib`](https://docs.python.org/3/library/pathlib.html) /// - [Path repository](https://github.com/jaraco/path) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Suspicious)] pub(crate) struct PyPath; impl Violation for PyPath { @@ -262,12 +207,18 @@ impl Violation for PyPath { /// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) /// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Pedantic)] pub(crate) struct OsListdir; impl Violation for OsListdir { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { "Use `pathlib.Path.iterdir()` instead.".to_string() } + + fn fix_title(&self) -> Option { + Some("Replace with `Path(...).iterdir()`".to_string()) + } } diff --git a/crates/ruff_linter/src/rules/flynt/mod.rs b/crates/ruff_linter/src/rules/flynt/mod.rs index 373e6cfc4c..9000b1cd94 100644 --- a/crates/ruff_linter/src/rules/flynt/mod.rs +++ b/crates/ruff_linter/src/rules/flynt/mod.rs @@ -15,7 +15,7 @@ mod tests { #[test_case(Rule::StaticJoinToFString, Path::new("FLY002.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("flynt").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs index bdc7340974..14d1dadeb3 100644 --- a/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs +++ b/crates/ruff_linter/src/rules/flynt/rules/static_join_to_fstring.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, Arguments, Expr, StringFlags, str::Quote}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -39,7 +40,7 @@ use crate::rules::flynt::helpers; /// ## References /// - [Python documentation: f-strings](https://docs.python.org/3/reference/lexical_analysis.html#f-strings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.266")] +#[violation_metadata(stable_since = "v0.0.266", category = Category::Complexity)] pub(crate) struct StaticJoinToFString { expression: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap b/crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__static-join-to-f-string_FLY002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__FLY002_FLY002.py.snap rename to crates/ruff_linter/src/rules/flynt/snapshots/ruff_linter__rules__flynt__tests__static-join-to-f-string_FLY002.py.snap diff --git a/crates/ruff_linter/src/rules/isort/format.rs b/crates/ruff_linter/src/rules/isort/format.rs index 2eb236196b..b356d8df9a 100644 --- a/crates/ruff_linter/src/rules/isort/format.rs +++ b/crates/ruff_linter/src/rules/isort/format.rs @@ -1,6 +1,7 @@ use ruff_python_codegen::Stylist; use crate::line_width::{LineLength, LineWidthBuilder}; +use crate::settings::types::PreviewMode; use super::types::{AliasData, ImportCommentSet, ImportFromCommentSet, ImportFromData, Importable}; @@ -54,6 +55,7 @@ pub(crate) fn format_import_from( force_wrap_aliases: bool, is_first: bool, trailing_comma: bool, + preview: PreviewMode, ) -> String { if aliases.len() == 1 && aliases @@ -67,6 +69,7 @@ pub(crate) fn format_import_from( is_first, stylist, indentation_width, + preview, ); return single_line; } @@ -95,6 +98,7 @@ pub(crate) fn format_import_from( is_first, stylist, indentation_width, + preview, ); if import_width <= line_length || aliases.iter().any(|(alias, _)| alias.name == "*") { return single_line; @@ -114,6 +118,7 @@ fn format_single_line( is_first: bool, stylist: &Stylist, indentation_width: LineWidthBuilder, + preview: PreviewMode, ) -> (String, LineWidthBuilder) { let mut output = String::with_capacity(CAPACITY); let mut line_width = indentation_width; @@ -161,7 +166,7 @@ fn format_single_line( output.push(' '); output.push(' '); output.push_str(comment); - line_width = line_width.add_width(2).add_str(comment); + line_width = line_width.add_comment(comment, preview); } for (_, comments) in aliases { @@ -169,21 +174,21 @@ fn format_single_line( output.push(' '); output.push(' '); output.push_str(comment); - line_width = line_width.add_width(2).add_str(comment); + line_width = line_width.add_comment(comment, preview); } for comment in &comments.inline { output.push(' '); output.push(' '); output.push_str(comment); - line_width = line_width.add_width(2).add_str(comment); + line_width = line_width.add_comment(comment, preview); } for comment in &comments.trailing { output.push(' '); output.push(' '); output.push_str(comment); - line_width = line_width.add_width(2).add_str(comment); + line_width = line_width.add_comment(comment, preview); } } @@ -191,7 +196,7 @@ fn format_single_line( output.push(' '); output.push(' '); output.push_str(comment); - line_width = line_width.add_width(2).add_str(comment); + line_width = line_width.add_comment(comment, preview); } output.push_str(&stylist.line_ending()); diff --git a/crates/ruff_linter/src/rules/isort/mod.rs b/crates/ruff_linter/src/rules/isort/mod.rs index 497aa2822b..228e25e6a2 100644 --- a/crates/ruff_linter/src/rules/isort/mod.rs +++ b/crates/ruff_linter/src/rules/isort/mod.rs @@ -20,6 +20,7 @@ use types::{AliasData, ImportBlock, TrailingComma}; use crate::Locator; use crate::line_width::{LineLength, LineWidthBuilder}; use crate::package::PackageRoot; +use crate::settings::types::PreviewMode; use ruff_python_ast::PythonVersion; mod annotate; @@ -78,6 +79,7 @@ pub(crate) fn format_imports( source_type: PySourceType, target_version: PythonVersion, settings: &Settings, + preview: PreviewMode, tokens: &Tokens, ) -> String { let trailer = &block.trailer; @@ -105,6 +107,7 @@ pub(crate) fn format_imports( package, target_version, settings, + preview, ); if !block_output.is_empty() && !output.is_empty() { @@ -161,6 +164,7 @@ fn format_import_block( package: Option>, target_version: PythonVersion, settings: &Settings, + preview: PreviewMode, ) -> String { #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum LineInsertion { @@ -290,6 +294,7 @@ fn format_import_block( is_first_statement, settings.split_on_trailing_comma && matches!(trailing_comma, TrailingComma::Present), + preview, )); if settings.from_first { @@ -312,14 +317,16 @@ mod tests { use rustc_hash::{FxHashMap, FxHashSet}; use test_case::test_case; + use ruff_python_ast::{PySourceType, SourceType}; use ruff_python_semantic::{MemberNameImport, ModuleNameImport, NameImport}; use crate::assert_diagnostics; use crate::registry::Rule; use crate::rules::isort::categorize::{ImportSection, KnownModules}; use crate::settings::LinterSettings; - use crate::settings::types::IdentifierPattern; - use crate::test::{test_path, test_resource_path}; + use crate::settings::types::{IdentifierPattern, PreviewMode}; + use crate::source_kind::SourceKind; + use crate::test::{test_contents, test_path, test_resource_path}; use super::categorize::ImportType; use super::settings::RelativeImportsOrder; @@ -334,6 +341,8 @@ mod tests { #[test_case(Path::new("deduplicate_imports.py"))] #[test_case(Path::new("fit_line_length.py"))] #[test_case(Path::new("fit_line_length_comment.py"))] + #[test_case(Path::new("fit_line_length_mixed_pragma.py"))] + #[test_case(Path::new("fit_line_length_pragma.py"))] #[test_case(Path::new("force_sort_within_sections.py"))] #[test_case(Path::new("force_to_top.py"))] #[test_case(Path::new("force_wrap_aliases.py"))] @@ -392,6 +401,59 @@ mod tests { Ok(()) } + #[test_case(Path::new("fit_line_length_mixed_pragma.py"))] + #[test_case(Path::new("fit_line_length_pragma.py"))] + fn preview(path: &Path) -> Result<()> { + let snapshot = format!("preview__{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + preview: PreviewMode::Enabled, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + /// Fixing I001 must never leave behind a line that E501 then flags. + /// + /// isort excludes pragma comments (e.g., `# noqa: TID251`) from its width computation + /// per comment, while E501 measures the emitted line's single comment token as a whole. + /// When isort merges separate comments onto one line (e.g., a statement-level `# explain` + /// and an alias-level `# noqa`), the two computations could disagree; this test verifies + /// that the fix nonetheless converges to output that E501 accepts, in both stable and + /// preview modes. + #[test_case(PreviewMode::Disabled, "stable")] + #[test_case(PreviewMode::Enabled, "preview")] + fn no_line_too_long_after_fix(preview: PreviewMode, label: &str) -> Result<()> { + let path = test_resource_path("fixtures").join("isort/fit_line_length_merged_pragma.py"); + let source_type = SourceType::Python(PySourceType::from(&path)); + let source_kind = SourceKind::from_path(&path, source_type)?.expect("valid source"); + let settings = LinterSettings { + preview, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rules([Rule::UnsortedImports, Rule::LineTooLong]) + }; + + // `test_contents` applies fixes to convergence (and panics if they fail to converge). + let (_, transformed) = test_contents(&source_kind, &path, &settings); + insta::assert_snapshot!( + format!("fit_line_length_merged_pragma_fixed_{label}"), + transformed.source_code() + ); + + // Re-linting the converged output must produce no diagnostics: I001 is fully fixed + // and, in particular, the fix must not have introduced any E501 violations. + let (diagnostics, _) = test_contents(&transformed, &path, &settings); + assert!( + diagnostics.is_empty(), + "expected no diagnostics after applying fixes, found:\n{diagnostics:#?}" + ); + Ok(()) + } + fn pattern(pattern: &str) -> IdentifierPattern { IdentifierPattern::new(pattern).unwrap() } diff --git a/crates/ruff_linter/src/rules/isort/rules/add_required_imports.rs b/crates/ruff_linter/src/rules/isort/rules/add_required_imports.rs index 5b37cfa840..7e3e30cf79 100644 --- a/crates/ruff_linter/src/rules/isort/rules/add_required_imports.rs +++ b/crates/ruff_linter/src/rules/isort/rules/add_required_imports.rs @@ -8,6 +8,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::importer::Importer; use crate::settings::LinterSettings; use crate::{AlwaysFixableViolation, Fix}; @@ -39,7 +40,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## Options /// - `lint.isort.required-imports` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.218")] +#[violation_metadata(stable_since = "v0.0.218", category = Category::Pedantic)] pub(crate) struct MissingRequiredImport(pub String); impl AlwaysFixableViolation for MissingRequiredImport { diff --git a/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs b/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs index 43c0ed9b90..e0aa2f553e 100644 --- a/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs +++ b/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs @@ -12,6 +12,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::line_width::LineWidthBuilder; use crate::package::PackageRoot; use crate::rules::isort::block::Block; @@ -39,7 +40,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ``` /// #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.110")] +#[violation_metadata(stable_since = "v0.0.110", category = Category::Style)] pub(crate) struct UnsortedImports; impl Violation for UnsortedImports { @@ -169,6 +170,7 @@ pub(crate) fn organize_imports( source_type, target_version, &settings.isort, + settings.preview, tokens, ); diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_preview.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_preview.snap new file mode 100644 index 0000000000..38ba563037 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_preview.snap @@ -0,0 +1,17 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +expression: transformed.source_code() +--- +# Separate statement-level and alias-level comments that isort merges onto one line +# when collapsing. The merged comment token is 89 columns with the `# explain` prefix +# counted, so the import must not end up on an overlong single line. +from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import x # explain # noqa: TID251 + +# A single mixed comment on an already-collapsed 102-column line. The code plus the +# non-pragma prefix is 86 columns. +from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # explain # noqa: TID251 + +# Separate comments with the pragma first: when isort collapses this import in preview, +# the merged comment token is pragma-prefixed, so E501 strips it entirely and the +# collapsed 104-column line is fine. +from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import x # noqa: TID251 # explain diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_stable.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_stable.snap new file mode 100644 index 0000000000..aeff3e15ca --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_merged_pragma_fixed_stable.snap @@ -0,0 +1,23 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +expression: transformed.source_code() +--- +# Separate statement-level and alias-level comments that isort merges onto one line +# when collapsing. The merged comment token is 89 columns with the `# explain` prefix +# counted, so the import must not end up on an overlong single line. +from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import ( # explain + x, # noqa: TID251 +) + +# A single mixed comment on an already-collapsed 102-column line. The code plus the +# non-pragma prefix is 86 columns. +from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import ( + x, # explain # noqa: TID251 +) + +# Separate comments with the pragma first: when isort collapses this import in preview, +# the merged comment token is pragma-prefixed, so E501 strips it entirely and the +# collapsed 104-column line is fine. +from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import ( # noqa: TID251 + x, # explain +) diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_mixed_pragma.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_mixed_pragma.py.snap new file mode 100644 index 0000000000..a1a4fd29d0 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_mixed_pragma.py.snap @@ -0,0 +1,28 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> fit_line_length_mixed_pragma.py:3:1 + | +1 | # The next import fits on one line once the trailing pragma is excluded from the width +2 | # (the `# keep this` prefix still counts); in preview it should not be wrapped. +3 | / from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251 +4 | | # The next import exceeds the line length even without the trailing pragma +5 | | # (code plus the `# keep this` prefix is 89 columns); it must always be wrapped. +6 | | from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251 + | |____________________________________________________________________________^ +help: Organize imports + | +2 | # (the `# keep this` prefix still counts); in preview it should not be wrapped. + - from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251 +3 + from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import ( +4 + x, # keep this # noqa: TID251 +5 + ) +6 + +7 | # The next import exceeds the line length even without the trailing pragma +8 | # (code plus the `# keep this` prefix is 89 columns); it must always be wrapped. + - from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251 +9 + from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import ( +10 + x, # keep this # noqa: TID251 +11 + ) + | diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_pragma.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_pragma.py.snap new file mode 100644 index 0000000000..258b51f527 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__fit_line_length_pragma.py.snap @@ -0,0 +1,48 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> fit_line_length_pragma.py:3:1 + | +1 | # The next import fits on one line once the pragma comment is excluded from the width; +2 | # in preview it should not be wrapped (the `# noqa` must stay effective). +3 | / from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251 +4 | | # The next import exceeds the line length even without the pragma comment; +5 | | # it must still be wrapped. +6 | | from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251 + | |_________________________________________________________________________________________^ +help: Organize imports + | +2 | # in preview it should not be wrapped (the `# noqa` must stay effective). + - from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251 +3 + from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import ( +4 + x, # noqa: TID251 +5 + ) +6 + +7 | # The next import exceeds the line length even without the pragma comment; +8 | # it must still be wrapped. + - from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251 +9 + from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import ( +10 + x, # noqa: TID251 +11 + ) +12 | + | + +I001 [*] Import block is un-sorted or un-formatted + --> fit_line_length_pragma.py:12:5 + | +10 | # The next import fits on one line once the pragma comment is excluded from the +11 | # width, so in preview it should not be wrapped. +12 | from cccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccccccccc import bar # noqa: PLC0415 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +13 | bar() + | +help: Organize imports + | +11 | # width, so in preview it should not be wrapped. + - from cccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccccccccc import bar # noqa: PLC0415 +12 + from cccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccccccccc import ( +13 + bar, # noqa: PLC0415 +14 + ) +15 | bar() + | diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_mixed_pragma.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_mixed_pragma.py.snap new file mode 100644 index 0000000000..19e870e003 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_mixed_pragma.py.snap @@ -0,0 +1,24 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> fit_line_length_mixed_pragma.py:3:1 + | +1 | # The next import fits on one line once the trailing pragma is excluded from the width +2 | # (the `# keep this` prefix still counts); in preview it should not be wrapped. +3 | / from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251 +4 | | # The next import exceeds the line length even without the trailing pragma +5 | | # (code plus the `# keep this` prefix is 89 columns); it must always be wrapped. +6 | | from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251 + | |____________________________________________________________________________^ +help: Organize imports + | +3 | from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251 +4 + +5 | # The next import exceeds the line length even without the trailing pragma +6 | # (code plus the `# keep this` prefix is 89 columns); it must always be wrapped. + - from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251 +7 + from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import ( +8 + x, # keep this # noqa: TID251 +9 + ) + | diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_pragma.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_pragma.py.snap new file mode 100644 index 0000000000..5bc4f7f090 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__preview__fit_line_length_pragma.py.snap @@ -0,0 +1,25 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> fit_line_length_pragma.py:3:1 + | +1 | # The next import fits on one line once the pragma comment is excluded from the width; +2 | # in preview it should not be wrapped (the `# noqa` must stay effective). +3 | / from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251 +4 | | # The next import exceeds the line length even without the pragma comment; +5 | | # it must still be wrapped. +6 | | from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251 + | |_________________________________________________________________________________________^ +help: Organize imports + | +3 | from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251 +4 + +5 | # The next import exceeds the line length even without the pragma comment; +6 | # it must still be wrapped. + - from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251 +7 + from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import ( +8 + x, # noqa: TID251 +9 + ) +10 | + | diff --git a/crates/ruff_linter/src/rules/mccabe/rules/function_is_too_complex.rs b/crates/ruff_linter/src/rules/mccabe/rules/function_is_too_complex.rs index d3b023e43b..24d495e87b 100644 --- a/crates/ruff_linter/src/rules/mccabe/rules/function_is_too_complex.rs +++ b/crates/ruff_linter/src/rules/mccabe/rules/function_is_too_complex.rs @@ -3,6 +3,7 @@ use ruff_python_ast::identifier::Identifier; use ruff_python_ast::{self as ast, ExceptHandler, Stmt}; use crate::Violation; +use crate::codes::Category; use crate::checkers::ast::Checker; @@ -56,7 +57,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.mccabe.max-complexity` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.127")] +#[violation_metadata(stable_since = "v0.0.127", category = Category::Restriction)] pub(crate) struct ComplexStructure { name: String, complexity: usize, diff --git a/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs b/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs index b945ba8df0..81bd21ebb0 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -31,7 +32,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// np.all([True, False]) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct NumpyDeprecatedFunction { existing: String, replacement: String, diff --git a/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs b/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs index c2744dfbe1..597791c787 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -31,7 +32,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// int /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.247")] +#[violation_metadata(stable_since = "v0.0.247", category = Category::Suspicious)] pub(crate) struct NumpyDeprecatedTypeAlias { type_name: String, } diff --git a/crates/ruff_linter/src/rules/numpy/rules/legacy_random.rs b/crates/ruff_linter/src/rules/numpy/rules/legacy_random.rs index e39ac5303c..fba9117915 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/legacy_random.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/legacy_random.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of legacy `np.random` function calls. @@ -46,7 +47,7 @@ use crate::checkers::ast::Checker; /// [Random Sampling]: https://numpy.org/doc/stable/reference/random/index.html#random-quick-start /// [NEP 19]: https://numpy.org/neps/nep-0019-rng-policy.html #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.248")] +#[violation_metadata(stable_since = "v0.0.248", category = Category::Suspicious)] pub(crate) struct NumpyLegacyRandom { method_name: String, } diff --git a/crates/ruff_linter/src/rules/numpy/rules/numpy_2_0_deprecation.rs b/crates/ruff_linter/src/rules/numpy/rules/numpy_2_0_deprecation.rs index 59b003d5db..dda858fc71 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/numpy_2_0_deprecation.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/numpy_2_0_deprecation.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::{Exceptions, Modules, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::rules::numpy::helpers::{AttributeSearcher, ImportSearcher}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -50,7 +51,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// np.round(arr2) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct Numpy2Deprecation { existing: String, migration_guide: Option, diff --git a/crates/ruff_linter/src/rules/pandas_vet/mod.rs b/crates/ruff_linter/src/rules/pandas_vet/mod.rs index 1102d131f5..98818d0770 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/mod.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/mod.rs @@ -380,7 +380,7 @@ mod tests { #[test_case(Rule::PandasUseOfInplaceArgument, Path::new("PD002.py"))] #[test_case(Rule::PandasNuniqueConstantSeriesCheck, Path::new("PD101.py"))] fn paths(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pandas_vet").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/assignment_to_df.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/assignment_to_df.rs index fb7ae9713b..15b30970c2 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/assignment_to_df.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/assignment_to_df.rs @@ -2,7 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; -use crate::{Violation, checkers::ast::Checker}; +use crate::{Violation, checkers::ast::Checker, codes::Category}; /// ## Removed /// @@ -32,7 +32,7 @@ use crate::{Violation, checkers::ast::Checker}; /// animals = pd.read_csv("animals.csv") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.13.0")] +#[violation_metadata(removed_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct PandasDfVariableName; impl Violation for PandasDfVariableName { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/attr.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/attr.rs index cd5c1d2d8d..7e74450419 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/attr.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/attr.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// ## What it does @@ -34,7 +35,7 @@ use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// ## References /// - [Pandas documentation: Accessing the values in a Series or Index](https://pandas.pydata.org/pandas-docs/stable/whatsnew/v0.24.0.html#accessing-the-values-in-a-series-or-index) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotValues; impl Violation for PandasUseOfDotValues { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/call.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/call.rs index 407f689b69..263d5189be 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/call.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/call.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; @@ -40,7 +41,7 @@ use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// - [Pandas documentation: `isnull`](https://pandas.pydata.org/docs/reference/api/pandas.isnull.html#pandas.isnull) /// - [Pandas documentation: `isna`](https://pandas.pydata.org/docs/reference/api/pandas.isna.html#pandas.isna) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Style)] pub(crate) struct PandasUseOfDotIsNull; impl Violation for PandasUseOfDotIsNull { @@ -81,7 +82,7 @@ impl Violation for PandasUseOfDotIsNull { /// - [Pandas documentation: `notnull`](https://pandas.pydata.org/docs/reference/api/pandas.notnull.html#pandas.notnull) /// - [Pandas documentation: `notna`](https://pandas.pydata.org/docs/reference/api/pandas.notna.html#pandas.notna) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Style)] pub(crate) struct PandasUseOfDotNotNull; impl Violation for PandasUseOfDotNotNull { @@ -118,7 +119,7 @@ impl Violation for PandasUseOfDotNotNull { /// - [Pandas documentation: Reshaping and pivot tables](https://pandas.pydata.org/docs/user_guide/reshaping.html) /// - [Pandas documentation: `pivot_table`](https://pandas.pydata.org/docs/reference/api/pandas.pivot_table.html#pandas.pivot_table) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotPivotOrUnstack; impl Violation for PandasUseOfDotPivotOrUnstack { @@ -156,7 +157,7 @@ impl Violation for PandasUseOfDotPivotOrUnstack { /// - [Pandas documentation: `melt`](https://pandas.pydata.org/docs/reference/api/pandas.melt.html) /// - [Pandas documentation: `stack`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.stack.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotStack; impl Violation for PandasUseOfDotStack { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/inplace_argument.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/inplace_argument.rs index c307bb6951..12625ae6d2 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/inplace_argument.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/inplace_argument.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_semantic::Modules; @@ -40,7 +41,7 @@ use ruff_python_semantic::Modules; /// ## References /// - [_Why You Should Probably Never Use pandas `inplace=True`_](https://towardsdatascience.com/why-you-should-probably-never-use-pandas-inplace-true-9f9f211849e4) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfInplaceArgument; impl Violation for PandasUseOfInplaceArgument { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/nunique_constant_series_check.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/nunique_constant_series_check.rs index 279e0a9d69..8ebe2d447b 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/nunique_constant_series_check.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/nunique_constant_series_check.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// ## What it does @@ -51,7 +52,7 @@ use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// - [Pandas Cookbook: "Constant Series"](https://pandas.pydata.org/docs/user_guide/cookbook.html#constant-series) /// - [Pandas documentation: `nunique`](https://pandas.pydata.org/docs/reference/api/pandas.Series.nunique.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct PandasNuniqueConstantSeriesCheck; impl Violation for PandasNuniqueConstantSeriesCheck { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/pd_merge.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/pd_merge.rs index 34ca700a13..3c4ea6464f 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/pd_merge.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/pd_merge.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `pd.merge` on Pandas objects. @@ -44,7 +45,7 @@ use crate::checkers::ast::Checker; /// - [Pandas documentation: `merge`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.merge.html#pandas.DataFrame.merge) /// - [Pandas documentation: `pd.merge`](https://pandas.pydata.org/docs/reference/api/pandas.merge.html#pandas.merge) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Style)] pub(crate) struct PandasUseOfPdMerge; impl Violation for PandasUseOfPdMerge { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/read_table.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/read_table.rs index 79025d2b8b..b724b72b11 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/read_table.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/read_table.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `pd.read_table` to read CSV files. @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// - [Pandas documentation: `read_csv`](https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html#pandas.read_csv) /// - [Pandas documentation: `read_table`](https://pandas.pydata.org/docs/reference/api/pandas.read_table.html#pandas.read_table) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Complexity)] pub(crate) struct PandasUseOfDotReadTable; impl Violation for PandasUseOfDotReadTable { diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs index 7ebb5ba0d0..507219ccce 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; @@ -40,7 +41,7 @@ use crate::rules::pandas_vet::helpers::{Resolution, test_expression}; /// - [Pandas documentation: `loc`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.loc.html) /// - [Pandas documentation: `iloc`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.iloc.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotIx; impl Violation for PandasUseOfDotIx { @@ -83,7 +84,7 @@ impl Violation for PandasUseOfDotIx { /// - [Pandas documentation: `loc`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.loc.html) /// - [Pandas documentation: `at`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.at.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotAt; impl Violation for PandasUseOfDotAt { @@ -135,7 +136,7 @@ impl Violation for PandasUseOfDotAt { /// - [Pandas documentation: `iloc`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.iloc.html) /// - [Pandas documentation: `iat`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.iat.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.188")] +#[violation_metadata(stable_since = "v0.0.188", category = Category::Pedantic)] pub(crate) struct PandasUseOfDotIat; impl Violation for PandasUseOfDotIat { diff --git a/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD101_PD101.py.snap b/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-nunique-constant-series-check_PD101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD101_PD101.py.snap rename to crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-nunique-constant-series-check_PD101.py.snap diff --git a/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD012_pandas_use_of_dot_read_table.py.snap b/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-use-of-dot-read-table_pandas_use_of_dot_read_table.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD012_pandas_use_of_dot_read_table.py.snap rename to crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-use-of-dot-read-table_pandas_use_of_dot_read_table.py.snap diff --git a/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD002_PD002.py.snap b/crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-use-of-inplace-argument_PD002.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__PD002_PD002.py.snap rename to crates/ruff_linter/src/rules/pandas_vet/snapshots/ruff_linter__rules__pandas_vet__tests__pandas-use-of-inplace-argument_PD002.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/mod.rs b/crates/ruff_linter/src/rules/pep8_naming/mod.rs index e2cf544221..6068a60641 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/mod.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/mod.rs @@ -81,7 +81,7 @@ mod tests { Path::new("N999/module/invalid_name/import.py") )] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pep8_naming").join(path).as_path(), &settings::LinterSettings { @@ -167,7 +167,7 @@ mod tests { #[test_case(Rule::ErrorSuffixOnExceptionName, "N818.py")] #[test_case(Rule::InvalidModuleName, "N999/badAllowed/__init__.py")] fn ignore_names(rule_code: Rule, path: &str) -> Result<()> { - let snapshot = format!("ignore_names_{}_{path}", rule_code.noqa_code()); + let snapshot = format!("ignore_names_{}_{path}", rule_code.name()); let diagnostics = test_path( PathBuf::from_iter(["pep8_naming", "ignore_names", path]).as_path(), &settings::LinterSettings { diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_acronym.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_acronym.rs index 6e5c75886a..7ece900ed5 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_acronym.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_acronym.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; /// ## What it does @@ -42,7 +43,7 @@ use crate::rules::pep8_naming::helpers; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct CamelcaseImportedAsAcronym { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_constant.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_constant.rs index 09f5f2deb0..a2f523c5cb 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_constant.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_constant.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; use crate::rules::pep8_naming::settings::IgnoreNames; @@ -51,7 +52,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct CamelcaseImportedAsConstant { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_lowercase.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_lowercase.rs index eb3cf80bdf..641e3b835d 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_lowercase.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/camelcase_imported_as_lowercase.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; use crate::rules::pep8_naming::settings::IgnoreNames; @@ -36,7 +37,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct CamelcaseImportedAsLowercase { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/constant_imported_as_non_constant.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/constant_imported_as_non_constant.rs index 0f54c1e474..5b0c7fc4d7 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/constant_imported_as_non_constant.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/constant_imported_as_non_constant.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::{helpers, settings::IgnoreNames}; /// ## What it does @@ -49,7 +50,7 @@ use crate::rules::pep8_naming::{helpers, settings::IgnoreNames}; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct ConstantImportedAsNonConstant { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/dunder_function_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/dunder_function_name.rs index 84ec885347..cdacaabb10 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/dunder_function_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/dunder_function_name.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::{Scope, ScopeKind}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::settings::IgnoreNames; /// ## What it does @@ -38,7 +39,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct DunderFunctionName; impl Violation for DunderFunctionName { diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/error_suffix_on_exception_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/error_suffix_on_exception_name.rs index bb990df5ce..e02bab019d 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/error_suffix_on_exception_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/error_suffix_on_exception_name.rs @@ -5,6 +5,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::settings::IgnoreNames; /// ## What it does @@ -35,7 +36,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/#exception-names #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.89")] +#[violation_metadata(stable_since = "v0.0.89", category = Category::Pedantic)] pub(crate) struct ErrorSuffixOnExceptionName { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_argument_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_argument_name.rs index b3ee458b31..9ca195b52d 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_argument_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_argument_name.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for argument names that do not follow the `snake_case` convention. @@ -46,7 +47,7 @@ use crate::checkers::ast::Checker; /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.77")] +#[violation_metadata(stable_since = "v0.0.77", category = Category::Pedantic)] pub(crate) struct InvalidArgumentName { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_class_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_class_name.rs index cf996876c8..8768f7b8ba 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_class_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_class_name.rs @@ -5,6 +5,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::settings::IgnoreNames; /// ## What it does @@ -41,7 +42,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/#class-names #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.77")] +#[violation_metadata(stable_since = "v0.0.77", category = Category::Pedantic)] pub(crate) struct InvalidClassName { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_first_argument_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_first_argument_name.rs index b174329467..f0214eadfb 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_first_argument_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_first_argument_name.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::{Scope, ScopeKind, SemanticModel}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::{Checker, DiagnosticGuard}; +use crate::codes::Category; use crate::registry::Rule; use crate::renamer::{Renamer, ShadowedKind}; use crate::{Fix, Violation}; @@ -59,7 +60,7 @@ use crate::{Fix, Violation}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-method-arguments #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.77")] +#[violation_metadata(stable_since = "v0.0.77", category = Category::Pedantic)] pub(crate) struct InvalidFirstArgumentNameForMethod { argument_name: String, } @@ -130,7 +131,7 @@ impl Violation for InvalidFirstArgumentNameForMethod { /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-method-arguments /// [PLW0211]: https://docs.astral.sh/ruff/rules/bad-staticmethod-argument/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.77")] +#[violation_metadata(stable_since = "v0.0.77", category = Category::Pedantic)] pub(crate) struct InvalidFirstArgumentNameForClassMethod { argument_name: String, // Whether the method is `__new__` diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_function_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_function_name.rs index 344dc5089b..d3d69f1931 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_function_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_function_name.rs @@ -6,6 +6,7 @@ use ruff_python_stdlib::str; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::settings::IgnoreNames; /// ## What it does @@ -48,7 +49,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-variable-names /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.77")] +#[violation_metadata(stable_since = "v0.0.77", category = Category::Pedantic)] pub(crate) struct InvalidFunctionName { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs index f19d3ccfc5..6e46a3bd86 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs @@ -9,6 +9,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::package::PackageRoot; use crate::rules::pep8_naming::settings::IgnoreNames; @@ -42,7 +43,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/#package-and-module-names #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.248")] +#[violation_metadata(stable_since = "v0.0.248", category = Category::Style)] pub(crate) struct InvalidModuleName { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/lowercase_imported_as_non_lowercase.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/lowercase_imported_as_non_lowercase.rs index ae246a1f5b..67bfe56ded 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/lowercase_imported_as_non_lowercase.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/lowercase_imported_as_non_lowercase.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::settings::IgnoreNames; /// ## What it does @@ -35,7 +36,7 @@ use crate::rules::pep8_naming::settings::IgnoreNames; /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.82")] +#[violation_metadata(stable_since = "v0.0.82", category = Category::Pedantic)] pub(crate) struct LowercaseImportedAsNonLowercase { name: String, asname: String, diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_class_scope.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_class_scope.rs index 12d9e76760..4b2d24824c 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_class_scope.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_class_scope.rs @@ -4,6 +4,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; /// ## What it does @@ -41,7 +42,7 @@ use crate::rules::pep8_naming::helpers; /// /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-method-arguments #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.89")] +#[violation_metadata(stable_since = "v0.0.89", category = Category::Pedantic)] pub(crate) struct MixedCaseVariableInClassScope { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_global_scope.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_global_scope.rs index 73e9a2d57e..76cfc275b3 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_global_scope.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/mixed_case_variable_in_global_scope.rs @@ -3,6 +3,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; /// ## What it does @@ -50,7 +51,7 @@ use crate::rules::pep8_naming::helpers; /// /// [PEP 8]: https://peps.python.org/pep-0008/#global-variable-names #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.89")] +#[violation_metadata(stable_since = "v0.0.89", category = Category::Pedantic)] pub(crate) struct MixedCaseVariableInGlobalScope { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/non_lowercase_variable_in_function.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/non_lowercase_variable_in_function.rs index c171f23ea9..24a6b4c97b 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/non_lowercase_variable_in_function.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/non_lowercase_variable_in_function.rs @@ -4,6 +4,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pep8_naming::helpers; /// ## What it does @@ -37,7 +38,7 @@ use crate::rules::pep8_naming::helpers; /// /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-variable-names #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.89")] +#[violation_metadata(stable_since = "v0.0.89", category = Category::Pedantic)] pub(crate) struct NonLowercaseVariableInFunction { name: String, } diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N817_N817.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-acronym_N817.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N817_N817.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-acronym_N817.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N814_N814.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-constant_N814.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N814_N814.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-constant_N814.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N813_N813.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-lowercase_N813.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N813_N813.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__camelcase-imported-as-lowercase_N813.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N811_N811.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__constant-imported-as-non-constant_N811.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N811_N811.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__constant-imported-as-non-constant_N811.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N807_N807.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__dunder-function-name_N807.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N807_N807.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__dunder-function-name_N807.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N807_N807_basedpython.by.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__dunder-function-name_N807_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N807_N807_basedpython.by.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__dunder-function-name_N807_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N818_N818.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__error-suffix-on-exception-name_N818.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N818_N818.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__error-suffix-on-exception-name_N818.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N817_N817.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-acronym_N817.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N817_N817.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-acronym_N817.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N814_N814.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-constant_N814.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N814_N814.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-constant_N814.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N813_N813.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-lowercase_N813.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N813_N813.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_camelcase-imported-as-lowercase_N813.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N811_N811.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_constant-imported-as-non-constant_N811.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N811_N811.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_constant-imported-as-non-constant_N811.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N807_N807.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_dunder-function-name_N807.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N807_N807.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_dunder-function-name_N807.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N818_N818.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_error-suffix-on-exception-name_N818.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N818_N818.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_error-suffix-on-exception-name_N818.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N803_N803.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-argument-name_N803.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N803_N803.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-argument-name_N803.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N801_N801.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-class-name_N801.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N801_N801.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-class-name_N801.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N804_N804.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-first-argument-name-for-class-method_N804.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N804_N804.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-first-argument-name-for-class-method_N804.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N805_N805.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-first-argument-name-for-method_N805.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N805_N805.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-first-argument-name-for-method_N805.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N802_N802.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-function-name_N802.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N802_N802.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-function-name_N802.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N803_N804.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-module-name_N999__badAllowed____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N803_N804.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_invalid-module-name_N999__badAllowed____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N812_N812.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_lowercase-imported-as-non-lowercase_N812.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N812_N812.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_lowercase-imported-as-non-lowercase_N812.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N815_N815.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_mixed-case-variable-in-class-scope_N815.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N815_N815.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_mixed-case-variable-in-class-scope_N815.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N816_N816.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_mixed-case-variable-in-global-scope_N816.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N816_N816.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_mixed-case-variable-in-global-scope_N816.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N806_N806.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_non-lowercase-variable-in-function_N806.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N806_N806.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_non-lowercase-variable-in-function_N806.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N803_N803.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-argument-name_N803.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N803_N803.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-argument-name_N803.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__MODULE__file.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-argument-name_N804.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__MODULE__file.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-argument-name_N804.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N801_N801.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-class-name_N801.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N801_N801.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-class-name_N801.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N801_N801_basedpython.by.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-class-name_N801_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N801_N801_basedpython.by.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-class-name_N801_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N804_N804.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-first-argument-name-for-class-method_N804.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N804_N804.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-first-argument-name-for-class-method_N804.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N805_N805.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-first-argument-name-for-method_N805.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N805_N805.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-first-argument-name-for-method_N805.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N802_N802.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-function-name_N802.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N802_N802.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-function-name_N802.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__MODULE____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__MODULE____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__MODULE____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__MODULE____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__flake9____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__MODULE__file.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__flake9____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__MODULE__file.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod with spaces__file.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__flake9____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod with spaces__file.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__flake9____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__invalid_name__0001_initial.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__invalid_name__0001_initial.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__invalid_name__0001_initial.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__invalid_name__0001_initial.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__invalid_name__import.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__invalid_name__import.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__invalid_name__import.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__invalid_name__import.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod with spaces____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod with spaces____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod with spaces____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod with spaces____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__no_module__test.txt.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod with spaces__file.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__no_module__test.txt.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod with spaces__file.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod-with-dashes____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod-with-dashes____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__mod-with-dashes____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__mod-with-dashes____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__no_module__test.txt.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__no_module__test.txt.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____main__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____main__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____setup__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____main__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name____setup__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____main__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name__file-with-dashes.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____setup__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name__file-with-dashes.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name____setup__.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name__file-with-dashes.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name__file-with-dashes.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N999_N999__module__valid_name__file-with-dashes.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name__file-with-dashes.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N999_N999__badAllowed____init__.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name__file-with-dashes.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__ignore_names_N999_N999__badAllowed____init__.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__invalid-module-name_N999__module__valid_name__file-with-dashes.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N812_N812.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__lowercase-imported-as-non-lowercase_N812.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N812_N812.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__lowercase-imported-as-non-lowercase_N812.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N815_N815.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__mixed-case-variable-in-class-scope_N815.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N815_N815.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__mixed-case-variable-in-class-scope_N815.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N816_N816.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__mixed-case-variable-in-global-scope_N816.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N816_N816.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__mixed-case-variable-in-global-scope_N816.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N806_N806.py.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__non-lowercase-variable-in-function_N806.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N806_N806.py.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__non-lowercase-variable-in-function_N806.py.snap diff --git a/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N806_N806_basedpython.by.snap b/crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__non-lowercase-variable-in-function_N806_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__N806_N806_basedpython.by.snap rename to crates/ruff_linter/src/rules/pep8_naming/snapshots/ruff_linter__rules__pep8_naming__tests__non-lowercase-variable-in-function_N806_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/perflint/mod.rs b/crates/ruff_linter/src/rules/perflint/mod.rs index 13ddca3125..c3e28dc3c5 100644 --- a/crates/ruff_linter/src/rules/perflint/mod.rs +++ b/crates/ruff_linter/src/rules/perflint/mod.rs @@ -21,7 +21,7 @@ mod tests { #[test_case(Rule::ManualListCopy, Path::new("PERF402.py"))] #[test_case(Rule::ManualDictComprehension, Path::new("PERF403.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("perflint").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY310), @@ -35,11 +35,7 @@ mod tests { #[test_case(Rule::ManualDictComprehension, Path::new("PERF403.py"))] #[test_case(Rule::ManualListComprehension, Path::new("PERF401.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("perflint").join(path).as_path(), &LinterSettings::for_rule(rule_code) diff --git a/crates/ruff_linter/src/rules/perflint/rules/incorrect_dict_iterator.rs b/crates/ruff_linter/src/rules/perflint/rules/incorrect_dict_iterator.rs index 3a1bbfeb61..7a77619085 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/incorrect_dict_iterator.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/incorrect_dict_iterator.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{Arguments, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// (e.g., if it is missing a `.keys()` or `.values()` method, or if those /// methods behave differently than they do on standard mapping types). #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Complexity)] pub(crate) struct IncorrectDictIterator { subset: DictSubset, } diff --git a/crates/ruff_linter/src/rules/perflint/rules/manual_dict_comprehension.rs b/crates/ruff_linter/src/rules/perflint/rules/manual_dict_comprehension.rs index 0699a29ca4..0a525aaaa2 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/manual_dict_comprehension.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/manual_dict_comprehension.rs @@ -7,6 +7,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_fix_manual_dict_comprehension_enabled; use crate::rules::perflint::helpers::{comment_strings_in_range, statement_deletion_range}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// result.update({x: y for x, y in pairs if y % 2}) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct ManualDictComprehension { fix_type: DictComprehensionType, is_async: bool, diff --git a/crates/ruff_linter/src/rules/perflint/rules/manual_list_comprehension.rs b/crates/ruff_linter/src/rules/perflint/rules/manual_list_comprehension.rs index f144743281..50512936a0 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/manual_list_comprehension.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/manual_list_comprehension.rs @@ -1,5 +1,6 @@ use ruff_python_ast::{self as ast, Arguments, Expr}; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::{ checkers::ast::Checker, preview::is_fix_manual_list_comprehension_enabled, @@ -49,7 +50,7 @@ use ruff_text_size::{Ranged, TextRange}; /// filtered.extend(x for x in original if x % 2) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Pedantic)] pub(crate) struct ManualListComprehension { is_async: bool, comprehension_type: Option, diff --git a/crates/ruff_linter/src/rules/perflint/rules/manual_list_copy.rs b/crates/ruff_linter/src/rules/perflint/rules/manual_list_copy.rs index c8a23320cc..b6845f7567 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/manual_list_copy.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/manual_list_copy.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `for` loops that append every item of an iterable to a list, @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// filtered = list(original) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Complexity)] pub(crate) struct ManualListCopy; impl Violation for ManualListCopy { diff --git a/crates/ruff_linter/src/rules/perflint/rules/try_except_in_loop.rs b/crates/ruff_linter/src/rules/perflint/rules/try_except_in_loop.rs index 9fdcc29446..293560a46e 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/try_except_in_loop.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/try_except_in_loop.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of except handling via `try`-`except` within `for` and @@ -77,7 +78,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `target-version` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Pedantic)] pub(crate) struct TryExceptInLoop; impl Violation for TryExceptInLoop { diff --git a/crates/ruff_linter/src/rules/perflint/rules/unnecessary_list_cast.rs b/crates/ruff_linter/src/rules/perflint/rules/unnecessary_list_cast.rs index 4dd4f8c32a..7797911dad 100644 --- a/crates/ruff_linter/src/rules/perflint/rules/unnecessary_list_cast.rs +++ b/crates/ruff_linter/src/rules/perflint/rules/unnecessary_list_cast.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::typing::find_assigned_value; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -51,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// print(i) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Performance)] pub(crate) struct UnnecessaryListCast; impl AlwaysFixableViolation for UnnecessaryListCast { diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF102_PERF102.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__incorrect-dict-iterator_PERF102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF102_PERF102.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__incorrect-dict-iterator_PERF102.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF403_PERF403.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-dict-comprehension_PERF403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF403_PERF403.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-dict-comprehension_PERF403.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF401_PERF401.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-list-comprehension_PERF401.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF401_PERF401.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-list-comprehension_PERF401.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF402_PERF402.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-list-copy_PERF402.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF402_PERF402.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__manual-list-copy_PERF402.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF102_PERF102.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__incorrect-dict-iterator_PERF102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF102_PERF102.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__incorrect-dict-iterator_PERF102.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF403_PERF403.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__manual-dict-comprehension_PERF403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF403_PERF403.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__manual-dict-comprehension_PERF403.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF401_PERF401.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__manual-list-comprehension_PERF401.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__PERF401_PERF401.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__preview__manual-list-comprehension_PERF401.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF203_PERF203.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__try-except-in-loop_PERF203.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF203_PERF203.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__try-except-in-loop_PERF203.py.snap diff --git a/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF101_PERF101.py.snap b/crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__unnecessary-list-cast_PERF101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__PERF101_PERF101.py.snap rename to crates/ruff_linter/src/rules/perflint/snapshots/ruff_linter__rules__perflint__tests__unnecessary-list-cast_PERF101.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs index 071c8810af..937a024266 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs @@ -71,7 +71,7 @@ mod tests { #[test_case(Rule::UselessSemicolon, Path::new("E703.ipynb"))] #[test_case(Rule::WhitespaceAfterDecorator, Path::new("E204.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pycodestyle").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -105,11 +105,7 @@ mod tests { #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_4.py"))] #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391.ipynb"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pycodestyle").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -210,7 +206,7 @@ mod tests { )] #[test_case(Rule::MissingWhitespaceAroundParameterEquals, Path::new("E25.py"))] fn logical(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pycodestyle").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -245,7 +241,7 @@ mod tests { #[test_case(Rule::TooManyBlankLines, Path::new("E303_first_line_expression.py"))] #[test_case(Rule::TooManyBlankLines, Path::new("E303_first_line_statement.py"))] fn blank_lines_first_line(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pycodestyle").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -269,7 +265,7 @@ mod tests { Path::new("E30_syntax_error.py") )] fn blank_lines(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pycodestyle").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -345,7 +341,7 @@ mod tests { #[test_case(Rule::BlankLinesAfterFunctionOrClass)] #[test_case(Rule::BlankLinesBeforeNestedDefinition)] fn blank_lines_typing_stub(rule_code: Rule) -> Result<()> { - let snapshot = format!("blank_lines_{}_typing_stub", rule_code.noqa_code()); + let snapshot = format!("blank_lines_{}_typing_stub", rule_code.name()); let diagnostics = test_path( Path::new("pycodestyle").join("E30.pyi"), &settings::LinterSettings::for_rule(rule_code), @@ -361,7 +357,7 @@ mod tests { #[test_case(Rule::BlankLinesAfterFunctionOrClass)] #[test_case(Rule::BlankLinesBeforeNestedDefinition)] fn blank_lines_notebook(rule_code: Rule) -> Result<()> { - let snapshot = format!("blank_lines_{}_notebook", rule_code.noqa_code()); + let snapshot = format!("blank_lines_{}_notebook", rule_code.name()); let diagnostics = test_path( Path::new("pycodestyle").join("E30.ipynb"), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/pycodestyle/overlong.rs b/crates/ruff_linter/src/rules/pycodestyle/overlong.rs index b7439cc239..80092565b1 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/overlong.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/overlong.rs @@ -1,11 +1,10 @@ use std::ops::Deref; -use ruff_python_trivia::{CommentRanges, find_trailing_pragma_offset, is_pragma_comment}; +use ruff_python_trivia::CommentRanges; use ruff_source_file::Line; use ruff_text_size::{TextLen, TextRange}; -use crate::line_width::{IndentWidth, LineLength, LineWidthBuilder}; -use crate::preview::is_trailing_pragma_in_line_length_enabled; +use crate::line_width::{IndentWidth, LineLength, LineWidthBuilder, pragma_offset_for_line_length}; use crate::settings::types::PreviewMode; #[derive(Debug)] @@ -134,18 +133,10 @@ impl<'a> StrippedLine<'a> { let comment = &line.as_str()[comment_range]; // Ex) `# type: ignore` or (in preview) `# some comment # noqa: F401` - if is_trailing_pragma_in_line_length_enabled(preview) { - if let Some(offset) = find_trailing_pragma_offset(comment) { - // Strip only the pragma suffix from the comment, preserving any - // preceding non-pragma comment text. - let pragma_start = usize::from(comment_range.start()) + offset; - let prefix = line[..pragma_start].trim_end(); - return Self::WithoutPragma(Line::new(prefix, line.start())); - } - } - // Stable behavior: only strip when the entire comment is a pragma. - else if is_pragma_comment(comment) { - let prefix = &line.as_str()[..usize::from(comment_range.start())].trim_end(); + if let Some(offset) = pragma_offset_for_line_length(comment, preview) { + // Strip the pragma from the line, preserving any preceding non-pragma comment text. + let pragma_start = usize::from(comment_range.start()) + offset; + let prefix = line[..pragma_start].trim_end(); return Self::WithoutPragma(Line::new(prefix, line.start())); } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_class_name.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_class_name.rs index eb2bb786ba..1486ea08b5 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_class_name.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_class_name.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// ## What it does @@ -26,7 +27,7 @@ use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// class Integer(object): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.35")] +#[violation_metadata(stable_since = "v0.0.35", category = Category::Pedantic)] pub(crate) struct AmbiguousClassName(pub String); impl Violation for AmbiguousClassName { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_function_name.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_function_name.rs index f9d53f25b8..ef77301bd9 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_function_name.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_function_name.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// ## What it does @@ -26,7 +27,7 @@ use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// def long_name(x): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.35")] +#[violation_metadata(stable_since = "v0.0.35", category = Category::Pedantic)] pub(crate) struct AmbiguousFunctionName(pub String); impl Violation for AmbiguousFunctionName { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_variable_name.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_variable_name.rs index d618e3d116..3301604d17 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_variable_name.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/ambiguous_variable_name.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// ## What it does @@ -33,7 +34,7 @@ use crate::rules::pycodestyle::helpers::is_ambiguous_name; /// i = 42 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.34")] +#[violation_metadata(stable_since = "v0.0.34", category = Category::Pedantic)] pub(crate) struct AmbiguousVariableName(pub String); impl Violation for AmbiguousVariableName { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/bare_except.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/bare_except.rs index 82bbea5beb..a22adbc559 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/bare_except.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/bare_except.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, ExceptHandler, Expr, Stmt}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for bare `except` catches in `try`-`except` statements. @@ -45,7 +46,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: Exception hierarchy](https://docs.python.org/3/library/exceptions.html#exception-hierarchy) /// - [Google Python Style Guide: "Exceptions"](https://google.github.io/styleguide/pyguide.html#24-exceptions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.36")] +#[violation_metadata(stable_since = "v0.0.36", category = Category::Suspicious)] pub(crate) struct BareExcept; impl Violation for BareExcept { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/blank_lines.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/blank_lines.rs index 32795e95d5..937a6de8c2 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/blank_lines.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/blank_lines.rs @@ -19,6 +19,7 @@ use ruff_text_size::TextSize; use crate::checkers::ast::{DiagnosticGuard, LintContext}; use crate::checkers::logical_lines::expand_indent; +use crate::codes::Category; use crate::line_width::IndentWidth; use crate::rules::pycodestyle::helpers::is_non_logical_token; use crate::{AlwaysFixableViolation, Edit, Fix, Locator, Violation}; @@ -62,7 +63,7 @@ const BLANK_LINES_NESTED_LEVEL: u32 = 1; /// - [Flake 8 rule](https://www.flake8rules.com/rules/E301.html) /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct BlankLineBetweenMethods; impl AlwaysFixableViolation for BlankLineBetweenMethods { @@ -116,7 +117,7 @@ impl AlwaysFixableViolation for BlankLineBetweenMethods { /// - [Flake 8 rule](https://www.flake8rules.com/rules/E302.html) /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct BlankLinesTopLevel { actual_blank_lines: u32, expected_blank_lines: u32, @@ -184,7 +185,7 @@ impl AlwaysFixableViolation for BlankLinesTopLevel { /// - [Flake 8 rule](https://www.flake8rules.com/rules/E303.html) /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct TooManyBlankLines { actual_blank_lines: u32, } @@ -231,7 +232,7 @@ impl AlwaysFixableViolation for TooManyBlankLines { /// - [PEP 8: Blank Lines](https://peps.python.org/pep-0008/#blank-lines) /// - [Flake 8 rule](https://www.flake8rules.com/rules/E304.html) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct BlankLineAfterDecorator { actual_blank_lines: u32, } @@ -283,7 +284,7 @@ impl AlwaysFixableViolation for BlankLineAfterDecorator { /// - [Flake 8 rule](https://www.flake8rules.com/rules/E305.html) /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct BlankLinesAfterFunctionOrClass { actual_blank_lines: u32, } @@ -338,7 +339,7 @@ impl AlwaysFixableViolation for BlankLinesAfterFunctionOrClass { /// - [Flake 8 rule](https://www.flake8rules.com/rules/E306.html) /// - [Typing Style Guide](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.2")] +#[violation_metadata(preview_since = "v0.2.2", category = Category::Formatting)] pub(crate) struct BlankLinesBeforeNestedDefinition; impl AlwaysFixableViolation for BlankLinesBeforeNestedDefinition { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs index 2982aa26db..dc9968d024 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs @@ -7,6 +7,7 @@ use ruff_text_size::{Ranged, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Violation}; use crate::{Edit, Fix}; @@ -29,7 +30,7 @@ use crate::{Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Formatting)] pub(crate) struct MultipleStatementsOnOneLineColon; impl Violation for MultipleStatementsOnOneLineColon { @@ -60,7 +61,7 @@ impl Violation for MultipleStatementsOnOneLineColon { /// /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Formatting)] pub(crate) struct MultipleStatementsOnOneLineSemicolon; impl Violation for MultipleStatementsOnOneLineSemicolon { @@ -86,7 +87,7 @@ impl Violation for MultipleStatementsOnOneLineSemicolon { /// do_four() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Formatting)] pub(crate) struct UselessSemicolon; impl AlwaysFixableViolation for UselessSemicolon { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/doc_line_too_long.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/doc_line_too_long.rs index 5744b9051f..afdca7b7e7 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/doc_line_too_long.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/doc_line_too_long.rs @@ -4,6 +4,7 @@ use ruff_source_file::Line; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::overlong::Overlong; use crate::settings::LinterSettings; @@ -72,7 +73,7 @@ use crate::settings::LinterSettings; /// /// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.219")] +#[violation_metadata(stable_since = "v0.0.219", category = Category::Formatting)] pub(crate) struct DocLineTooLong(usize, usize); impl Violation for DocLineTooLong { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/errors.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/errors.rs index ad74b42169..5322265bbf 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/errors.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/errors.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// This is not a regular diagnostic; instead, it's raised when a file cannot be read @@ -25,7 +26,7 @@ use crate::Violation; /// - [UNIX Permissions introduction](https://mason.gmu.edu/~montecin/UNIXpermiss.htm) /// - [Command Line Basics: Symbolic Links](https://www.digitalocean.com/community/tutorials/workflow-symbolic-links) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Correctness)] pub struct IOError { pub message: String, } @@ -66,7 +67,7 @@ impl Violation for IOError { /// - [Python documentation: Syntax Errors](https://docs.python.org/3/tutorial/errors.html#syntax-errors) #[derive(ViolationMetadata)] #[deprecated(note = "E999 has been removed")] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Correctness)] pub(crate) struct SyntaxError; #[expect(deprecated)] diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/invalid_escape_sequence.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/invalid_escape_sequence.rs index 3642869ef8..91f4ec17e3 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/invalid_escape_sequence.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/invalid_escape_sequence.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad_start; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -41,7 +42,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: String and Bytes literals](https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.85")] +#[violation_metadata(stable_since = "v0.0.85", category = Category::Correctness)] pub(crate) struct InvalidEscapeSequence { ch: char, fix_title: FixTitle, diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs index fa86563229..7fd183c474 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs @@ -10,6 +10,7 @@ use ruff_source_file::UniversalNewlines; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -45,7 +46,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#programming-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct LambdaAssignment { name: String, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/line_too_long.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/line_too_long.rs index c9b6ef7f28..a2d98f1604 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/line_too_long.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/line_too_long.rs @@ -4,6 +4,7 @@ use ruff_source_file::Line; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::overlong::Overlong; use crate::settings::LinterSettings; @@ -70,7 +71,7 @@ use crate::settings::LinterSettings; /// /// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Formatting)] pub(crate) struct LineTooLong(usize, usize); impl Violation for LineTooLong { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs index 5ae6fe9028..c7b0cde1a1 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs @@ -7,7 +7,7 @@ use ruff_python_ast::{self as ast, CmpOp, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -use crate::codes::Rule; +use crate::codes::{Category, Rule}; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -58,7 +58,7 @@ impl EqCmpOp { /// [PEP 8]: https://peps.python.org/pep-0008/#programming-recommendations /// [this issue]: https://github.com/astral-sh/ruff/issues/4560 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct NoneComparison(EqCmpOp); impl AlwaysFixableViolation for NoneComparison { @@ -121,7 +121,7 @@ impl AlwaysFixableViolation for NoneComparison { /// [PEP 8]: https://peps.python.org/pep-0008/#programming-recommendations /// [this issue]: https://github.com/astral-sh/ruff/issues/4560 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct TrueFalseComparison { value: bool, op: EqCmpOp, diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/extraneous_whitespace.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/extraneous_whitespace.rs index bfa6a247dd..36933d8782 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/extraneous_whitespace.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/extraneous_whitespace.rs @@ -6,6 +6,7 @@ use crate::AlwaysFixableViolation; use crate::Edit; use crate::Fix; use crate::checkers::ast::LintContext; +use crate::codes::Category; use super::{LogicalLine, Whitespace}; @@ -31,7 +32,7 @@ use super::{LogicalLine, Whitespace}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#pet-peeves #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct WhitespaceAfterOpenBracket { symbol: char, } @@ -71,7 +72,7 @@ impl AlwaysFixableViolation for WhitespaceAfterOpenBracket { /// /// [PEP 8]: https://peps.python.org/pep-0008/#pet-peeves #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct WhitespaceBeforeCloseBracket { symbol: char, } @@ -109,7 +110,7 @@ impl AlwaysFixableViolation for WhitespaceBeforeCloseBracket { /// /// [PEP 8]: https://peps.python.org/pep-0008/#pet-peeves #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct WhitespaceBeforePunctuation { symbol: char, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/indentation.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/indentation.rs index 4d2e1dbacd..344dfe3dba 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/indentation.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/indentation.rs @@ -4,6 +4,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; use super::LogicalLine; @@ -38,7 +39,7 @@ use super::LogicalLine; /// [PEP 8]: https://peps.python.org/pep-0008/#indentation /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct IndentationWithInvalidMultiple { indent_width: usize, } @@ -84,7 +85,7 @@ impl Violation for IndentationWithInvalidMultiple { /// [PEP 8]: https://peps.python.org/pep-0008/#indentation /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct IndentationWithInvalidMultipleComment { indent_width: usize, } @@ -118,7 +119,7 @@ impl Violation for IndentationWithInvalidMultipleComment { /// /// [PEP 8]: https://peps.python.org/pep-0008/#indentation #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct NoIndentedBlock; impl Violation for NoIndentedBlock { @@ -151,7 +152,7 @@ impl Violation for NoIndentedBlock { /// /// [PEP 8]: https://peps.python.org/pep-0008/#indentation #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct NoIndentedBlockComment; impl Violation for NoIndentedBlockComment { @@ -181,7 +182,7 @@ impl Violation for NoIndentedBlockComment { /// /// [PEP 8]: https://peps.python.org/pep-0008/#indentation #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct UnexpectedIndentation; impl Violation for UnexpectedIndentation { @@ -211,7 +212,7 @@ impl Violation for UnexpectedIndentation { /// /// [PEP 8]: https://peps.python.org/pep-0008/#indentation #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct UnexpectedIndentationComment; impl Violation for UnexpectedIndentationComment { @@ -248,7 +249,7 @@ impl Violation for UnexpectedIndentationComment { /// [PEP 8]: https://peps.python.org/pep-0008/#indentation /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct OverIndented { is_comment: bool, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs index 999dc2f637..d4c5d23d9d 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Edit; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix}; use super::{DefinitionState, LogicalLine}; @@ -24,7 +25,7 @@ use super::{DefinitionState, LogicalLine}; /// a = (1, 2) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespace { token: TokenKind, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_after_keyword.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_after_keyword.rs index c3bb8f3422..02389359b7 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_after_keyword.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_after_keyword.rs @@ -3,6 +3,7 @@ use ruff_python_ast::token::TokenKind; use ruff_text_size::Ranged; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::rules::logical_lines::LogicalLine; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -27,7 +28,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: Keywords](https://docs.python.org/3/reference/lexical_analysis.html#keywords) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAfterKeyword; impl AlwaysFixableViolation for MissingWhitespaceAfterKeyword { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_around_operator.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_around_operator.rs index db28627b28..e1ae9712b7 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_around_operator.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace_around_operator.rs @@ -3,6 +3,7 @@ use ruff_python_ast::token::TokenKind; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::helpers::is_non_logical_token; use crate::rules::pycodestyle::rules::logical_lines::{DefinitionState, LogicalLine}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [PEP 8]: https://peps.python.org/pep-0008/#pet-peeves // E225 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAroundOperator; impl AlwaysFixableViolation for MissingWhitespaceAroundOperator { @@ -70,7 +71,7 @@ impl AlwaysFixableViolation for MissingWhitespaceAroundOperator { /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations // E226 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAroundArithmeticOperator; impl AlwaysFixableViolation for MissingWhitespaceAroundArithmeticOperator { @@ -110,7 +111,7 @@ impl AlwaysFixableViolation for MissingWhitespaceAroundArithmeticOperator { /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations // E227 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAroundBitwiseOrShiftOperator; impl AlwaysFixableViolation for MissingWhitespaceAroundBitwiseOrShiftOperator { @@ -150,7 +151,7 @@ impl AlwaysFixableViolation for MissingWhitespaceAroundBitwiseOrShiftOperator { /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations // E228 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAroundModuloOperator; impl AlwaysFixableViolation for MissingWhitespaceAroundModuloOperator { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/redundant_backslash.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/redundant_backslash.rs index e077825712..85a7b93673 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/redundant_backslash.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/redundant_backslash.rs @@ -6,6 +6,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use super::LogicalLine; @@ -30,7 +31,7 @@ use super::LogicalLine; /// /// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.3")] +#[violation_metadata(preview_since = "v0.3.3", category = Category::Formatting)] pub(crate) struct RedundantBackslash; impl AlwaysFixableViolation for RedundantBackslash { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs index 74341c3da8..55fcad66da 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs @@ -3,6 +3,7 @@ use ruff_python_ast::token::TokenKind; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use super::{LogicalLine, Whitespace}; @@ -26,7 +27,7 @@ use super::{LogicalLine, Whitespace}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct TabBeforeOperator; impl AlwaysFixableViolation for TabBeforeOperator { @@ -59,7 +60,7 @@ impl AlwaysFixableViolation for TabBeforeOperator { /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MultipleSpacesBeforeOperator; impl AlwaysFixableViolation for MultipleSpacesBeforeOperator { @@ -92,7 +93,7 @@ impl AlwaysFixableViolation for MultipleSpacesBeforeOperator { /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct TabAfterOperator; impl AlwaysFixableViolation for TabAfterOperator { @@ -125,7 +126,7 @@ impl AlwaysFixableViolation for TabAfterOperator { /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MultipleSpacesAfterOperator; impl AlwaysFixableViolation for MultipleSpacesAfterOperator { @@ -156,7 +157,7 @@ impl AlwaysFixableViolation for MultipleSpacesAfterOperator { /// ``` /// #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.281")] +#[violation_metadata(preview_since = "v0.0.281", category = Category::Formatting)] pub(crate) struct TabAfterComma; impl AlwaysFixableViolation for TabAfterComma { @@ -187,7 +188,7 @@ impl AlwaysFixableViolation for TabAfterComma { /// a = 4, 5 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.281")] +#[violation_metadata(preview_since = "v0.0.281", category = Category::Formatting)] pub(crate) struct MultipleSpacesAfterComma; impl AlwaysFixableViolation for MultipleSpacesAfterComma { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_keywords.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_keywords.rs index 69ef1174d4..000c9dc61a 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_keywords.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_keywords.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use super::{LogicalLine, Whitespace}; @@ -22,7 +23,7 @@ use super::{LogicalLine, Whitespace}; /// True and False /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MultipleSpacesAfterKeyword; impl AlwaysFixableViolation for MultipleSpacesAfterKeyword { @@ -52,7 +53,7 @@ impl AlwaysFixableViolation for MultipleSpacesAfterKeyword { /// x and y /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MultipleSpacesBeforeKeyword; impl AlwaysFixableViolation for MultipleSpacesBeforeKeyword { @@ -82,7 +83,7 @@ impl AlwaysFixableViolation for MultipleSpacesBeforeKeyword { /// True and False /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct TabAfterKeyword; impl AlwaysFixableViolation for TabAfterKeyword { @@ -112,7 +113,7 @@ impl AlwaysFixableViolation for TabAfterKeyword { /// True and False /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct TabBeforeKeyword; impl AlwaysFixableViolation for TabBeforeKeyword { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs index 66c1c95dd3..4c4476f05d 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs @@ -3,6 +3,7 @@ use ruff_python_ast::token::TokenKind; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::rules::logical_lines::{DefinitionState, LogicalLine}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct UnexpectedSpacesAroundKeywordParameterEquals; impl AlwaysFixableViolation for UnexpectedSpacesAroundKeywordParameterEquals { @@ -72,7 +73,7 @@ impl AlwaysFixableViolation for UnexpectedSpacesAroundKeywordParameterEquals { /// /// [PEP 8]: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct MissingWhitespaceAroundParameterEquals; impl AlwaysFixableViolation for MissingWhitespaceAroundParameterEquals { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_comment.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_comment.rs index 729079c092..b744c3bb94 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_comment.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_comment.rs @@ -6,6 +6,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::rules::logical_lines::LogicalLine; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -31,7 +32,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#comments #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct TooFewSpacesBeforeInlineComment; impl AlwaysFixableViolation for TooFewSpacesBeforeInlineComment { @@ -68,7 +69,7 @@ impl AlwaysFixableViolation for TooFewSpacesBeforeInlineComment { /// /// [PEP 8]: https://peps.python.org/pep-0008/#comments #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct NoSpaceAfterInlineComment; impl AlwaysFixableViolation for NoSpaceAfterInlineComment { @@ -106,7 +107,7 @@ impl AlwaysFixableViolation for NoSpaceAfterInlineComment { /// /// [PEP 8]: https://peps.python.org/pep-0008/#comments #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct NoSpaceAfterBlockComment; impl AlwaysFixableViolation for NoSpaceAfterBlockComment { @@ -153,7 +154,7 @@ impl AlwaysFixableViolation for NoSpaceAfterBlockComment { /// /// [PEP 8]: https://peps.python.org/pep-0008/#comments #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Pedantic)] pub(crate) struct MultipleLeadingHashesForBlockComment; impl AlwaysFixableViolation for MultipleLeadingHashesForBlockComment { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_parameters.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_parameters.rs index d2595c384f..9ed3e43613 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_parameters.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_before_parameters.rs @@ -3,6 +3,7 @@ use ruff_python_ast::token::TokenKind; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::rules::pycodestyle::rules::logical_lines::LogicalLine; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -26,7 +27,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#pet-peeves #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.269")] +#[violation_metadata(preview_since = "v0.0.269", category = Category::Formatting)] pub(crate) struct WhitespaceBeforeParameters { bracket: TokenKind, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/missing_newline_at_end_of_file.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/missing_newline_at_end_of_file.rs index de7fbd042d..570b0e593b 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/missing_newline_at_end_of_file.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/missing_newline_at_end_of_file.rs @@ -4,6 +4,7 @@ use ruff_text_size::{TextLen, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -24,7 +25,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// spam(1)\n /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.61")] +#[violation_metadata(stable_since = "v0.0.61", category = Category::Formatting)] pub(crate) struct MissingNewlineAtEndOfFile; impl AlwaysFixableViolation for MissingNewlineAtEndOfFile { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/mixed_spaces_and_tabs.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/mixed_spaces_and_tabs.rs index b62d4e008b..da5db539bf 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/mixed_spaces_and_tabs.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/mixed_spaces_and_tabs.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_trivia::leading_indentation; use ruff_source_file::Line; +use crate::codes::Category; use crate::{Violation, checkers::ast::LintContext}; /// ## What it does @@ -27,7 +28,7 @@ use crate::{Violation, checkers::ast::LintContext}; /// if a == 0:\n a = 1\n b = 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Formatting)] pub(crate) struct MixedSpacesAndTabs; impl Violation for MixedSpacesAndTabs { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs index 0112808f32..cba53fbe4b 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs @@ -4,6 +4,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_e402_fix_enabled; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct ModuleImportNotAtTopOfFile { source_type: PySourceType, } diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/multiple_imports_on_one_line.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/multiple_imports_on_one_line.rs index 838170a71f..6f14acd195 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/multiple_imports_on_one_line.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/multiple_imports_on_one_line.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -31,7 +32,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.191")] +#[violation_metadata(stable_since = "v0.0.191", category = Category::Pedantic)] pub(crate) struct MultipleImportsOnOneLine; impl Violation for MultipleImportsOnOneLine { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs index ccc2047358..2f1eb95385 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, CmpOp, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -28,7 +29,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct NotInTest; impl AlwaysFixableViolation for NotInTest { @@ -65,7 +66,7 @@ impl AlwaysFixableViolation for NotInTest { /// /// [PEP8]: https://peps.python.org/pep-0008/#programming-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Pedantic)] pub(crate) struct NotIsTest; impl AlwaysFixableViolation for NotIsTest { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/tab_indentation.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/tab_indentation.rs index 581f2a1eaa..3e0a9c93f3 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/tab_indentation.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/tab_indentation.rs @@ -6,6 +6,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; /// ## What it does /// Checks for indentation that uses tabs. @@ -24,7 +25,7 @@ use crate::checkers::ast::LintContext; /// [PEP 8]: https://peps.python.org/pep-0008/#tabs-or-spaces /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.254")] +#[violation_metadata(stable_since = "v0.0.254", category = Category::Formatting)] pub(crate) struct TabIndentation; impl Violation for TabIndentation { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/too_many_newlines_at_end_of_file.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/too_many_newlines_at_end_of_file.rs index ed4ddbfb42..114972737c 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/too_many_newlines_at_end_of_file.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/too_many_newlines_at_end_of_file.rs @@ -5,6 +5,7 @@ use ruff_notebook::CellOffsets; use ruff_python_ast::token::{Token, TokenKind, Tokens}; use ruff_text_size::{Ranged, TextRange, TextSize}; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix, checkers::ast::LintContext}; /// ## What it does @@ -28,7 +29,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, checkers::ast::LintContext}; /// spam(1)\n /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.3")] +#[violation_metadata(preview_since = "v0.3.3", category = Category::Formatting)] pub(crate) struct TooManyNewlinesAtEndOfFile { num_trailing_newlines: u32, in_notebook: bool, diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/trailing_whitespace.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/trailing_whitespace.rs index c72825da72..4db4483082 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/trailing_whitespace.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/trailing_whitespace.rs @@ -5,6 +5,7 @@ use ruff_text_size::{TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Formatting)] pub(crate) struct TrailingWhitespace; impl AlwaysFixableViolation for TrailingWhitespace { @@ -70,7 +71,7 @@ impl AlwaysFixableViolation for TrailingWhitespace { /// /// [PEP 8]: https://peps.python.org/pep-0008/#other-recommendations #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Formatting)] pub(crate) struct BlankLineWithWhitespace; impl AlwaysFixableViolation for BlankLineWithWhitespace { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs index 152e5e808e..2835593119 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for object type comparisons using `==` and other comparison @@ -49,7 +50,7 @@ use crate::checkers::ast::Checker; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.39")] +#[violation_metadata(stable_since = "v0.0.39", category = Category::Pedantic)] pub(crate) struct TypeComparison; impl Violation for TypeComparison { diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs index 1ce7f2c0a8..5cda822793 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs @@ -4,6 +4,7 @@ use ruff_python_trivia::is_python_whitespace; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.1")] +#[violation_metadata(preview_since = "0.5.1", category = Category::Formatting)] pub(crate) struct WhitespaceAfterDecorator; impl AlwaysFixableViolation for WhitespaceAfterDecorator { diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E742_E742.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-class-name_E742.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E742_E742.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-class-name_E742.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E742_E742_basedpython.by.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-class-name_E742_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E742_E742_basedpython.by.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-class-name_E742_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E743_E743.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-function-name_E743.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E743_E743.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-function-name_E743.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E741_E741.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-variable-name_E741.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E741_E741.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-variable-name_E741.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_2.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-variable-name_E741.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_2.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__ambiguous-variable-name_E741.pyi.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E722_E722.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__bare-except_E722.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E722_E722.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__bare-except_E722.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E304_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-after-decorator_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E304_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-after-decorator_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E301_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-between-methods_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E301_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-between-methods_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E301_E30_syntax_error.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-between-methods_E30_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E301_E30_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-between-methods_E30_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W293_W29.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-with-whitespace_W29.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W293_W29.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-with-whitespace_W29.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W293_W293.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-with-whitespace_W293.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W293_W293.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-line-with-whitespace_W293.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E305_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-after-function-or-class_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E305_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-after-function-or-class_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E305_E30_syntax_error.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-after-function-or-class_E30_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E305_E30_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-after-function-or-class_E30_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E306_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-before-nested-definition_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E306_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-before-nested-definition_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E306_E30_syntax_error.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-before-nested-definition_E30_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E306_E30_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-before-nested-definition_E30_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_docstring.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_docstring.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_docstring.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_docstring.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_expression.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_expression.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_expression.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_expression.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_function.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_function.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_function.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_function.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_statement.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_statement.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E302_first_line_statement.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E302_first_line_statement.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E30_syntax_error.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E30_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E302_E30_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank-lines-top-level_E30_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E304_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-after-decorator_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E304_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-after-decorator_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E304_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-after-decorator_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E304_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-after-decorator_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E301_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-between-methods_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E301_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-between-methods_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-between-methods_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_3.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-line-between-methods_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E305_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-after-function-or-class_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E305_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-after-function-or-class_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_4.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-after-function-or-class_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_4.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-after-function-or-class_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E306_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-before-nested-definition_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E306_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-before-nested-definition_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_5.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-before-nested-definition_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_5.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-before-nested-definition_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E302_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-top-level_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E302_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-top-level_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E741_E741.pyi.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-top-level_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E741_E741.pyi.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_blank-lines-top-level_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E303_notebook.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_too-many-blank-lines_notebook.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E303_notebook.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_too-many-blank-lines_notebook.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E303_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_too-many-blank-lines_typing_stub.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E303_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_too-many-blank-lines_typing_stub.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E114_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__indentation-with-invalid-multiple-comment_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E114_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__indentation-with-invalid-multiple-comment_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E111_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__indentation-with-invalid-multiple_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E111_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__indentation-with-invalid-multiple_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W605_W605_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__invalid-escape-sequence_W605_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W605_W605_0.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__invalid-escape-sequence_W605_0.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W605_W605_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__invalid-escape-sequence_W605_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W605_W605_1.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__invalid-escape-sequence_W605_1.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E731_E731.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__lambda-assignment_E731.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E731_E731.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__lambda-assignment_E731.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501_3.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501_3.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501_4.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E501_E501_4.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__line-too-long_E501_4.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_0.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_0.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_1.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_1.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_2.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_2.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_2.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W292_W292_3.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-newline-at-end-of-file_W292_3.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E275_E27.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-after-keyword_E27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E275_E27.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-after-keyword_E27.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E226_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-arithmetic-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E226_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-arithmetic-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E227_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-bitwise-or-shift-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E227_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-bitwise-or-shift-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E228_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-modulo-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E228_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-modulo-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E225_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E225_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-parameter-equals_E25.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace-around-parameter-equals_E25.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace_E23.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__missing-whitespace_E23.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E101_E101.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__mixed-spaces-and-tabs_E101.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E101_E101.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__mixed-spaces-and-tabs_E101.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E40.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E40.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_0.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_1.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E301_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E301_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_2.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E302_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E302_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_3.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E305_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E305_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_4.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E306_typing_stub.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__blank_lines_E306_typing_stub.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__module-import-not-at-top-of-file_E402_5.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E401_E40.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-imports-on-one-line_E40.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E401_E40.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-imports-on-one-line_E40.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E266_E26.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-leading-hashes-for-block-comment_E26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E266_E26.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-leading-hashes-for-block-comment_E26.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E241_E24.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-comma_E24.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E241_E24.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-comma_E24.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E271_E27.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-keyword_E27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E271_E27.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-keyword_E27.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-after-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E272_E27.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-before-keyword_E27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E272_E27.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-before-keyword_E27.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-before-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-spaces-before-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E701_E70.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E701_E70.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E701_E70_basedpython.by.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E701_E70_basedpython.by.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E702_E70.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-semicolon_E70.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E702_E70.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-semicolon_E70.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E115_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-indented-block-comment_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E115_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-indented-block-comment_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E112_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-indented-block_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E112_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-indented-block_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E265_E26.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-space-after-block-comment_E26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E265_E26.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-space-after-block-comment_E26.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E262_E26.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-space-after-inline-comment_E26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E262_E26.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__no-space-after-inline-comment_E26.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E711_E711.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__none-comparison_E711.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E711_E711.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__none-comparison_E711.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E713_E713.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__not-in-test_E713.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E713_E713.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__not-in-test_E713.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E714_E714.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__not-is-test_E714.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E714_E714.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__not-is-test_E714.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E117_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__over-indented_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E117_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__over-indented_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E501_E501_5.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__line-too-long_E501_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E501_E501_5.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__line-too-long_E501_5.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E40.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E40.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_0.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_1.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_2.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_3.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_4.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_5.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_comments.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_comments.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_docstring.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_docstring.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_future.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_future.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_shebang.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_shebang.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_shebang_docstring_and_future.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__module-import-not-at-top-of-file_E402_shebang_docstring_and_future.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E502_E502.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__redundant-backslash_E502.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E502_E502.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__redundant-backslash_E502.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391.ipynb.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_0.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_0.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_1.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_1.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_2.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_2.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_2.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_3.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_3.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_4.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__W391_W391_4.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__too-many-newlines-at-end-of-file_W391_4.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E242_E24.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-comma_E24.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E242_E24.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-comma_E24.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E273_E27.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-keyword_E27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E273_E27.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-keyword_E27.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E224_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E224_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-after-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E274_E27.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-before-keyword_E27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E274_E27.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-before-keyword_E27.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E223_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-before-operator_E22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E223_E22.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-before-operator_E22.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W191_W19.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-indentation_W19.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W191_W19.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__tab-indentation_W19.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E261_E26.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-few-spaces-before-inline-comment_E26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E261_E26.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-few-spaces-before-inline-comment_E26.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E30.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E30.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E30.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_comment.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_comment.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_comment.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_comment.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_docstring.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_docstring.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_docstring.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_docstring.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_expression.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_expression.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_expression.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_expression.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_statement.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_statement.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E303_first_line_statement.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E303_first_line_statement.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E30_syntax_error.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E30_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E303_E30_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__too-many-blank-lines_E30_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W291_W29.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__trailing-whitespace_W29.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W291_W29.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__trailing-whitespace_W29.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W291_W291.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__trailing-whitespace_W291.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__W291_W291.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__trailing-whitespace_W291.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E712_E712.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__true-false-comparison_E712.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E712_E712.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__true-false-comparison_E712.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E721_E721.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__type-comparison_E721.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E721_E721.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__type-comparison_E721.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E116_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-indentation-comment_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E116_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-indentation-comment_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E113_E11.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-indentation_E11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E113_E11.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-indentation_E11.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E251_E25.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-spaces-around-keyword-parameter-equals_E25.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E251_E25.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__unexpected-spaces-around-keyword-parameter-equals_E25.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E703_E70.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__useless-semicolon_E70.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E703_E70.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__useless-semicolon_E70.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E703_E703.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__useless-semicolon_E703.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E703_E703.ipynb.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__useless-semicolon_E703.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E204_E204.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-after-decorator_E204.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E204_E204.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-after-decorator_E204.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E201_E20.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-after-open-bracket_E20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E201_E20.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-after-open-bracket_E20.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E202_E20.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-close-bracket_E20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E202_E20.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-close-bracket_E20.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E211_E21.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-parameters_E21.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E211_E21.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-parameters_E21.py.snap diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E203_E20.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-punctuation_E20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E203_E20.py.snap rename to crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__whitespace-before-punctuation_E20.py.snap diff --git a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs index e3ea55510f..04fdbcd220 100644 --- a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs +++ b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs @@ -13,6 +13,7 @@ use rustc_hash::FxHashMap; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::docstrings::sections::{SectionContext, SectionContexts, SectionKind}; use crate::docstrings::styles::SectionStyle; @@ -64,7 +65,7 @@ use crate::rules::pydocstyle::settings::Convention; /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.14.1")] +#[violation_metadata(preview_since = "0.14.1", category = Category::Pedantic)] pub(crate) struct DocstringExtraneousParameter { id: String, } @@ -125,7 +126,7 @@ impl Violation for DocstringExtraneousParameter { /// - `lint.pydocstyle.convention` /// - `lint.pydocstyle.property-decorators` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.6")] +#[violation_metadata(preview_since = "0.5.6", category = Category::Pedantic)] pub(crate) struct DocstringMissingReturns; impl Violation for DocstringMissingReturns { @@ -182,7 +183,7 @@ impl Violation for DocstringMissingReturns { /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.6")] +#[violation_metadata(preview_since = "0.5.6", category = Category::Pedantic)] pub(crate) struct DocstringExtraneousReturns; impl Violation for DocstringExtraneousReturns { @@ -240,7 +241,7 @@ impl Violation for DocstringExtraneousReturns { /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.7")] +#[violation_metadata(preview_since = "0.5.7", category = Category::Pedantic)] pub(crate) struct DocstringMissingYields; impl Violation for DocstringMissingYields { @@ -297,7 +298,7 @@ impl Violation for DocstringMissingYields { /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.7")] +#[violation_metadata(preview_since = "0.5.7", category = Category::Pedantic)] pub(crate) struct DocstringExtraneousYields; impl Violation for DocstringExtraneousYields { @@ -374,7 +375,7 @@ impl Violation for DocstringExtraneousYields { /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.5")] +#[violation_metadata(preview_since = "0.5.5", category = Category::Pedantic)] pub(crate) struct DocstringMissingException { id: String, } @@ -447,7 +448,7 @@ impl Violation for DocstringMissingException { /// - `lint.pydoclint.ignore-one-line-docstrings` /// - `lint.pydocstyle.convention` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.5")] +#[violation_metadata(preview_since = "0.5.5", category = Category::Pedantic)] pub(crate) struct DocstringExtraneousException { ids: Vec, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/mod.rs b/crates/ruff_linter/src/rules/pydocstyle/mod.rs index e3490be532..ff22b1f059 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/mod.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/mod.rs @@ -103,7 +103,7 @@ mod tests { #[test_case(Rule::TripleSingleQuotes, Path::new("D.py"))] #[test_case(Rule::TripleSingleQuotes, Path::new("D300.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pydocstyle").join(path).as_path(), &settings::LinterSettings { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs index f54ffe86a4..1f12cf5ff0 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/backslashes.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [PEP 257 – Docstring Conventions](https://peps.python.org/pep-0257/) /// - [Python documentation: String and Bytes literals](https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.172")] +#[violation_metadata(stable_since = "v0.0.172", category = Category::Suspicious)] pub(crate) struct EscapeSequenceInDocstring; impl Violation for EscapeSequenceInDocstring { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_after_summary.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_after_summary.rs index 7978bde342..ff8f4bc3ea 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_after_summary.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_after_summary.rs @@ -3,6 +3,7 @@ use ruff_source_file::{UniversalNewlineIterator, UniversalNewlines}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -45,7 +46,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Pedantic)] pub(crate) struct MissingBlankLineAfterSummary { num_lines: usize, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs index 5839278f13..af20e60a47 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use ruff_text_size::TextRange; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -49,7 +50,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [D211]: https://docs.astral.sh/ruff/rules/blank-line-before-class /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Formatting)] pub(crate) struct IncorrectBlankLineBeforeClass; impl AlwaysFixableViolation for IncorrectBlankLineBeforeClass { @@ -102,7 +103,7 @@ impl AlwaysFixableViolation for IncorrectBlankLineBeforeClass { /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Formatting)] pub(crate) struct IncorrectBlankLineAfterClass; impl AlwaysFixableViolation for IncorrectBlankLineAfterClass { @@ -150,7 +151,7 @@ impl AlwaysFixableViolation for IncorrectBlankLineAfterClass { /// /// [D203]: https://docs.astral.sh/ruff/rules/incorrect-blank-line-before-class #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Formatting)] pub(crate) struct BlankLineBeforeClass; impl AlwaysFixableViolation for BlankLineBeforeClass { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs index acbbd38af0..9a48c61464 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use ruff_text_size::TextRange; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::registry::Rule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -42,7 +43,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Formatting)] pub(crate) struct BlankLineBeforeFunction { num_lines: usize, } @@ -93,7 +94,7 @@ impl Violation for BlankLineBeforeFunction { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Formatting)] pub(crate) struct BlankLineAfterFunction { num_lines: usize, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/capitalized.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/capitalized.rs index 05319d9f30..1e7aa0b7e6 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/capitalized.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/capitalized.rs @@ -3,6 +3,7 @@ use ruff_text_size::Ranged; use ruff_text_size::{TextLen, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -34,7 +35,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.69")] +#[violation_metadata(stable_since = "v0.0.69", category = Category::Pedantic)] pub(crate) struct FirstWordUncapitalized { first_word: String, capitalized_word: String, diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_period.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_period.rs index 19081b0c88..250e683476 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_period.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_period.rs @@ -6,6 +6,7 @@ use ruff_source_file::{UniversalNewlineIterator, UniversalNewlines}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::docstrings::sections::SectionKind; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -50,7 +51,7 @@ use crate::rules::pydocstyle::helpers::logical_line; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Pedantic)] pub(crate) struct MissingTrailingPeriod; impl Violation for MissingTrailingPeriod { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_punctuation.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_punctuation.rs index 2e1174a72e..95a53a3aa5 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_punctuation.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/ends_with_punctuation.rs @@ -6,6 +6,7 @@ use ruff_source_file::{UniversalNewlineIterator, UniversalNewlines}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::docstrings::sections::SectionKind; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -44,7 +45,7 @@ use crate::rules::pydocstyle::helpers::logical_line; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.69")] +#[violation_metadata(stable_since = "v0.0.69", category = Category::Pedantic)] pub(crate) struct MissingTerminalPunctuation; impl Violation for MissingTerminalPunctuation { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/if_needed.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/if_needed.rs index 9dacc0efcb..040eeffa65 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/if_needed.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/if_needed.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::visibility::is_overload; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; /// ## What it does @@ -73,7 +74,7 @@ use crate::docstrings::Docstring; /// - [PEP 257 – Docstring Conventions](https://peps.python.org/pep-0257/) /// - [Python documentation: `typing.overload`](https://docs.python.org/3/library/typing.html#typing.overload) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct OverloadWithDocstring; impl Violation for OverloadWithDocstring { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/indent.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/indent.rs index d550447e84..fd28ad6c64 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/indent.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/indent.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextSize}; use ruff_text_size::{TextLen, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Violation}; @@ -56,7 +57,7 @@ use crate::{Edit, Fix}; /// [PEP 8]: https://peps.python.org/pep-0008/#tabs-or-spaces /// [formatter]: https://docs.astral.sh/ruff/formatter #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.75")] +#[violation_metadata(stable_since = "v0.0.75", category = Category::Formatting)] pub(crate) struct DocstringTabIndentation; impl Violation for DocstringTabIndentation { @@ -109,7 +110,7 @@ impl Violation for DocstringTabIndentation { /// [PEP 257]: https://peps.python.org/pep-0257/ /// [formatter]: https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.75")] +#[violation_metadata(stable_since = "v0.0.75", category = Category::Formatting)] pub(crate) struct UnderIndentation; impl AlwaysFixableViolation for UnderIndentation { @@ -166,7 +167,7 @@ impl AlwaysFixableViolation for UnderIndentation { /// [PEP 257]: https://peps.python.org/pep-0257/ /// [formatter]:https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.75")] +#[violation_metadata(stable_since = "v0.0.75", category = Category::Formatting)] pub(crate) struct OverIndentation; impl AlwaysFixableViolation for OverIndentation { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/multi_line_summary_start.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/multi_line_summary_start.rs index b02c06af01..005f6ae0e7 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/multi_line_summary_start.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/multi_line_summary_start.rs @@ -7,6 +7,7 @@ use ruff_source_file::{LineRanges, NewlineWithTrailingNewline, UniversalNewlineI use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::registry::Rule; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -61,7 +62,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [D213]: https://docs.astral.sh/ruff/rules/multi-line-summary-second-line /// [PEP 257]: https://peps.python.org/pep-0257 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.69")] +#[violation_metadata(stable_since = "v0.0.69", category = Category::Pedantic)] pub(crate) struct MultiLineSummaryFirstLine; impl AlwaysFixableViolation for MultiLineSummaryFirstLine { @@ -125,7 +126,7 @@ impl AlwaysFixableViolation for MultiLineSummaryFirstLine { /// [D212]: https://docs.astral.sh/ruff/rules/multi-line-summary-first-line /// [PEP 257]: https://peps.python.org/pep-0257 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.69")] +#[violation_metadata(stable_since = "v0.0.69", category = Category::Pedantic)] pub(crate) struct MultiLineSummarySecondLine; impl AlwaysFixableViolation for MultiLineSummarySecondLine { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/newline_after_last_paragraph.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/newline_after_last_paragraph.rs index 97617ed631..346e0411f9 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/newline_after_last_paragraph.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/newline_after_last_paragraph.rs @@ -6,6 +6,7 @@ use ruff_source_file::{NewlineWithTrailingNewline, UniversalNewlines}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -48,7 +49,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Pedantic)] pub(crate) struct NewLineAfterLastParagraph; impl AlwaysFixableViolation for NewLineAfterLastParagraph { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/no_signature.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/no_signature.rs index 4ffc8be1dd..dfed04fc84 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/no_signature.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/no_signature.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; /// ## What it does @@ -41,7 +42,7 @@ use crate::docstrings::Docstring; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct SignatureInDocstring; impl Violation for SignatureInDocstring { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/no_surrounding_whitespace.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/no_surrounding_whitespace.rs index df2e736965..1514ba89d4 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/no_surrounding_whitespace.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/no_surrounding_whitespace.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use ruff_text_size::{TextLen, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -36,7 +37,7 @@ use crate::rules::pydocstyle::helpers::ends_with_backslash; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Formatting)] pub(crate) struct SurroundingWhitespace; impl Violation for SurroundingWhitespace { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs index 9597b17b92..836f0cc6b0 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs @@ -9,6 +9,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::rules::pydocstyle::helpers::normalize_word; use crate::rules::pydocstyle::settings::Settings; @@ -51,7 +52,7 @@ static MOOD: LazyLock = LazyLock::new(Mood::new); /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.228")] +#[violation_metadata(stable_since = "v0.0.228", category = Category::Pedantic)] pub(crate) struct NonImperativeMood { first_line: String, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/not_empty.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/not_empty.rs index 84f2b7e659..1d55d67707 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/not_empty.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/not_empty.rs @@ -3,6 +3,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; /// ## What it does @@ -33,7 +34,7 @@ use crate::docstrings::Docstring; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Suspicious)] pub(crate) struct EmptyDocstring; impl Violation for EmptyDocstring { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/not_missing.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/not_missing.rs index 60fb7b4504..eac8fdce4a 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/not_missing.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/not_missing.rs @@ -8,6 +8,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for undocumented public module definitions. @@ -65,7 +66,7 @@ use crate::checkers::ast::Checker; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicModule; impl Violation for UndocumentedPublicModule { @@ -153,7 +154,7 @@ impl Violation for UndocumentedPublicModule { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicClass; impl Violation for UndocumentedPublicClass { @@ -245,7 +246,7 @@ impl Violation for UndocumentedPublicClass { /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicMethod; impl Violation for UndocumentedPublicMethod { @@ -336,7 +337,7 @@ impl Violation for UndocumentedPublicMethod { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Python Docstrings](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicFunction; impl Violation for UndocumentedPublicFunction { @@ -384,7 +385,7 @@ impl Violation for UndocumentedPublicFunction { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Python Docstrings](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicPackage; impl Violation for UndocumentedPublicPackage { @@ -442,7 +443,7 @@ impl Violation for UndocumentedPublicPackage { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Python Docstrings](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedMagicMethod; impl Violation for UndocumentedMagicMethod { @@ -502,7 +503,7 @@ impl Violation for UndocumentedMagicMethod { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Python Docstrings](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicNestedClass; impl Violation for UndocumentedPublicNestedClass { @@ -551,7 +552,7 @@ impl Violation for UndocumentedPublicNestedClass { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Python Docstrings](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.70")] +#[violation_metadata(stable_since = "v0.0.70", category = Category::Pedantic)] pub(crate) struct UndocumentedPublicInit; impl Violation for UndocumentedPublicInit { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/one_liner.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/one_liner.rs index a0fcde567c..35e3b28857 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/one_liner.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/one_liner.rs @@ -3,6 +3,7 @@ use ruff_source_file::NewlineWithTrailingNewline; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -41,7 +42,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.68")] +#[violation_metadata(stable_since = "v0.0.68", category = Category::Pedantic)] pub(crate) struct UnnecessaryMultilineDocstring; impl Violation for UnnecessaryMultilineDocstring { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/property_docstring_starts_with_verb.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/property_docstring_starts_with_verb.rs index 8debed2bd7..0ef61fb58c 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/property_docstring_starts_with_verb.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/property_docstring_starts_with_verb.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::rules::pydocstyle::helpers::normalize_word; use crate::rules::pydocstyle::settings::Settings; @@ -46,7 +47,7 @@ use crate::rules::pydocstyle::settings::Settings; /// /// [Google Python style guide]: https://google.github.io/styleguide/pyguide.html#383-functions-and-methods #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.18")] +#[violation_metadata(preview_since = "0.15.18", category = Category::Pedantic)] pub(crate) struct PropertyDocstringStartsWithVerb { first_word: String, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs index fb41b083c9..b31d35b158 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs @@ -14,6 +14,7 @@ use ruff_source_file::NewlineWithTrailingNewline; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::docstrings::sections::{SectionContext, SectionContexts, SectionKind}; use crate::docstrings::styles::SectionStyle; @@ -91,7 +92,7 @@ use crate::{Edit, Fix}; /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Pedantic)] pub(crate) struct OverindentedSection { name: String, } @@ -195,7 +196,7 @@ impl AlwaysFixableViolation for OverindentedSection { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Pedantic)] pub(crate) struct OverindentedSectionUnderline { name: String, } @@ -279,7 +280,7 @@ impl AlwaysFixableViolation for OverindentedSectionUnderline { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct NonCapitalizedSectionName { name: String, } @@ -378,7 +379,7 @@ impl AlwaysFixableViolation for NonCapitalizedSectionName { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct MissingNewLineAfterSectionName { name: String, } @@ -482,7 +483,7 @@ impl AlwaysFixableViolation for MissingNewLineAfterSectionName { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct MissingDashedUnderlineAfterSection { name: String, } @@ -589,7 +590,7 @@ impl AlwaysFixableViolation for MissingDashedUnderlineAfterSection { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct MissingSectionUnderlineAfterName { name: String, } @@ -694,7 +695,7 @@ impl AlwaysFixableViolation for MissingSectionUnderlineAfterName { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct MismatchedSectionUnderlineLength { name: String, } @@ -792,7 +793,7 @@ impl AlwaysFixableViolation for MismatchedSectionUnderlineLength { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct NoBlankLineAfterSection { name: String, } @@ -886,7 +887,7 @@ impl AlwaysFixableViolation for NoBlankLineAfterSection { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct NoBlankLineBeforeSection { name: String, } @@ -982,7 +983,7 @@ impl AlwaysFixableViolation for NoBlankLineBeforeSection { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct MissingBlankLineAfterLastSection { name: String, } @@ -1076,7 +1077,7 @@ impl AlwaysFixableViolation for MissingBlankLineAfterLastSection { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Style Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct EmptyDocstringSection { name: String, } @@ -1154,7 +1155,7 @@ impl Violation for EmptyDocstringSection { /// - [PEP 287 – reStructuredText Docstring Format](https://peps.python.org/pep-0287/) /// - [Google Style Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.74")] +#[violation_metadata(stable_since = "v0.0.74", category = Category::Pedantic)] pub(crate) struct MissingSectionNameColon { name: String, } @@ -1244,7 +1245,7 @@ impl AlwaysFixableViolation for MissingSectionNameColon { /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) /// - [Python - Unpack for keyword arguments](https://typing.python.org/en/latest/spec/callables.html#unpack-kwargs) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.73")] +#[violation_metadata(stable_since = "v0.0.73", category = Category::Pedantic)] pub(crate) struct UndocumentedParam { /// The name of the function being documented. definition: String, @@ -1331,7 +1332,7 @@ impl Violation for UndocumentedParam { /// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide - Docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct BlankLinesBetweenHeaderAndContent { name: String, } @@ -1428,7 +1429,7 @@ impl AlwaysFixableViolation for BlankLinesBetweenHeaderAndContent { /// - [NumPy docstring standard](https://numpydoc.readthedocs.io/en/latest/format.html) /// - [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html#383-functions-and-methods) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.3")] +#[violation_metadata(preview_since = "0.15.3", category = Category::Pedantic)] pub(crate) struct IncorrectSectionOrder { current: String, previous: String, diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/starts_with_this.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/starts_with_this.rs index 9d4617396b..c978f5b8fa 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/starts_with_this.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/starts_with_this.rs @@ -3,6 +3,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::rules::pydocstyle::helpers::normalize_word; @@ -40,7 +41,7 @@ use crate::rules::pydocstyle::helpers::normalize_word; /// /// [PEP 257]: https://peps.python.org/pep-0257/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.71")] +#[violation_metadata(stable_since = "v0.0.71", category = Category::Pedantic)] pub(crate) struct DocstringStartsWithThis; impl Violation for DocstringStartsWithThis { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/triple_quotes.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/triple_quotes.rs index 9c916bc8a5..4ba21e8ea5 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/triple_quotes.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/triple_quotes.rs @@ -3,6 +3,7 @@ use ruff_python_ast::str::Quote; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::docstrings::Docstring; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -47,7 +48,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [formatter]: https://docs.astral.sh/ruff/formatter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.69")] +#[violation_metadata(stable_since = "v0.0.69", category = Category::Formatting)] pub(crate) struct TripleSingleQuotes { expected_quote: Quote, } diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D202_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-after-function_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D202_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-after-function_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D202_D202.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-after-function_D202.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D202_D202.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-after-function_D202.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D211_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-before-class_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D211_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-before-class_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D201_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-before-function_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D201_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-line-before-function_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D412_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-lines-between-header-and-content_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D412_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-lines-between-header-and-content_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D412_sphinx.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-lines-between-header-and-content_sphinx.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D412_sphinx.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__blank-lines-between-header-and-content_sphinx.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D404_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__docstring-starts-with-this_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D404_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__docstring-starts-with-this_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100_D100.ipynb.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__docstring-tab-indentation_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100_D100.ipynb.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__docstring-tab-indentation_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D414_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__empty-docstring-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D414_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__empty-docstring-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D419_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__empty-docstring_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D419_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__empty-docstring_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__escape-sequence-in-docstring_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__escape-sequence-in-docstring_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__escape-sequence-in-docstring_D301.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D301_D301.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__escape-sequence-in-docstring_D301.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated___no_pkg_priv.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__first-word-uncapitalized_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated___no_pkg_priv.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__first-word-uncapitalized_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D403_D403.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__first-word-uncapitalized_D403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D403_D403.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__first-word-uncapitalized_D403.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D204_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__incorrect-blank-line-after-class_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D204_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__incorrect-blank-line-after-class_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D203_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__incorrect-blank-line-before-class_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D203_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__incorrect-blank-line-before-class_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__mismatched-section-underline-length_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D409_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__mismatched-section-underline-length_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_D413.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_D413.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_D413.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_D413.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_sphinx_directive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D413_sphinx_directive.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-last-section_sphinx_directive.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D205_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-summary_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D205_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-blank-line-after-summary_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-dashed-underline-after-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D407_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-dashed-underline-after-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D406_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-new-line-after-section-name_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D406_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-new-line-after-section-name_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated__pkg___priv__no_D100_priv.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-section-name-colon_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated__pkg___priv__no_D100_priv.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-section-name-colon_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-section-underline-after-name_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D408_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-section-underline-after-name_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D415_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-terminal-punctuation_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D415_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-terminal-punctuation_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D415_D400_415.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-terminal-punctuation_D400_415.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D415_D400_415.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-terminal-punctuation_D400_415.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D400.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D400.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D400.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D400.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D400_415.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D400_415.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D400_D400_415.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__missing-trailing-period_D400_415.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D212_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__multi-line-summary-first-line_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D212_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__multi-line-summary-first-line_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D213_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__multi-line-summary-second-line_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D213_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__multi-line-summary-second-line_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D209_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__new-line-after-last-paragraph_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D209_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__new-line-after-last-paragraph_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D410_D410.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-after-section_D410.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D410_D410.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-after-section_D410.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D410_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-after-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D410_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-after-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D411_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-before-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D411_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__no-blank-line-before-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-capitalized-section-name_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-capitalized-section-name_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D104_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-capitalized-section-name_sphinx_directive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D104_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-capitalized-section-name_sphinx_directive.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D401_D401.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-imperative-mood_D401.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D401_D401.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__non-imperative-mood_D401.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D208_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__over-indentation_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D208_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__over-indentation_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D208_D208.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__over-indentation_D208.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D208_D208.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__over-indentation_D208.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section-underline_D215.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_D215.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section-underline_D215.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section-underline_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D215_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section-underline_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_D214_module.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_D214_module.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_D214_module.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_D214_module.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D106_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_sphinx_directive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D106_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overindented-section_sphinx_directive.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D418_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overload-with-docstring_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D418_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overload-with-docstring_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D206_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overload-with-docstring_D418.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D206_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__overload-with-docstring_D418.pyi.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D421_D421.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__property-docstring-starts-with-verb_D421.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D421_D421.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__property-docstring-starts-with-verb_D421.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D402_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__signature-in-docstring_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D402_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__signature-in-docstring_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D402_D402.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__signature-in-docstring_D402.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D402_D402.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__signature-in-docstring_D402.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D210_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__surrounding-whitespace_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D210_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__surrounding-whitespace_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D300_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__triple-single-quotes_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D300_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__triple-single-quotes_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D300_D300.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__triple-single-quotes_D300.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D300_D300.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__triple-single-quotes_D300.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D207_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__under-indentation_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D207_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__under-indentation_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D105_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-magic-method_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D105_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-magic-method_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_canonical_google_examples.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D214_sphinx_directive.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_canonical_google_examples.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D403_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_canonical_numpy_examples.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D403_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_canonical_numpy_examples.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_sections.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_sections.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_sections.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-param_sections.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D101_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-class_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D101_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-class_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D103_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-function_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D103_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-function_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D107_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-init_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D107_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-init_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D102_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-method_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D102_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-method_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D102_setter.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-method_setter.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D102_setter.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-method_setter.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module_D100.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D405_sphinx_directive.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module_D100.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D416_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated___no_pkg_priv.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D416_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated___no_pkg_priv.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated__pkg__D100_pub.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated__pkg__D100_pub.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D100__unrelated__pkg__D100_pub.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated__pkg__D100_pub.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_canonical_google_examples.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated__pkg___priv__no_D100_priv.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_canonical_google_examples.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-module__unrelated__pkg___priv__no_D100_priv.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_canonical_numpy_examples.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-nested-class_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D417_canonical_numpy_examples.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-nested-class_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D418_D418.pyi.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-package_D.py.snap similarity index 98% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D418_D418.pyi.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-package_D.py.snap index d08596ffd3..724d6e7d20 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D418_D418.pyi.snap +++ b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-package_D.py.snap @@ -1,3 +1,4 @@ --- source: crates/ruff_linter/src/rules/pydocstyle/mod.rs --- + diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D104_D104____init__.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-package_D104____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D104_D104____init__.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__undocumented-public-package_D104____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D200_D.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__unnecessary-multiline-docstring_D.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D200_D.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__unnecessary-multiline-docstring_D.py.snap diff --git a/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D200_D200.py.snap b/crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__unnecessary-multiline-docstring_D200.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__D200_D200.py.snap rename to crates/ruff_linter/src/rules/pydocstyle/snapshots/ruff_linter__rules__pydocstyle__tests__unnecessary-multiline-docstring_D200.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/fixes.rs b/crates/ruff_linter/src/rules/pyflakes/fixes.rs index 24590ea5b6..e6d1c615f4 100644 --- a/crates/ruff_linter/src/rules/pyflakes/fixes.rs +++ b/crates/ruff_linter/src/rules/pyflakes/fixes.rs @@ -1,3 +1,5 @@ +use std::debug_assert_matches; + use anyhow::{Context, Ok, Result}; use ruff_python_ast as ast; @@ -125,7 +127,7 @@ pub(crate) fn remove_exception_handler_assignment( let preceding = tokenizer .next() .context("expected the exception name to be preceded by `as`")?; - debug_assert!(matches!(preceding.kind, SimpleTokenKind::As)); + debug_assert_matches!(preceding.kind, SimpleTokenKind::As); // Lex to the end of the preceding token, which should be the exception value. let preceding = tokenizer @@ -137,7 +139,7 @@ pub(crate) fn remove_exception_handler_assignment( .skip_trivia() .next() .context("expected the exception name to be followed by a colon")?; - debug_assert!(matches!(following.kind, SimpleTokenKind::Colon)); + debug_assert_matches!(following.kind, SimpleTokenKind::Colon); Ok(Edit::deletion(preceding.end(), following.start())) } diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index c609686038..6cda6ca437 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -193,7 +193,7 @@ mod tests { #[test_case(Rule::UnusedAnnotation, Path::new("F842.py"))] #[test_case(Rule::RaiseNotImplemented, Path::new("F901.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pyflakes").join(path).as_path(), &LinterSettings::for_rule(rule_code), @@ -207,7 +207,7 @@ mod tests { rule_code: Rule, path: &Path, ) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pyflakes").join(path).as_path(), &LinterSettings { @@ -267,11 +267,7 @@ mod tests { #[test_case(Rule::UndefinedExport, Path::new("__init__.py"))] #[test_case(Rule::RedefinedWhileUnused, Path::new("F811_36.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pyflakes").join(path).as_path(), &LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -334,11 +330,7 @@ mod tests { // Regression test for https://github.com/astral-sh/ruff/issues/12897 #[test_case(Rule::UnusedImport, Path::new("F401_33/__init__.py"))] fn f401_preview_local_init_import(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let settings = LinterSettings { preview: PreviewMode::Enabled, isort: isort::settings::Settings { @@ -367,11 +359,7 @@ mod tests { #[test_case(Rule::UnusedImport, Path::new("F401_28__all_multiple/__init__.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_29__all_conditional/__init__.py"))] fn f401_stable(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "{}_stable_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("{}_stable_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pyflakes").join(path).as_path(), &LinterSettings::for_rule(rule_code), @@ -390,7 +378,7 @@ mod tests { fn f401_deprecated_option(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "{}_deprecated_option_{}", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/assert_tuple.rs b/crates/ruff_linter/src/rules/pyflakes/rules/assert_tuple.rs index 1884a2e6cf..328634803f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/assert_tuple.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/assert_tuple.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `assert` statements that use non-empty tuples as test @@ -28,7 +29,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `assert` statement](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Correctness)] pub(crate) struct AssertTuple; impl Violation for AssertTuple { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs b/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs index 834d63f730..42a83759e7 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for `break` statements outside of loops. @@ -18,7 +19,7 @@ use crate::Violation; /// ## References /// - [Python documentation: `break`](https://docs.python.org/3/reference/simple_stmts.html#the-break-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.36")] +#[violation_metadata(stable_since = "v0.0.36", category = Category::Correctness)] pub(crate) struct BreakOutsideLoop; impl Violation for BreakOutsideLoop { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs b/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs index 4e775f3ccd..d3df598142 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for `continue` statements outside of loops. @@ -18,7 +19,7 @@ use crate::Violation; /// ## References /// - [Python documentation: `continue`](https://docs.python.org/3/reference/simple_stmts.html#the-continue-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.36")] +#[violation_metadata(stable_since = "v0.0.36", category = Category::Correctness)] pub(crate) struct ContinueOutsideLoop; impl Violation for ContinueOutsideLoop { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/default_except_not_last.rs b/crates/ruff_linter/src/rules/pyflakes/rules/default_except_not_last.rs index 0cfa0a1950..66ab01592f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/default_except_not_last.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/default_except_not_last.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, ExceptHandler}; use crate::Locator; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `except` blocks that handle all exceptions, but are not the last @@ -45,7 +46,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `except` clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.28")] +#[violation_metadata(stable_since = "v0.0.28", category = Category::Correctness)] pub(crate) struct DefaultExceptNotLast; impl Violation for DefaultExceptNotLast { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/f_string_missing_placeholders.rs b/crates/ruff_linter/src/rules/pyflakes/rules/f_string_missing_placeholders.rs index 3538e596e1..e2a62046d8 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/f_string_missing_placeholders.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/f_string_missing_placeholders.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -54,7 +55,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [PEP 498 – Literal String Interpolation](https://peps.python.org/pep-0498/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Complexity)] pub(crate) struct FStringMissingPlaceholders; impl AlwaysFixableViolation for FStringMissingPlaceholders { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/forward_annotation_syntax_error.rs b/crates/ruff_linter/src/rules/pyflakes/rules/forward_annotation_syntax_error.rs index d552c3f3d9..7003c742ec 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/forward_annotation_syntax_error.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/forward_annotation_syntax_error.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for forward annotations that include invalid syntax. @@ -24,7 +25,7 @@ use crate::Violation; /// ## References /// - [PEP 563 – Postponed Evaluation of Annotations](https://peps.python.org/pep-0563/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.39")] +#[violation_metadata(stable_since = "v0.0.39", category = Category::Pedantic)] pub(crate) struct ForwardAnnotationSyntaxError { pub parse_error: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/future_feature_not_defined.rs b/crates/ruff_linter/src/rules/pyflakes/rules/future_feature_not_defined.rs index 59e2bf79a1..560f39c867 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/future_feature_not_defined.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/future_feature_not_defined.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for `__future__` imports that are not defined in the current Python @@ -13,7 +14,7 @@ use crate::Violation; /// ## References /// - [Python documentation: `__future__`](https://docs.python.org/3/library/__future__.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.34")] +#[violation_metadata(stable_since = "v0.0.34", category = Category::Correctness)] pub(crate) struct FutureFeatureNotDefined { pub name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/if_tuple.rs b/crates/ruff_linter/src/rules/pyflakes/rules/if_tuple.rs index 3ca91730d2..c2299c2e27 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/if_tuple.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/if_tuple.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `if` statements that use non-empty tuples as test conditions. @@ -29,7 +30,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `if` statement](https://docs.python.org/3/reference/compound_stmts.html#the-if-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Correctness)] pub(crate) struct IfTuple; impl Violation for IfTuple { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/imports.rs b/crates/ruff_linter/src/rules/pyflakes/rules/imports.rs index 19f2efeb3c..0350350a3e 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/imports.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/imports.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for import bindings that are shadowed by loop variables. @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// print(filename) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.44")] +#[violation_metadata(stable_since = "v0.0.44", category = Category::Suspicious)] pub(crate) struct ImportShadowedByLoopVar { name: String, row: SourceRow, @@ -118,7 +119,7 @@ pub(crate) fn import_shadowed_by_loop_var(checker: &Checker, scope_id: ScopeId, /// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Restriction)] pub(crate) struct UndefinedLocalWithImportStar { pub(crate) name: String, } @@ -157,7 +158,7 @@ impl Violation for UndefinedLocalWithImportStar { /// ## References /// - [Python documentation: Future statements](https://docs.python.org/3/reference/simple_stmts.html#future) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.34")] +#[violation_metadata(stable_since = "v0.0.34", category = Category::Correctness)] pub(crate) struct LateFutureImport; impl Violation for LateFutureImport { @@ -202,7 +203,7 @@ impl Violation for LateFutureImport { /// return pi * radius**2 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.44")] +#[violation_metadata(stable_since = "v0.0.44", category = Category::Restriction)] pub(crate) struct UndefinedLocalWithImportStarUsage { pub(crate) name: String, } @@ -244,7 +245,7 @@ impl Violation for UndefinedLocalWithImportStarUsage { /// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.37")] +#[violation_metadata(stable_since = "v0.0.37", category = Category::Correctness)] pub(crate) struct UndefinedLocalWithNestedImportStarUsage { pub(crate) name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs index 04acf6ef01..6a8b9b164e 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{CmpOp, Expr}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -51,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: Value comparisons](https://docs.python.org/3/reference/expressions.html#value-comparisons) /// - [_Why does Python log a SyntaxWarning for ‘is’ with literals?_ by Adam Johnson](https://adamj.eu/tech/2020/01/21/why-does-python-3-8-syntaxwarning-for-is-literal/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.39")] +#[violation_metadata(stable_since = "v0.0.39", category = Category::Suspicious)] pub(crate) struct IsLiteral { cmp_op: IsCmpOp, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_print_syntax.rs b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_print_syntax.rs index 72ccffb266..1046eda3bd 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_print_syntax.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_print_syntax.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `print` statements that use the `>>` syntax. @@ -47,7 +48,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `print`](https://docs.python.org/3/library/functions.html#print) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.39")] +#[violation_metadata(stable_since = "v0.0.39", category = Category::Correctness)] pub(crate) struct InvalidPrintSyntax; impl Violation for InvalidPrintSyntax { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/raise_not_implemented.rs b/crates/ruff_linter/src/rules/pyflakes/rules/raise_not_implemented.rs index b7bcb44413..16541dfbf7 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/raise_not_implemented.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/raise_not_implemented.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -35,7 +36,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `NotImplemented`](https://docs.python.org/3/library/constants.html#NotImplemented) /// - [Python documentation: `NotImplementedError`](https://docs.python.org/3/library/exceptions.html#NotImplementedError) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Correctness)] pub(crate) struct RaiseNotImplemented; impl Violation for RaiseNotImplemented { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs b/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs index bd67c26bb7..e70d65cc88 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs @@ -10,6 +10,7 @@ use ruff_source_file::SourceRow; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::preview::{ is_annotated_assignment_redefinition_enabled, is_f811_shadowing_in_type_checking_enabled, @@ -70,7 +71,7 @@ use crate::{Fix, FixAvailability, Violation}; /// /// - `lint.dummy-variable-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.171")] +#[violation_metadata(stable_since = "v0.0.171", category = Category::Suspicious)] pub(crate) struct RedefinedWhileUnused { pub name: String, pub row: SourceRow, diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/repeated_keys.rs b/crates/ruff_linter/src/rules/pyflakes/rules/repeated_keys.rs index f3427d9646..80d5a89103 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/repeated_keys.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/repeated_keys.rs @@ -8,6 +8,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::registry::Rule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -49,7 +50,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Dictionaries](https://docs.python.org/3/tutorial/datastructures.html#dictionaries) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.30")] +#[violation_metadata(stable_since = "v0.0.30", category = Category::Correctness)] pub(crate) struct MultiValueRepeatedKeyLiteral { name: SourceCodeSnippet, existing: SourceCodeSnippet, @@ -122,7 +123,7 @@ impl Violation for MultiValueRepeatedKeyLiteral { /// ## References /// - [Python documentation: Dictionaries](https://docs.python.org/3/tutorial/datastructures.html#dictionaries) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.30")] +#[violation_metadata(stable_since = "v0.0.30", category = Category::Correctness)] pub(crate) struct MultiValueRepeatedKeyVariable { name: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/return_outside_function.rs b/crates/ruff_linter/src/rules/pyflakes/rules/return_outside_function.rs index dc67da1e58..04f173d277 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/return_outside_function.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/return_outside_function.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for `return` statements outside of functions. @@ -18,7 +19,7 @@ use crate::Violation; /// ## References /// - [Python documentation: `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Correctness)] pub(crate) struct ReturnOutsideFunction; impl Violation for ReturnOutsideFunction { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/starred_expressions.rs b/crates/ruff_linter/src/rules/pyflakes/rules/starred_expressions.rs index 70f25fb689..912c48ece5 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/starred_expressions.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/starred_expressions.rs @@ -3,6 +3,7 @@ use ruff_text_size::TextRange; use ruff_macros::{ViolationMetadata, derive_message_formats}; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -18,7 +19,7 @@ use crate::{Violation, checkers::ast::Checker}; /// ## References /// - [PEP 3132 – Extended Iterable Unpacking](https://peps.python.org/pep-3132/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.32")] +#[violation_metadata(stable_since = "v0.0.32", category = Category::Correctness)] pub(crate) struct ExpressionsInStarAssignment; impl Violation for ExpressionsInStarAssignment { @@ -45,7 +46,7 @@ impl Violation for ExpressionsInStarAssignment { /// ## References /// - [PEP 3132 – Extended Iterable Unpacking](https://peps.python.org/pep-3132/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.32")] +#[violation_metadata(stable_since = "v0.0.32", category = Category::Correctness)] pub(crate) struct MultipleStarredExpressions; impl Violation for MultipleStarredExpressions { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs index f9a6472fb9..aa031f4381 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs @@ -11,6 +11,7 @@ use ruff_python_ast::{self as ast, Expr, Keyword}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Fix, FixAvailability, Violation}; use crate::rules::pyflakes::cformat::CFormatSummary; @@ -40,7 +41,7 @@ use crate::rules::pyflakes::format::FormatSummary; /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatInvalidFormat { pub(crate) message: String, } @@ -80,7 +81,7 @@ impl Violation for PercentFormatInvalidFormat { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatExpectedMapping; impl Violation for PercentFormatExpectedMapping { @@ -117,7 +118,7 @@ impl Violation for PercentFormatExpectedMapping { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatExpectedSequence; impl Violation for PercentFormatExpectedSequence { @@ -157,7 +158,7 @@ impl Violation for PercentFormatExpectedSequence { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatExtraNamedArguments { missing: Vec, } @@ -198,7 +199,7 @@ impl AlwaysFixableViolation for PercentFormatExtraNamedArguments { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatMissingArgument { missing: Vec, } @@ -239,7 +240,7 @@ impl Violation for PercentFormatMissingArgument { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatMixedPositionalAndNamed; impl Violation for PercentFormatMixedPositionalAndNamed { @@ -270,7 +271,7 @@ impl Violation for PercentFormatMixedPositionalAndNamed { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatPositionalCountMismatch { wanted: usize, got: usize, @@ -309,7 +310,7 @@ impl Violation for PercentFormatPositionalCountMismatch { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatStarRequiresSequence; impl Violation for PercentFormatStarRequiresSequence { @@ -340,7 +341,7 @@ impl Violation for PercentFormatStarRequiresSequence { /// ## References /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.142")] +#[violation_metadata(stable_since = "v0.0.142", category = Category::Correctness)] pub(crate) struct PercentFormatUnsupportedFormatCharacter { pub(crate) char: char, } @@ -372,7 +373,7 @@ impl Violation for PercentFormatUnsupportedFormatCharacter { /// ## References /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.138")] +#[violation_metadata(stable_since = "v0.0.138", category = Category::Correctness)] pub(crate) struct StringDotFormatInvalidFormat { pub(crate) message: String, } @@ -415,7 +416,7 @@ impl Violation for StringDotFormatInvalidFormat { /// ## References /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.139")] +#[violation_metadata(stable_since = "v0.0.139", category = Category::Correctness)] pub(crate) struct StringDotFormatExtraNamedArguments { missing: Vec, } @@ -467,7 +468,7 @@ impl Violation for StringDotFormatExtraNamedArguments { /// ## References /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.139")] +#[violation_metadata(stable_since = "v0.0.139", category = Category::Correctness)] pub(crate) struct StringDotFormatExtraPositionalArguments { missing: Vec, } @@ -511,7 +512,7 @@ impl Violation for StringDotFormatExtraPositionalArguments { /// ## References /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.139")] +#[violation_metadata(stable_since = "v0.0.139", category = Category::Correctness)] pub(crate) struct StringDotFormatMissingArguments { missing: Vec, } @@ -550,7 +551,7 @@ impl Violation for StringDotFormatMissingArguments { /// ## References /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.139")] +#[violation_metadata(stable_since = "v0.0.139", category = Category::Correctness)] pub(crate) struct StringDotFormatMixingAutomatic; impl Violation for StringDotFormatMixingAutomatic { diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_export.rs b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_export.rs index 1a1648f92c..e70b2ce2ba 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_export.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_export.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for undefined names in `__all__`. @@ -40,7 +41,7 @@ use crate::Violation; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.25")] +#[violation_metadata(stable_since = "v0.0.25", category = Category::Suspicious)] pub(crate) struct UndefinedExport { pub name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_local.rs b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_local.rs index a46205a72a..fab954b8a1 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_local.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_local.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for undefined local variables. @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// x += 1 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.24")] +#[violation_metadata(stable_since = "v0.0.24", category = Category::Correctness)] pub(crate) struct UndefinedLocal { name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_name.rs b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_name.rs index 314488cb21..35c94473f8 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/undefined_name.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/undefined_name.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for uses of undefined names. @@ -27,7 +28,7 @@ use crate::Violation; /// ## References /// - [Python documentation: Naming and binding](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.20")] +#[violation_metadata(stable_since = "v0.0.20", category = Category::Correctness)] pub(crate) struct UndefinedName { pub(crate) name: String, pub(crate) minor_version_builtin_added: Option, diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_annotation.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_annotation.rs index f432a01ba4..6715f0330f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_annotation.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_annotation.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for local variables that are annotated but never used. @@ -27,7 +28,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [PEP 484 – Type Hints](https://peps.python.org/pep-0484/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.172")] +#[violation_metadata(stable_since = "v0.0.172", category = Category::Correctness)] pub(crate) struct UnusedAnnotation { name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 45fa7f984e..cd03b14cc2 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -14,6 +14,7 @@ use ruff_python_semantic::{ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::preview::{ is_dunder_init_fix_unused_import_enabled, is_refined_submodule_import_match_enabled, @@ -142,7 +143,7 @@ use crate::{Applicability, Fix, FixAvailability, Violation}; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.18")] +#[violation_metadata(stable_since = "v0.0.18", category = Category::Suspicious)] pub(crate) struct UnusedImport { /// Qualified name of the import name: String, diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_variable.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_variable.rs index 59dcbf5c22..65fa8c078f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_variable.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_variable.rs @@ -9,6 +9,7 @@ use ruff_python_semantic::Binding; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::delete_stmt; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -53,7 +54,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [RUF059]: https://docs.astral.sh/ruff/rules/unused-unpacked-variable/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.22")] +#[violation_metadata(stable_since = "v0.0.22", category = Category::Correctness)] pub(crate) struct UnusedVariable { pub name: String, } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/yield_outside_function.rs b/crates/ruff_linter/src/rules/pyflakes/rules/yield_outside_function.rs index 38be7d1167..e6f4f879f1 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/yield_outside_function.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/yield_outside_function.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_parser::semantic_errors::YieldOutsideFunctionKind; use crate::Violation; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq)] pub(crate) enum DeferralKeyword { @@ -54,7 +55,7 @@ impl From for DeferralKeyword { /// /// [autoawait]: https://ipython.readthedocs.io/en/stable/interactive/autoawait.html #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.22")] +#[violation_metadata(stable_since = "v0.0.22", category = Category::Correctness)] pub(crate) struct YieldOutsideFunction { keyword: DeferralKeyword, } diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F631_F631.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__assert-tuple_F631.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F631_F631.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__assert-tuple_F631.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F701_F701.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__break-outside-loop_F701.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F701_F701.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__break-outside-loop_F701.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F702_F702.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__continue-outside-loop_F702.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F702_F702.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__continue-outside-loop_F702.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F707_F707.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__default-except-not-last_F707.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F707_F707.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__default-except-not-last_F707.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F541_F541.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f-string-missing-placeholders_F541.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F541_F541.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f-string-missing-placeholders_F541.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__forward-annotation-syntax-error_F722.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__forward-annotation-syntax-error_F722.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__forward-annotation-syntax-error_F722_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__forward-annotation-syntax-error_F722_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F407_F407.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__future-feature-not-defined_F407.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F407_F407.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__future-feature-not-defined_F407.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F634_F634.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__if-tuple_F634.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F634_F634.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__if-tuple_F634.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F634_F634_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__if-tuple_F634_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F634_F634_basedpython.by.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__if-tuple_F634_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F402_F402.ipynb.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__import-shadowed-by-loop-var_F402.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F402_F402.ipynb.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__import-shadowed-by-loop-var_F402.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F402_F402.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__import-shadowed-by-loop-var_F402.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F402_F402.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__import-shadowed-by-loop-var_F402.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F633_F633.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__invalid-print-syntax_F633.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F633_F633.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__invalid-print-syntax_F633.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F632_F632.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__is-literal_F632.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F632_F632.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__is-literal_F632.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F404_F404_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__late-future-import_F404_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F404_F404_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__late-future-import_F404_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F404_F404_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__late-future-import_F404_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F404_F404_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__late-future-import_F404_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F601_F601.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multi-value-repeated-key-literal_F601.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F601_F601.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multi-value-repeated-key-literal_F601.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F602_F602.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multi-value-repeated-key-variable_F602.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F602_F602.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multi-value-repeated-key-variable_F602.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F622_F622.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multiple-starred-expressions_F622.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F622_F622.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__multiple-starred-expressions_F622.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F502_F502.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-mapping_F502.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F502_F502.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-mapping_F502.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F502_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-mapping_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F502_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-mapping_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F503_F503.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-sequence_F503.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F503_F503.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-sequence_F503.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F503_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-sequence_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F503_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-expected-sequence_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F504_F504.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-extra-named-arguments_F504.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F504_F504.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-extra-named-arguments_F504.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F504_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-extra-named-arguments_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F504_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-extra-named-arguments_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F501_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-invalid-format_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F501_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-invalid-format_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-missing-argument_F504.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-missing-argument_F504.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F505_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-missing-argument_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F505_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-missing-argument_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F506_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-mixed-positional-and-named_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F506_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-mixed-positional-and-named_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-positional-count-mismatch_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-positional-count-mismatch_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F508_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-star-requires-sequence_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F508_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-star-requires-sequence_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F509_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-unsupported-format-character_F50x.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F509_F50x.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__percent-format-unsupported-format-character_F50x.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F811_F811_36.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__redefined-while-unused_F811_36.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F811_F811_36.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__redefined-while-unused_F811_36.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F822___init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__undefined-export___init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F822___init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__undefined-export___init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_24____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_24____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_24____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_24____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_25__all_nonempty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_25__all_nonempty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_25__all_nonempty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_25__all_nonempty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_26__all_empty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_26__all_empty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_26__all_empty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_26__all_empty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_27__all_mistyped____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_27__all_mistyped____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_27__all_mistyped____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_27__all_mistyped____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_28__all_multiple____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_28__all_multiple____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_28__all_multiple____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_28__all_multiple____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_29__all_conditional____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_29__all_conditional____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_29__all_conditional____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_29__all_conditional____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_33____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_33____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_33____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import_F401_33____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401___init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import___init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401___init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__unused-import___init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F901_F901.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__raise-not-implemented_F901.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F901_F901.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__raise-not-implemented_F901.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_12.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_10.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_12.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_10.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_13.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_13.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_11.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_12.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_12.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_12.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_12.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_14.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_13.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_14.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_13.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_16.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_14.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_16.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_14.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_15.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_15.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_15.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_15.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_16.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_16.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_16.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_16.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_17.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_17.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_17.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_17.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_19.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_18.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_19.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_18.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_19.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_2.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_19.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_2.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_2.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_20.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_20.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_20.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_21.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_21.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_21.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_21.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_21.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_21.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_22.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_23.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_23.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_23.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_23.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_22.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_24.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_22.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_24.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_25.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_3.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_25.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_26.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_26.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_26.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_32.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_32.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_27.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_28.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_28.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_28.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_28.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_29.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_29.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_29.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_29.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_3.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_3.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_30.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_30.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_30.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_31.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_31.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_31.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_31.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_32.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_32.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_32.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_32.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_4.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_33.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_4.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_33.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_35.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_35.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_35.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_35.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_8.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_36.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_8.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_36.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_4.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_4.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_4.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_5.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_5.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_5.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_6.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_6.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_6.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F505_F504.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F505_F504.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_7.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_8.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_8.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_8.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_8.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_10.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_10.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_9.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_basedpython.by.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__redefined-while-unused_F811_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F706_F706.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__return-outside-function_F706.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F706_F706.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__return-outside-function_F706.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F522_F522.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-extra-named-arguments_F522.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F522_F522.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-extra-named-arguments_F522.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F523_F523.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-extra-positional-arguments_F523.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F523_F523.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-extra-positional-arguments_F523.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-invalid-format_F521.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-invalid-format_F521.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F524_F524.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-missing-arguments_F524.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F524_F524.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-missing-arguments_F524.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F525_F525.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-mixing-automatic_F525.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F525_F525.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__string-dot-format-mixing-automatic_F525.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_0.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_0.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_1b.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_1b.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_1b.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_1b.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_11.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_11.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_2.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_3.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-export_F822_3.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F405_F405.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-import-star-usage_F405.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F405_F405.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-import-star-usage_F405.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F403_F403.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-import-star_F403.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F403_F403.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-import-star_F403.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F406_F406.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-nested-import-star-usage_F406.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F406_F406.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local-with-nested-import-star-usage_F406.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F823_F823.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local_F823.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F823_F823.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-local_F823.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_13.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_10.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_13.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_10.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_11.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_11.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_11.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_12.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_12.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_12.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_12.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_13.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_13.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_13.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_13.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_14.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_14.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_14.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_14.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_18.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_15.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_18.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_15.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_19.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_16.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_19.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_16.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_17.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_17.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_17.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_17.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_18.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_18.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_18.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_18.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_19.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_19.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_19.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_19.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_20.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_20.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_2.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_20.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_20.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_20.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_21.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_21.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_21.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_21.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_22.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_22.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_22.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_22.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_24.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_23.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_24.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_23.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_25.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_24.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_25.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_24.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_27.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_25.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_27.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_25.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_26.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_26.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_33.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_26.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_33.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_26.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_27.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_27.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_27.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_27.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_28.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_28.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_28.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_28.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_36.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_29.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_36.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_29.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_3.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_3.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_30.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_30.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_30.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_31.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_31.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_31.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_31.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_7.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_32.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_7.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_32.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_33.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_33.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_33.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_33.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_34.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_34.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_34.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_34.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_34.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_34.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_34.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_34.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_4.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_4.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_4.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_5.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_5.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_5.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_9.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F811_F811_9.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_6.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_7.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_7.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_7.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_10.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_8.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_10.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_8.pyi.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_9.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_9.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_9.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_basedpython.by.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__undefined-name_F821_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F842_F842.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-annotation_F842.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F842_F842.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-annotation_F842.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_14.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_14.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_10.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_10.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_10.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_10.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_11.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_11.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_11.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_15.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_12.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_15.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_12.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_16.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_13.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_16.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_13.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_14.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_2.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_14.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_15.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_15.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_15.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_15.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_22.ipynb.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_16.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_22.ipynb.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_16.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_17.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_17.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_17.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_17.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_18.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_18.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_18.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_18.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_23.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_19.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_23.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_19.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_24.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_24.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_2.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_25.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_20.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_25.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_20.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_21.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_21.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_29.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_22.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_29.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_22.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_23.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_23.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_23.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_23.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_32.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_32.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_3.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_6.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_32.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_6.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_32.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_34.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_34.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_8.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_8.pyi.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_4.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_5.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_5.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_5.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_6.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_6.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_6.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_7.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_7.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_7.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_8.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_2.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_8.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_9.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_9.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_9.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_basedpython.by.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_F401_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_24____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_24____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_24____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_24____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_25__all_nonempty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_25__all_nonempty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_25__all_nonempty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_25__all_nonempty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_26__all_empty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_26__all_empty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_26__all_empty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_26__all_empty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_27__all_mistyped____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_27__all_mistyped____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_27__all_mistyped____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_27__all_mistyped____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_28__all_multiple____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_28__all_multiple____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_28__all_multiple____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_28__all_multiple____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_29__all_conditional____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_29__all_conditional____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_29__all_conditional____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_29__all_conditional____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_30.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_30.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_30.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_deprecated_option_F401_30.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_24____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_24____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_24____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_24____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_25__all_nonempty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_25__all_nonempty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_25__all_nonempty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_25__all_nonempty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_26__all_empty____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_26__all_empty____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_26__all_empty____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_26__all_empty____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_27__all_mistyped____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_27__all_mistyped____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_27__all_mistyped____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_27__all_mistyped____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_28__all_multiple____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_28__all_multiple____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_stable_F401_28__all_multiple____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_28__all_multiple____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_29__all_conditional____init__.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_29__all_conditional____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview__F401_F401_29__all_conditional____init__.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-import_stable_F401_29__all_conditional____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_0.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_0.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_1.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_1.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_2.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_2.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_3.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_3.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F841_F841_basedpython.by.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__yield-outside-function_F704.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap rename to crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__yield-outside-function_F704.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs b/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs index 8b4e7e438c..45c80e01c3 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs @@ -22,7 +22,7 @@ mod tests { #[test_case(Rule::BlanketNOQA, Path::new("PGH004_3.py"))] #[test_case(Rule::InvalidMockAccess, Path::new("PGH005_0.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pygrep_hooks").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -33,11 +33,7 @@ mod tests { #[test_case(Rule::InvalidMockAccess, Path::new("PGH005_0.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_noqa.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_noqa.rs index 1c5bdf4339..a55cf82e33 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_noqa.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_noqa.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::noqa::{self, Directive, FileNoqaDirectives, NoqaDirectives}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -39,7 +40,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Ruff documentation](https://docs.astral.sh/ruff/configuration/#error-suppression) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.200")] +#[violation_metadata(stable_since = "v0.0.200", category = Category::Pedantic)] pub(crate) struct BlanketNOQA { missing_colon: bool, file_exemption: bool, diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs index 1386ca0f61..4fafa361f7 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs @@ -11,6 +11,7 @@ use ruff_text_size::TextSize; use crate::Locator; use crate::Violation; use crate::checkers::ast::LintContext; +use crate::codes::Category; /// ## What it does /// Check for `type: ignore` annotations that suppress all type warnings, as @@ -42,7 +43,7 @@ use crate::checkers::ast::LintContext; /// enable_error_code = ["ignore-without-code"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.187")] +#[violation_metadata(stable_since = "v0.0.187", category = Category::Restriction)] pub(crate) struct BlanketTypeIgnore; impl Violation for BlanketTypeIgnore { diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/deprecated_log_warn.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/deprecated_log_warn.rs index 87319784a8..3aa28e8ebd 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/deprecated_log_warn.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/deprecated_log_warn.rs @@ -1,6 +1,6 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; -use crate::{FixAvailability, Violation}; +use crate::{FixAvailability, Violation, codes::Category}; /// ## Removed /// This rule is identical to [G010] which should be used instead. @@ -34,7 +34,7 @@ use crate::{FixAvailability, Violation}; /// /// [G010]: https://docs.astral.sh/ruff/rules/logging-warn/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.2.0")] +#[violation_metadata(removed_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct DeprecatedLogWarn; /// PGH002 diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs index afcc230966..ae95d8632a 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq)] enum Reason { @@ -33,7 +34,7 @@ enum Reason { /// my_mock.assert_called() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.266")] +#[violation_metadata(stable_since = "v0.0.266", category = Category::Suspicious)] pub(crate) struct InvalidMockAccess { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/no_eval.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/no_eval.rs index 1818aaf79a..5fa2a55b34 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/no_eval.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/no_eval.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## Removed /// This rule is identical to [S307] which should be used instead. @@ -31,7 +32,7 @@ use crate::Violation; /// /// [S307]: https://docs.astral.sh/ruff/rules/suspicious-eval-usage/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.2.0")] +#[violation_metadata(removed_since = "v0.2.0", category = Category::Security)] pub(crate) struct Eval; /// PGH001 diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_0.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_0.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_1.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_1.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_1.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_2.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_2.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_2.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_3.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH004_PGH004_3.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-noqa_PGH004_3.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH003_PGH003_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-type-ignore_PGH003_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH003_PGH003_0.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-type-ignore_PGH003_0.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH003_PGH003_1.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-type-ignore_PGH003_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH003_PGH003_1.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__blanket-type-ignore_PGH003_1.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__invalid-mock-access_PGH005_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__invalid-mock-access_PGH005_0.py.snap diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__invalid-mock-access_PGH005_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap rename to crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__invalid-mock-access_PGH005_0.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/helpers.rs b/crates/ruff_linter/src/rules/pylint/helpers.rs index e3ac9bb5e8..2ee6f0bd5b 100644 --- a/crates/ruff_linter/src/rules/pylint/helpers.rs +++ b/crates/ruff_linter/src/rules/pylint/helpers.rs @@ -1,5 +1,6 @@ use ruff_python_ast as ast; use ruff_python_ast::ExceptHandler; +use ruff_python_ast::name::QualifiedName; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::{Arguments, Expr, Stmt, visitor}; use ruff_python_semantic::analyze::function_type; @@ -8,6 +9,12 @@ use ruff_text_size::TextRange; use crate::settings::LinterSettings; +/// Returns `true` if a module member is public despite having an +/// underscore-prefixed name. +pub(crate) fn is_underscore_prefixed_public_member(qualified_name: &QualifiedName) -> bool { + matches!(qualified_name.segments(), ["os", "_exit"]) +} + /// Returns the value of the `name` parameter to, e.g., a `TypeVar` constructor. pub(super) fn type_param_name(arguments: &Arguments) -> Option<&str> { // Handle both `TypeVar("T")` and `TypeVar(name="T")`. diff --git a/crates/ruff_linter/src/rules/pylint/mod.rs b/crates/ruff_linter/src/rules/pylint/mod.rs index ed9e7e7b9a..dcfa7b50a3 100644 --- a/crates/ruff_linter/src/rules/pylint/mod.rs +++ b/crates/ruff_linter/src/rules/pylint/mod.rs @@ -244,7 +244,7 @@ mod tests { #[test_case(Rule::LenTest, Path::new("len_as_condition.py"))] #[test_case(Rule::MissingMaxsplitArg, Path::new("missing_maxsplit_arg.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("pylint").join(path).as_path(), &LinterSettings { @@ -266,11 +266,7 @@ mod tests { Path::new("useless_exception_statement.py") )] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); assert_diagnostics_diff!( snapshot, diff --git a/crates/ruff_linter/src/rules/pylint/rules/and_or_ternary.rs b/crates/ruff_linter/src/rules/pylint/rules/and_or_ternary.rs index e93e3a1c34..7b88e96efa 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/and_or_ternary.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/and_or_ternary.rs @@ -1,6 +1,7 @@ use ruff_macros::ViolationMetadata; use crate::Violation; +use crate::codes::Category; /// ## Removal /// This rule was removed from Ruff because it was common for it to introduce behavioral changes. @@ -29,7 +30,7 @@ use crate::Violation; /// maximum = x if x >= y else y /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.2.0")] +#[violation_metadata(removed_since = "v0.2.0", category = Category::Pedantic)] pub(crate) struct AndOrTernary; /// PLR1706 diff --git a/crates/ruff_linter/src/rules/pylint/rules/assert_on_string_literal.rs b/crates/ruff_linter/src/rules/pylint/rules/assert_on_string_literal.rs index 60dbad3e8c..bab5f00926 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/assert_on_string_literal.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/assert_on_string_literal.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq, Copy, Clone)] enum Kind { @@ -26,7 +27,7 @@ enum Kind { /// assert "always true" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Correctness)] pub(crate) struct AssertOnStringLiteral { kind: Kind, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/await_outside_async.rs b/crates/ruff_linter/src/rules/pylint/rules/await_outside_async.rs index 78ad99fd07..84a847ed8a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/await_outside_async.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/await_outside_async.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for uses of `await` outside `async` functions. @@ -36,7 +37,7 @@ use crate::Violation; /// /// [autoawait]: https://ipython.readthedocs.io/en/stable/interactive/autoawait.html #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.150")] +#[violation_metadata(stable_since = "v0.0.150", category = Category::Correctness)] pub(crate) struct AwaitOutsideAsync; impl Violation for AwaitOutsideAsync { diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_dunder_method_name.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_dunder_method_name.rs index a375147665..4c10cf94d8 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_dunder_method_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_dunder_method_name.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::visibility; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::is_known_dunder_method; /// ## What it does @@ -45,7 +46,7 @@ use crate::rules::pylint::helpers::is_known_dunder_method; /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.285")] +#[violation_metadata(preview_since = "v0.0.285", category = Category::Pedantic)] pub(crate) struct BadDunderMethodName { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_open_mode.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_open_mode.rs index c949b7baef..b2b81cd542 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_open_mode.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_open_mode.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Check for an invalid `mode` argument in `open` calls. @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `open`](https://docs.python.org/3/library/functions.html#open) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct BadOpenMode { mode: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_staticmethod_argument.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_staticmethod_argument.rs index 917203db0d..130f017b66 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_staticmethod_argument.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_staticmethod_argument.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for static methods that use `self` or `cls` as their first argument. @@ -42,7 +43,7 @@ use crate::checkers::ast::Checker; /// /// [PEP 8]: https://peps.python.org/pep-0008/#function-and-method-arguments #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct BadStaticmethodArgument { argument_name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_str_strip_call.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_str_strip_call.rs index 71958f972d..051031663d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_str_strip_call.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_str_strip_call.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use ruff_python_ast::PythonVersion; /// ## What it does @@ -46,9 +47,9 @@ use ruff_python_ast::PythonVersion; /// - `target-version` /// /// ## References -/// - [Python documentation: `str.strip`](https://docs.python.org/3/library/stdtypes.html?highlight=strip#str.strip) +/// - [Python documentation: `str.strip`](https://docs.python.org/3/library/stdtypes.html#str.strip) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.242")] +#[violation_metadata(stable_since = "v0.0.242", category = Category::Correctness)] pub(crate) struct BadStrStripCall { strip: StripKind, removal: Option, diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs index 6aca8f4dd0..931c9b6453 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs @@ -8,6 +8,7 @@ use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for unsupported format types in format strings. @@ -23,7 +24,7 @@ use crate::checkers::ast::Checker; /// print("{:z}".format("1")) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.283")] +#[violation_metadata(stable_since = "v0.0.283", category = Category::Correctness)] pub(crate) struct BadStringFormatCharacter { pub(crate) format_char: char, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_type.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_type.rs index d69e28ecf8..0da09d6ce4 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_type.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_type.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, Reso use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for mismatched argument types in "old-style" format strings. @@ -28,7 +29,7 @@ use crate::checkers::ast::Checker; /// print("%d" % 1) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Correctness)] pub(crate) struct BadStringFormatType; impl Violation for BadStringFormatType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/bidirectional_unicode.rs b/crates/ruff_linter/src/rules/pylint/rules/bidirectional_unicode.rs index 3e6f2eff6f..c0f05be39e 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bidirectional_unicode.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bidirectional_unicode.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_source_file::Line; +use crate::codes::Category; use crate::{Violation, checkers::ast::LintContext}; const BIDI_UNICODE: [char; 11] = [ @@ -50,7 +51,7 @@ const BIDI_UNICODE: [char; 11] = [ /// ## References /// - [PEP 672: Bidirectional Marks, Embeddings, Overrides and Isolates](https://peps.python.org/pep-0672/#bidirectional-marks-embeddings-overrides-and-isolates) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.244")] +#[violation_metadata(stable_since = "v0.0.244", category = Category::Suspicious)] pub(crate) struct BidirectionalUnicode; impl Violation for BidirectionalUnicode { diff --git a/crates/ruff_linter/src/rules/pylint/rules/binary_op_exception.rs b/crates/ruff_linter/src/rules/pylint/rules/binary_op_exception.rs index 533d44cc5a..d55cfc3716 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/binary_op_exception.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/binary_op_exception.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq, Copy, Clone)] enum BoolOp { @@ -47,7 +48,7 @@ impl From<&ast::BoolOp> for BoolOp { /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.258")] +#[violation_metadata(stable_since = "v0.0.258", category = Category::Suspicious)] pub(crate) struct BinaryOpException { op: BoolOp, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/boolean_chained_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/boolean_chained_comparison.rs index 9673524a93..6a51d68c23 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/boolean_chained_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/boolean_chained_comparison.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -35,7 +36,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Style)] pub(crate) struct BooleanChainedComparison; impl AlwaysFixableViolation for BooleanChainedComparison { diff --git a/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs b/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs index 1e68c246fb..f59a55b336 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::adjust_indentation; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `if` Statements](https://docs.python.org/3/tutorial/controlflow.html#if-statements) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Pedantic)] pub(crate) struct CollapsibleElseIf; impl Violation for CollapsibleElseIf { diff --git a/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs b/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs index f1aa962410..2615047aee 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for comparisons to empty strings. @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// /// [#4282]: https://github.com/astral-sh/ruff/issues/4282 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.255")] +#[violation_metadata(preview_since = "v0.0.255", category = Category::Pedantic)] pub(crate) struct CompareToEmptyString { existing: String, replacement: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs b/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs index 57abf6f9e3..eec0d4dd31 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for comparisons between constants. @@ -28,7 +29,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.221")] +#[violation_metadata(stable_since = "v0.0.221", category = Category::Complexity)] pub(crate) struct ComparisonOfConstant { left_constant: String, op: CmpOp, diff --git a/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs b/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs index 91d30c5699..91d469da42 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; /// ## What it does @@ -31,7 +32,7 @@ use crate::fix::snippet::SourceCodeSnippet; /// ## References /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Suspicious)] pub(crate) struct ComparisonWithItself { actual: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/continue_in_finally.rs b/crates/ruff_linter/src/rules/pylint/rules/continue_in_finally.rs index 476dae54ed..7b6a5dc064 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/continue_in_finally.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/continue_in_finally.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `continue` statements inside `finally` @@ -37,7 +38,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `target-version` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Correctness)] pub(crate) struct ContinueInFinally; impl Violation for ContinueInFinally { diff --git a/crates/ruff_linter/src/rules/pylint/rules/dict_index_missing_items.rs b/crates/ruff_linter/src/rules/pylint/rules/dict_index_missing_items.rs index 89c94a8e21..12a20b4215 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/dict_index_missing_items.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/dict_index_missing_items.rs @@ -12,6 +12,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::{Checker, DiagnosticGuard}; +use crate::codes::Category; /// ## What it does /// Checks for dictionary iterations that extract the dictionary value @@ -47,7 +48,7 @@ use crate::checkers::ast::{Checker, DiagnosticGuard}; /// print(f"{instrument}: {section}") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Complexity)] pub(crate) struct DictIndexMissingItems<'a> { key: &'a str, dict: &'a str, diff --git a/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs b/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs index 0becdaee36..c653eee2b6 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{Binding, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -51,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## Fix safety /// Due to the known problem with tuple keys, this fix is unsafe. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.0")] +#[violation_metadata(preview_since = "v0.3.0", category = Category::Suspicious)] pub(crate) struct DictIterMissingItems; impl AlwaysFixableViolation for DictIterMissingItems { diff --git a/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs b/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs index 09ce874a09..0d216983a2 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs @@ -6,6 +6,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Fix, FixAvailability, Violation}; @@ -55,7 +56,7 @@ use crate::{Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Class definitions](https://docs.python.org/3/reference/compound_stmts.html#class-definitions) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.269")] +#[violation_metadata(stable_since = "v0.0.269", category = Category::Correctness)] pub(crate) struct DuplicateBases { base: String, class: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/empty_comment.rs b/crates/ruff_linter/src/rules/pylint/rules/empty_comment.rs index 004d50e5f4..51e5bf7147 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/empty_comment.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/empty_comment.rs @@ -6,6 +6,7 @@ use ruff_text_size::{TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Pylint documentation](https://pylint.pycqa.org/en/latest/user_guide/messages/refactor/empty-comment.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Style)] pub(crate) struct EmptyComment; impl Violation for EmptyComment { diff --git a/crates/ruff_linter/src/rules/pylint/rules/eq_without_hash.rs b/crates/ruff_linter/src/rules/pylint/rules/eq_without_hash.rs index 1d539ab594..807cbcae2d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/eq_without_hash.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/eq_without_hash.rs @@ -10,6 +10,7 @@ use std::ops::BitOr; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for classes that implement `__eq__` but not `__hash__`. @@ -64,7 +65,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `object.__hash__`](https://docs.python.org/3/reference/datamodel.html#object.__hash__) /// - [Python glossary: hashable](https://docs.python.org/3/glossary.html#term-hashable) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Pedantic)] pub(crate) struct EqWithoutHash; impl Violation for EqWithoutHash { diff --git a/crates/ruff_linter/src/rules/pylint/rules/global_at_module_level.rs b/crates/ruff_linter/src/rules/pylint/rules/global_at_module_level.rs index 681bdc5a21..3ad391f0cf 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/global_at_module_level.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/global_at_module_level.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the `global` keyword at the module level. @@ -15,7 +16,7 @@ use crate::checkers::ast::Checker; /// At the module level, all names are global by default, so the `global` /// keyword is redundant. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct GlobalAtModuleLevel; impl Violation for GlobalAtModuleLevel { diff --git a/crates/ruff_linter/src/rules/pylint/rules/global_statement.rs b/crates/ruff_linter/src/rules/pylint/rules/global_statement.rs index 345357ecdd..308c7fe28b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/global_statement.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/global_statement.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of `global` statements to update identifiers. @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// print(var) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.253")] +#[violation_metadata(stable_since = "v0.0.253", category = Category::Restriction)] pub(crate) struct GlobalStatement { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/global_variable_not_assigned.rs b/crates/ruff_linter/src/rules/pylint/rules/global_variable_not_assigned.rs index 26c6312e96..eac5479db8 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/global_variable_not_assigned.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/global_variable_not_assigned.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `global` variables that are not assigned a value in the current @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.174")] +#[violation_metadata(stable_since = "v0.0.174", category = Category::Style)] pub(crate) struct GlobalVariableNotAssigned { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/if_stmt_min_max.rs b/crates/ruff_linter/src/rules/pylint/rules/if_stmt_min_max.rs index 4be9bd3f57..b99476ffb3 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/if_stmt_min_max.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/if_stmt_min_max.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, CmpOp, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `max`](https://docs.python.org/3/library/functions.html#max) /// - [Python documentation: `min`](https://docs.python.org/3/library/functions.html#min) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Complexity)] pub(crate) struct IfStmtMinMax { min_max: MinMax, replacement: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/pylint/rules/import_outside_top_level.rs b/crates/ruff_linter/src/rules/pylint/rules/import_outside_top_level.rs index d2a953c816..bb2edfaddd 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/import_outside_top_level.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/import_outside_top_level.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Stmt; use ruff_text_size::Ranged; use crate::Violation; +use crate::codes::Category; use crate::rules::flake8_tidy_imports::rules::BannedModuleImportPolicies; use crate::{ checkers::ast::Checker, codes::Rule, rules::flake8_tidy_imports::matchers::NameMatchPolicy, @@ -53,7 +54,7 @@ use crate::{ /// [TID253]: https://docs.astral.sh/ruff/rules/banned-module-level-imports/ /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Pedantic)] pub(crate) struct ImportOutsideTopLevel; impl Violation for ImportOutsideTopLevel { diff --git a/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs b/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs index 82c4da9d05..e27f18d470 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs @@ -10,7 +10,9 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::package::PackageRoot; +use crate::rules::pylint::helpers::is_underscore_prefixed_public_member; /// ## What it does /// Checks for import statements that import a private name (a name starting @@ -51,7 +53,7 @@ use crate::package::PackageRoot; /// [PEP 8]: https://peps.python.org/pep-0008/ /// [PEP 420]: https://peps.python.org/pep-0420/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.14")] +#[violation_metadata(preview_since = "v0.1.14", category = Category::Pedantic)] pub(crate) struct ImportPrivateName { name: String, module: Option, @@ -122,6 +124,10 @@ pub(crate) fn import_private_name(checker: &Checker, scope: &Scope) { continue; }; + if is_underscore_prefixed_public_member(import_info.qualified_name) { + continue; + } + // Ignore private imports used exclusively for typing. if !binding.references.is_empty() && binding diff --git a/crates/ruff_linter/src/rules/pylint/rules/import_self.rs b/crates/ruff_linter/src/rules/pylint/rules/import_self.rs index d755117246..0805cad7ce 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/import_self.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/import_self.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::helpers::resolve_imported_module_path; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -23,7 +24,7 @@ use crate::{Violation, checkers::ast::Checker}; /// def foo(): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.265")] +#[violation_metadata(stable_since = "v0.0.265", category = Category::Suspicious)] pub(crate) struct ImportSelf { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_all_format.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_all_format.rs index cd0cf491eb..9807b606b5 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_all_format.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_all_format.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_semantic::Binding; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -27,7 +28,7 @@ use crate::{Violation, checkers::ast::Checker}; /// ## References /// - [Python documentation: The `import` statement](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.237")] +#[violation_metadata(stable_since = "v0.0.237", category = Category::Correctness)] pub(crate) struct InvalidAllFormat; impl Violation for InvalidAllFormat { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_all_object.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_all_object.rs index 047d652f80..8f18726a01 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_all_object.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_all_object.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_semantic::Binding; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -27,7 +28,7 @@ use crate::{Violation, checkers::ast::Checker}; /// ## References /// - [Python documentation: The `import` statement](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.237")] +#[violation_metadata(stable_since = "v0.0.237", category = Category::Correctness)] pub(crate) struct InvalidAllObject; impl Violation for InvalidAllObject { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_bool_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_bool_return.rs index bb45dd993f..7f83765318 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_bool_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_bool_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__bool__` implementations that return a type other than `bool`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__bool__` method](https://docs.python.org/3/reference/datamodel.html#object.__bool__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Suspicious)] pub(crate) struct InvalidBoolReturnType; impl Violation for InvalidBoolReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_bytes_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_bytes_return.rs index 8040620a76..58d4a7e0f7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_bytes_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_bytes_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__bytes__` implementations that return types other than `bytes`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__bytes__` method](https://docs.python.org/3/reference/datamodel.html#object.__bytes__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct InvalidBytesReturnType; impl Violation for InvalidBytesReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_default.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_default.rs index 2d84a1b9da..600efb0e9a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_default.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_default.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `os.getenv` calls with invalid default values. @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// int(os.getenv("FOO", "1")) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.255")] +#[violation_metadata(stable_since = "v0.0.255", category = Category::Suspicious)] pub(crate) struct InvalidEnvvarDefault; impl Violation for InvalidEnvvarDefault { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_value.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_value.rs index 3733211a38..bf5627a88a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_value.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_envvar_value.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `os.getenv` calls with an invalid `key` argument. @@ -30,7 +31,7 @@ use crate::checkers::ast::Checker; /// os.getenv("1") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.255")] +#[violation_metadata(stable_since = "v0.0.255", category = Category::Correctness)] pub(crate) struct InvalidEnvvarValue; impl Violation for InvalidEnvvarValue { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_hash_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_hash_return.rs index 59e451142d..cbc879a9a1 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_hash_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_hash_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__hash__` implementations that return non-integer values. @@ -39,7 +40,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__hash__` method](https://docs.python.org/3/reference/datamodel.html#object.__hash__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct InvalidHashReturnType; impl Violation for InvalidHashReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_index_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_index_return.rs index 881823b551..096be49a57 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_index_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_index_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__index__` implementations that return non-integer values. @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__index__` method](https://docs.python.org/3/reference/datamodel.html#object.__index__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct InvalidIndexReturnType; impl Violation for InvalidIndexReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_length_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_length_return.rs index 16db437ec2..a4005151a7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_length_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_length_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__len__` implementations that return values that are not non-negative @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__len__` method](https://docs.python.org/3/reference/datamodel.html#object.__len__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct InvalidLengthReturnType; impl Violation for InvalidLengthReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_str_return.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_str_return.rs index 5ad2821f1d..4e2d68f72f 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_str_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_str_return.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `__str__` implementations that return a type other than `str`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: The `__str__` method](https://docs.python.org/3/reference/datamodel.html#object.__str__) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Suspicious)] pub(crate) struct InvalidStrReturnType; impl Violation for InvalidStrReturnType { diff --git a/crates/ruff_linter/src/rules/pylint/rules/invalid_string_characters.rs b/crates/ruff_linter/src/rules/pylint/rules/invalid_string_characters.rs index f292f31619..41f9c77a2d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/invalid_string_characters.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/invalid_string_characters.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -27,7 +28,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// x = "\b" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Suspicious)] pub(crate) struct InvalidCharacterBackspace; impl Violation for InvalidCharacterBackspace { @@ -63,7 +64,7 @@ impl Violation for InvalidCharacterBackspace { /// x = "\x1a" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Suspicious)] pub(crate) struct InvalidCharacterSub; impl Violation for InvalidCharacterSub { @@ -99,7 +100,7 @@ impl Violation for InvalidCharacterSub { /// x = "\x1b" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Suspicious)] pub(crate) struct InvalidCharacterEsc; impl Violation for InvalidCharacterEsc { @@ -135,7 +136,7 @@ impl Violation for InvalidCharacterEsc { /// x = "\0" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Suspicious)] pub(crate) struct InvalidCharacterNul; impl Violation for InvalidCharacterNul { @@ -170,7 +171,7 @@ impl Violation for InvalidCharacterNul { /// x = "Dear Sir\u200b/\u200bMadam" # zero width space /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Suspicious)] pub(crate) struct InvalidCharacterZeroWidthSpace; impl Violation for InvalidCharacterZeroWidthSpace { diff --git a/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs b/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs index 84156dd84f..ddc0d88fe4 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs @@ -6,6 +6,7 @@ use ruff_python_ast::comparable::HashableExpr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `set`](https://docs.python.org/3/library/stdtypes.html#set) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Performance)] pub(crate) struct IterationOverSet; impl AlwaysFixableViolation for IterationOverSet { diff --git a/crates/ruff_linter/src/rules/pylint/rules/len_test.rs b/crates/ruff_linter/src/rules/pylint/rules/len_test.rs index e64bcd12e5..e03353e954 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/len_test.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/len_test.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::find_binding_value; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -59,7 +60,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// [PEP 8: Programming Recommendations](https://peps.python.org/pep-0008/#programming-recommendations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Pedantic)] pub(crate) struct LenTest { expression: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/literal_membership.rs b/crates/ruff_linter/src/rules/pylint/rules/literal_membership.rs index b11870aa63..3e8e67c917 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/literal_membership.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/literal_membership.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -34,7 +35,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [What’s New In Python 3.2](https://docs.python.org/3/whatsnew/3.2.html#optimizations) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.1")] +#[violation_metadata(preview_since = "v0.1.1", category = Category::Pedantic)] pub(crate) struct LiteralMembership; impl AlwaysFixableViolation for LiteralMembership { diff --git a/crates/ruff_linter/src/rules/pylint/rules/load_before_global_declaration.rs b/crates/ruff_linter/src/rules/pylint/rules/load_before_global_declaration.rs index 575239ec12..b2ad137466 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/load_before_global_declaration.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/load_before_global_declaration.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_source_file::SourceRow; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for uses of names that are declared as `global` prior to the @@ -37,7 +38,7 @@ use crate::Violation; /// ## References /// - [Python documentation: The `global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.174")] +#[violation_metadata(stable_since = "v0.0.174", category = Category::Correctness)] pub(crate) struct LoadBeforeGlobalDeclaration { pub(crate) name: String, pub(crate) row: SourceRow, diff --git a/crates/ruff_linter/src/rules/pylint/rules/logging.rs b/crates/ruff_linter/src/rules/pylint/rules/logging.rs index 12f6ed6f4b..cde9c80471 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/logging.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/logging.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::registry::Rule; use crate::rules::pyflakes::cformat::CFormatSummary; @@ -41,7 +42,7 @@ use crate::rules::pyflakes::cformat::CFormatSummary; /// /// - `lint.logger-objects` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.252")] +#[violation_metadata(stable_since = "v0.0.252", category = Category::Correctness)] pub(crate) struct LoggingTooFewArgs; impl Violation for LoggingTooFewArgs { @@ -83,7 +84,7 @@ impl Violation for LoggingTooFewArgs { /// /// - `lint.logger-objects` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.252")] +#[violation_metadata(stable_since = "v0.0.252", category = Category::Correctness)] pub(crate) struct LoggingTooManyArgs; impl Violation for LoggingTooManyArgs { diff --git a/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs index 9e40ee119d..1abefe2a5f 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::settings::ConstantType; /// ## What it does @@ -50,7 +51,7 @@ use crate::rules::pylint::settings::ConstantType; /// /// [PEP 8]: https://peps.python.org/pep-0008/#constants #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.221")] +#[violation_metadata(stable_since = "v0.0.221", category = Category::Pedantic)] pub(crate) struct MagicValueComparison { value: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs b/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs index bdcf1b7791..58fa0e14be 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pyupgrade::rules::is_import_required_by_isort; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -33,7 +34,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Submodules](https://docs.python.org/3/reference/import.html#submodules) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Complexity)] pub(crate) struct ManualFromImport { module: String, name: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/misplaced_bare_raise.rs b/crates/ruff_linter/src/rules/pylint/rules/misplaced_bare_raise.rs index 98777b218c..86de49d774 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/misplaced_bare_raise.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/misplaced_bare_raise.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::in_dunder_method; /// ## What it does @@ -41,7 +42,7 @@ use crate::rules::pylint::helpers::in_dunder_method; /// raise ValueError("`obj` cannot be `None`") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct MisplacedBareRaise; impl Violation for MisplacedBareRaise { diff --git a/crates/ruff_linter/src/rules/pylint/rules/missing_maxsplit_arg.rs b/crates/ruff_linter/src/rules/pylint/rules/missing_maxsplit_arg.rs index ae575f0880..3c5293ff45 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/missing_maxsplit_arg.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/missing_maxsplit_arg.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::{SemanticModel, analyze::typing}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -42,7 +43,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// `**kwargs` arguments, as adding a `maxsplit` argument to such a call may lead to duplicate /// arguments. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Performance)] pub(crate) struct MissingMaxsplitArg<'a> { actual_split_type: &'a str, suggested_split_type: &'a str, diff --git a/crates/ruff_linter/src/rules/pylint/rules/modified_iterating_set.rs b/crates/ruff_linter/src/rules/pylint/rules/modified_iterating_set.rs index 434668b056..ed749f1ef3 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/modified_iterating_set.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/modified_iterating_set.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::typing::is_set; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -46,7 +47,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `set`](https://docs.python.org/3/library/stdtypes.html#set) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.5")] +#[violation_metadata(preview_since = "v0.3.5", category = Category::Suspicious)] pub(crate) struct ModifiedIteratingSet { name: Name, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/named_expr_without_context.rs b/crates/ruff_linter/src/rules/pylint/rules/named_expr_without_context.rs index 3e45e26a49..f88585141d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/named_expr_without_context.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/named_expr_without_context.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of named expressions (e.g., `a := 42`) that can be @@ -25,7 +26,7 @@ use crate::checkers::ast::Checker; /// a = 42 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.270")] +#[violation_metadata(stable_since = "v0.0.270", category = Category::Complexity)] pub(crate) struct NamedExprWithoutContext; impl Violation for NamedExprWithoutContext { diff --git a/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs index d387f65c06..b552b85010 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::linter::float::as_nan_float_string_literal; /// ## What it does @@ -33,7 +34,7 @@ use crate::linter::float::as_nan_float_string_literal; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Correctness)] pub(crate) struct NanComparison { nan: Nan, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/nested_min_max.rs b/crates/ruff_linter/src/rules/pylint/rules/nested_min_max.rs index 5f3de2aba7..890824adf5 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/nested_min_max.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/nested_min_max.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -75,7 +76,7 @@ pub(crate) enum MinMax { /// - [Python documentation: `min`](https://docs.python.org/3/library/functions.html#min) /// - [Python documentation: `max`](https://docs.python.org/3/library/functions.html#max) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.266")] +#[violation_metadata(stable_since = "v0.0.266", category = Category::Pedantic)] pub(crate) struct NestedMinMax { func: MinMax, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/no_method_decorator.rs b/crates/ruff_linter/src/rules/pylint/rules/no_method_decorator.rs index 334cd47bc8..33c83fc18a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/no_method_decorator.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/no_method_decorator.rs @@ -7,6 +7,7 @@ use ruff_python_trivia::indentation_at_offset; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -33,7 +34,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def bar(cls): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.7")] +#[violation_metadata(preview_since = "v0.1.7", category = Category::Style)] pub(crate) struct NoClassmethodDecorator; impl AlwaysFixableViolation for NoClassmethodDecorator { @@ -70,7 +71,7 @@ impl AlwaysFixableViolation for NoClassmethodDecorator { /// def bar(arg1, arg2): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.7")] +#[violation_metadata(preview_since = "v0.1.7", category = Category::Style)] pub(crate) struct NoStaticmethodDecorator; impl AlwaysFixableViolation for NoStaticmethodDecorator { diff --git a/crates/ruff_linter/src/rules/pylint/rules/no_self_use.rs b/crates/ruff_linter/src/rules/pylint/rules/no_self_use.rs index 69d630910d..de6dbd07dd 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/no_self_use.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/no_self_use.rs @@ -8,6 +8,7 @@ use ruff_python_semantic::{ use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_unused_arguments::rules::is_not_implemented_stub_with_variable; /// ## What it does @@ -58,7 +59,7 @@ use crate::rules::flake8_unused_arguments::rules::is_not_implemented_stub_with_v /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.286")] +#[violation_metadata(preview_since = "v0.0.286", category = Category::Pedantic)] pub(crate) struct NoSelfUse { method_name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/non_ascii_module_import.rs b/crates/ruff_linter/src/rules/pylint/rules/non_ascii_module_import.rs index be493bb7de..d734d91046 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/non_ascii_module_import.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/non_ascii_module_import.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of non-ASCII characters in import statements. @@ -30,7 +31,7 @@ use crate::checkers::ast::Checker; /// /// [PEP 672]: https://peps.python.org/pep-0672/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct NonAsciiImportName { name: String, kind: Kind, diff --git a/crates/ruff_linter/src/rules/pylint/rules/non_ascii_name.rs b/crates/ruff_linter/src/rules/pylint/rules/non_ascii_name.rs index 97584abf9e..69318bb85d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/non_ascii_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/non_ascii_name.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for the use of non-ASCII characters in variable names. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// /// [PEP 672]: https://peps.python.org/pep-0672/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct NonAsciiName { name: String, kind: Kind, diff --git a/crates/ruff_linter/src/rules/pylint/rules/non_augmented_assignment.rs b/crates/ruff_linter/src/rules/pylint/rules/non_augmented_assignment.rs index ddf01a3e7b..146266186b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/non_augmented_assignment.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/non_augmented_assignment.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{ExprBinOp, ExprRef, Operator}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -76,7 +77,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// The fix replaces the whole statement, so any comments inside it are lost. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.7")] +#[violation_metadata(preview_since = "v0.3.7", category = Category::Pedantic)] pub(crate) struct NonAugmentedAssignment { operator: AugmentedOperator, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/non_slot_assignment.rs b/crates/ruff_linter/src/rules/pylint/rules/non_slot_assignment.rs index 42964f1efa..314e7a2846 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/non_slot_assignment.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/non_slot_assignment.rs @@ -6,6 +6,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for assignments to attributes that are not defined in `__slots__`. @@ -47,7 +48,7 @@ use crate::checkers::ast::Checker; /// pass /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.1.15")] +#[violation_metadata(stable_since = "v0.1.15", category = Category::Pedantic)] pub(crate) struct NonSlotAssignment { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/nonlocal_and_global.rs b/crates/ruff_linter/src/rules/pylint/rules/nonlocal_and_global.rs index 9a202324e8..e888dc0c8b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/nonlocal_and_global.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/nonlocal_and_global.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for variables which are both declared as both `nonlocal` and @@ -41,7 +42,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: The `global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) /// - [Python documentation: The `nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#nonlocal) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct NonlocalAndGlobal { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/nonlocal_without_binding.rs b/crates/ruff_linter/src/rules/pylint/rules/nonlocal_without_binding.rs index f71902cb32..206b66b926 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/nonlocal_without_binding.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/nonlocal_without_binding.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for `nonlocal` names without bindings. @@ -31,7 +32,7 @@ use crate::Violation; /// - [Python documentation: The `nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#nonlocal) /// - [PEP 3104 – Access to Names in Outer Scopes](https://peps.python.org/pep-3104/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.174")] +#[violation_metadata(stable_since = "v0.0.174", category = Category::Correctness)] pub(crate) struct NonlocalWithoutBinding { pub(crate) name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/potential_index_error.rs b/crates/ruff_linter/src/rules/pylint/rules/potential_index_error.rs index 2a67e6a83b..9e3dcf6435 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/potential_index_error.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/potential_index_error.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for hard-coded sequence accesses that are known to be out of bounds. @@ -19,7 +20,7 @@ use crate::checkers::ast::Checker; /// print([0, 1, 2][3]) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct PotentialIndexError; impl Violation for PotentialIndexError { diff --git a/crates/ruff_linter/src/rules/pylint/rules/property_with_parameters.rs b/crates/ruff_linter/src/rules/pylint/rules/property_with_parameters.rs index 07058b56c0..3fe0b3f3f6 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/property_with_parameters.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/property_with_parameters.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::visibility::is_property; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for property definitions that accept function parameters. @@ -39,7 +40,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `property`](https://docs.python.org/3/library/functions.html#property) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.153")] +#[violation_metadata(stable_since = "v0.0.153", category = Category::Correctness)] pub(crate) struct PropertyWithParameters; impl Violation for PropertyWithParameters { diff --git a/crates/ruff_linter/src/rules/pylint/rules/redeclared_assigned_name.rs b/crates/ruff_linter/src/rules/pylint/rules/redeclared_assigned_name.rs index dc42cc1b4d..f2165ef032 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/redeclared_assigned_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/redeclared_assigned_name.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for declared assignments to the same variable multiple times @@ -34,7 +35,7 @@ use crate::checkers::ast::Checker; /// /// - `lint.dummy-variable-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct RedeclaredAssignedName { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/redefined_argument_from_local.rs b/crates/ruff_linter/src/rules/pylint/rules/redefined_argument_from_local.rs index 1fcf2e448d..a0b62522df 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/redefined_argument_from_local.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/redefined_argument_from_local.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for variables defined in `for`, `try`, `with` statements @@ -33,7 +34,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Pylint documentation](https://pylint.readthedocs.io/en/latest/user_guide/messages/refactor/redefined-argument-from-local.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct RedefinedArgumentFromLocal { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs b/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs index 340c92951d..f5d1d0575d 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs @@ -12,6 +12,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for variables defined in `for` loops and `with` statements that @@ -56,7 +57,7 @@ use crate::checkers::ast::Checker; /// /// - `lint.dummy-variable-rgx` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.252")] +#[violation_metadata(stable_since = "v0.0.252", category = Category::Pedantic)] pub(crate) struct RedefinedLoopName { name: String, outer_kind: OuterBindingKind, diff --git a/crates/ruff_linter/src/rules/pylint/rules/redefined_slots_in_subclass.rs b/crates/ruff_linter/src/rules/pylint/rules/redefined_slots_in_subclass.rs index 12c427df5c..68e8b883ef 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/redefined_slots_in_subclass.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/redefined_slots_in_subclass.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for a re-defined slot in a subclass. @@ -38,7 +39,7 @@ use crate::checkers::ast::Checker; /// __slots__ = "d" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.3")] +#[violation_metadata(preview_since = "0.9.3", category = Category::Correctness)] pub(crate) struct RedefinedSlotsInSubclass { base: String, slot_name: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs index 9fef63b3f7..3dc4a2a1a5 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs @@ -11,6 +11,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -53,7 +54,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: Membership test operations](https://docs.python.org/3/reference/expressions.html#membership-test-operations) /// - [Python documentation: `set`](https://docs.python.org/3/library/stdtypes.html#set) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.279")] +#[violation_metadata(stable_since = "v0.0.279", category = Category::Pedantic)] pub(crate) struct RepeatedEqualityComparison { expression: SourceCodeSnippet, all_hashable: bool, diff --git a/crates/ruff_linter/src/rules/pylint/rules/repeated_isinstance_calls.rs b/crates/ruff_linter/src/rules/pylint/rules/repeated_isinstance_calls.rs index 093c3fa8d3..9f1250f956 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/repeated_isinstance_calls.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/repeated_isinstance_calls.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::AlwaysFixableViolation; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; /// ## Removed @@ -49,7 +50,7 @@ use crate::fix::snippet::SourceCodeSnippet; /// /// [SIM101]: https://docs.astral.sh/ruff/rules/duplicate-isinstance-call/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.5.0")] +#[violation_metadata(removed_since = "0.5.0", category = Category::Complexity)] pub(crate) struct RepeatedIsinstanceCalls { expression: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/repeated_keyword_argument.rs b/crates/ruff_linter/src/rules/pylint/rules/repeated_keyword_argument.rs index ab2db3f95d..eaafb60891 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/repeated_keyword_argument.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/repeated_keyword_argument.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for repeated keyword arguments in function calls. @@ -23,7 +24,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Argument](https://docs.python.org/3/glossary.html#term-argument) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct RepeatedKeywordArgument { duplicate_keyword: String, } @@ -40,21 +41,26 @@ impl Violation for RepeatedKeywordArgument { pub(crate) fn repeated_keyword_argument(checker: &Checker, call: &ExprCall) { let ExprCall { arguments, .. } = call; + // Avoid allocating if there's only one non-unpacked keyword argument, or the unpacked value is + // not a dict literal. + if let [keyword] = &*arguments.keywords { + if keyword.arg.is_some() || !keyword.value.is_dict_expr() { + return; + } + } + let mut seen = FxHashSet::with_capacity_and_hasher(arguments.keywords.len(), FxBuildHasher); for keyword in &*arguments.keywords { if let Some(id) = &keyword.arg { - // Ex) `func(a=1, a=2)` - if !seen.insert(id.as_str()) { - checker.report_diagnostic( - RepeatedKeywordArgument { - duplicate_keyword: id.to_string(), - }, - keyword.range(), - ); - } - } else if let Expr::Dict(dict) = &keyword.value { - // Ex) `func(**{"a": 1, "a": 2})` + seen.insert(id.as_str()); + } + } + + for keyword in &*arguments.keywords { + if keyword.arg.is_none() + && let Expr::Dict(dict) = &keyword.value + { for key in dict.iter_keys().flatten() { if let Expr::StringLiteral(ExprStringLiteral { value, .. }) = key { if !seen.insert(value.to_str()) { diff --git a/crates/ruff_linter/src/rules/pylint/rules/return_in_init.rs b/crates/ruff_linter/src/rules/pylint/rules/return_in_init.rs index b27ee93b59..f2451512cc 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/return_in_init.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/return_in_init.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::in_dunder_method; /// ## What it does @@ -35,7 +36,7 @@ use crate::rules::pylint::helpers::in_dunder_method; /// ## References /// - [CodeQL: `py-explicit-return-in-init`](https://codeql.github.com/codeql-query-help/python/py-explicit-return-in-init/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.248")] +#[violation_metadata(stable_since = "v0.0.248", category = Category::Correctness)] pub(crate) struct ReturnInInit; impl Violation for ReturnInInit { diff --git a/crates/ruff_linter/src/rules/pylint/rules/self_assigning_variable.rs b/crates/ruff_linter/src/rules/pylint/rules/self_assigning_variable.rs index ea6f80773d..725a6305c1 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/self_assigning_variable.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/self_assigning_variable.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for self-assignment of variables. @@ -24,7 +25,7 @@ use crate::checkers::ast::Checker; /// country = "Poland" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct SelfAssigningVariable { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/self_or_cls_assignment.rs b/crates/ruff_linter/src/rules/pylint/rules/self_or_cls_assignment.rs index 81e11d408b..d52c5c1f64 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/self_or_cls_assignment.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/self_or_cls_assignment.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for assignment of `self` and `cls` in instance and class methods respectively. @@ -51,7 +52,7 @@ use crate::checkers::ast::Checker; /// - `lint.pep8-naming.classmethod-decorators` /// - `lint.pep8-naming.staticmethod-decorators` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct SelfOrClsAssignment { method_type: MethodType, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/shallow_copy_environ.rs b/crates/ruff_linter/src/rules/pylint/rules/shallow_copy_environ.rs index 94846decb2..1780698291 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/shallow_copy_environ.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/shallow_copy_environ.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -43,7 +44,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [BPO 15373]: https://bugs.python.org/issue15373 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Correctness)] pub(crate) struct ShallowCopyEnviron; impl AlwaysFixableViolation for ShallowCopyEnviron { diff --git a/crates/ruff_linter/src/rules/pylint/rules/single_string_slots.rs b/crates/ruff_linter/src/rules/pylint/rules/single_string_slots.rs index 0c6f94a483..9b302442ff 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/single_string_slots.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/single_string_slots.rs @@ -5,6 +5,7 @@ use ruff_python_ast::identifier::Identifier; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for single strings assigned to `__slots__`. @@ -48,7 +49,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.276")] +#[violation_metadata(stable_since = "v0.0.276", category = Category::Suspicious)] pub(crate) struct SingleStringSlots; impl Violation for SingleStringSlots { diff --git a/crates/ruff_linter/src/rules/pylint/rules/singledispatch_method.rs b/crates/ruff_linter/src/rules/pylint/rules/singledispatch_method.rs index d1562c89b5..c653858ca3 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/singledispatch_method.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/singledispatch_method.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::function_type; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - `lint.pep8-naming.classmethod-decorators` /// - `lint.pep8-naming.staticmethod-decorators` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct SingledispatchMethod; impl Violation for SingledispatchMethod { diff --git a/crates/ruff_linter/src/rules/pylint/rules/singledispatchmethod_function.rs b/crates/ruff_linter/src/rules/pylint/rules/singledispatchmethod_function.rs index e658b19a0d..ba0c31c74f 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/singledispatchmethod_function.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/singledispatchmethod_function.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::function_type; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -41,7 +42,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// This rule's fix is marked as unsafe, as migrating from `@singledispatchmethod` to /// `@singledispatch` may change the behavior of the code. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct SingledispatchmethodFunction; impl Violation for SingledispatchmethodFunction { diff --git a/crates/ruff_linter/src/rules/pylint/rules/stop_iteration_return.rs b/crates/ruff_linter/src/rules/pylint/rules/stop_iteration_return.rs index 8dbe97fd9c..6f557c8cff 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/stop_iteration_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/stop_iteration_return.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for explicit `raise StopIteration` in generator functions. @@ -38,7 +39,7 @@ use crate::checkers::ast::Checker; /// - [PEP 479](https://peps.python.org/pep-0479/) /// - [Python documentation](https://docs.python.org/3/library/exceptions.html#StopIteration) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Correctness)] pub(crate) struct StopIterationReturn; impl Violation for StopIterationReturn { diff --git a/crates/ruff_linter/src/rules/pylint/rules/subprocess_popen_preexec_fn.rs b/crates/ruff_linter/src/rules/pylint/rules/subprocess_popen_preexec_fn.rs index b27f592506..652ca5f31b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/subprocess_popen_preexec_fn.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/subprocess_popen_preexec_fn.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `subprocess.Popen` with a `preexec_fn` argument. @@ -40,7 +41,7 @@ use crate::checkers::ast::Checker; /// /// [targeted for deprecation]: https://github.com/python/cpython/issues/82616 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.281")] +#[violation_metadata(stable_since = "v0.0.281", category = Category::Suspicious)] pub(crate) struct SubprocessPopenPreexecFn; impl Violation for SubprocessPopenPreexecFn { diff --git a/crates/ruff_linter/src/rules/pylint/rules/subprocess_run_without_check.rs b/crates/ruff_linter/src/rules/pylint/rules/subprocess_run_without_check.rs index 4fab3ff6a2..a5868cbd54 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/subprocess_run_without_check.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/subprocess_run_without_check.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_argument; use crate::{Fix, FixAvailability, Violation}; @@ -49,7 +50,7 @@ use crate::{Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `subprocess.run`](https://docs.python.org/3/library/subprocess.html#subprocess.run) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.285")] +#[violation_metadata(stable_since = "v0.0.285", category = Category::Suspicious)] pub(crate) struct SubprocessRunWithoutCheck; impl Violation for SubprocessRunWithoutCheck { diff --git a/crates/ruff_linter/src/rules/pylint/rules/super_without_brackets.rs b/crates/ruff_linter/src/rules/pylint/rules/super_without_brackets.rs index 4287269502..214eb874bf 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/super_without_brackets.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/super_without_brackets.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -55,7 +56,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - `lint.pep8-naming.classmethod-decorators` /// - `lint.pep8-naming.staticmethod-decorators` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct SuperWithoutBrackets; impl AlwaysFixableViolation for SuperWithoutBrackets { diff --git a/crates/ruff_linter/src/rules/pylint/rules/swap_with_temporary_variable.rs b/crates/ruff_linter/src/rules/pylint/rules/swap_with_temporary_variable.rs index 0b8aa70d3d..585ea9a174 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/swap_with_temporary_variable.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/swap_with_temporary_variable.rs @@ -8,6 +8,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for code that swaps two variables using a temporary variable. @@ -39,7 +40,7 @@ use crate::checkers::ast::Checker; /// The rule's fix is marked as safe, unless the replacement range contains comments /// that would be removed. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.3")] +#[violation_metadata(preview_since = "0.15.3", category = Category::Complexity)] pub(crate) struct SwapWithTemporaryVariable<'a> { first: &'a Name, second: &'a Name, diff --git a/crates/ruff_linter/src/rules/pylint/rules/sys_exit_alias.rs b/crates/ruff_linter/src/rules/pylint/rules/sys_exit_alias.rs index 1fb4bd4792..1a8d809b33 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/sys_exit_alias.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/sys_exit_alias.rs @@ -3,6 +3,7 @@ use ruff_python_ast::ExprCall; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -50,7 +51,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: Constants added by the `site` module](https://docs.python.org/3/library/constants.html#constants-added-by-the-site-module) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.156")] +#[violation_metadata(stable_since = "v0.0.156", category = Category::Suspicious)] pub(crate) struct SysExitAlias { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_arguments.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_arguments.rs index bac196dda1..ea72e58772 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_arguments.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_arguments.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze::{function_type, visibility}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function definitions that include too many arguments. @@ -57,7 +58,7 @@ use crate::checkers::ast::Checker; /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.238")] +#[violation_metadata(stable_since = "v0.0.238", category = Category::Pedantic)] pub(crate) struct TooManyArguments { c_args: usize, max_args: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_boolean_expressions.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_boolean_expressions.rs index 61f0dfa442..b2ab3929c9 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_boolean_expressions.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_boolean_expressions.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for too many Boolean expressions in an `if` statement. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.pylint.max-bool-expr` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.1")] +#[violation_metadata(preview_since = "v0.1.1", category = Category::Pedantic)] pub(crate) struct TooManyBooleanExpressions { expressions: usize, max_expressions: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_branches.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_branches.rs index fd9d90cd38..938766f658 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_branches.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_branches.rs @@ -3,6 +3,7 @@ use ruff_python_ast::identifier::Identifier; use ruff_python_ast::{self as ast, ExceptHandler, Stmt}; use crate::Violation; +use crate::codes::Category; use crate::checkers::ast::Checker; @@ -145,7 +146,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.pylint.max-branches` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.242")] +#[violation_metadata(stable_since = "v0.0.242", category = Category::Pedantic)] pub(crate) struct TooManyBranches { branches: usize, max_branches: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_locals.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_locals.rs index 1353b0c17b..07e302c48e 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_locals.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_locals.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::{Scope, ScopeKind}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for functions that include too many local variables. @@ -20,7 +21,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.pylint.max-locals` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.9")] +#[violation_metadata(preview_since = "v0.1.9", category = Category::Pedantic)] pub(crate) struct TooManyLocals { current_amount: usize, max_amount: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_nested_blocks.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_nested_blocks.rs index b07828832d..b85c55a7ce 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_nested_blocks.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_nested_blocks.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for functions or methods with too many nested blocks. @@ -19,7 +20,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.pylint.max-nested-blocks` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.15")] +#[violation_metadata(preview_since = "v0.1.15", category = Category::Pedantic)] pub(crate) struct TooManyNestedBlocks { nested_blocks: usize, max_nested_blocks: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_positional_arguments.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_positional_arguments.rs index f37723ee64..da7d375eb4 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_positional_arguments.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_positional_arguments.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::{function_type, visibility}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for function definitions that include too many positional arguments. @@ -55,7 +56,7 @@ use crate::checkers::ast::Checker; /// /// [override]: https://docs.python.org/3/library/typing.html#typing.override #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Pedantic)] pub(crate) struct TooManyPositionalArguments { c_pos: usize, max_pos: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_public_methods.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_public_methods.rs index bc1802926b..0e8c518e4a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_public_methods.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_public_methods.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for classes with too many public methods @@ -83,7 +84,7 @@ use crate::checkers::ast::Checker; /// ## Options /// - `lint.pylint.max-public-methods` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.290")] +#[violation_metadata(preview_since = "v0.0.290", category = Category::Pedantic)] pub(crate) struct TooManyPublicMethods { methods: usize, max_methods: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_return_statements.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_return_statements.rs index f414f6931b..22fd2655e7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_return_statements.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_return_statements.rs @@ -4,6 +4,7 @@ use ruff_python_ast::helpers::ReturnStatementVisitor; use ruff_python_ast::identifier::Identifier; use ruff_python_ast::visitor::Visitor; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -52,7 +53,7 @@ use crate::{Violation, checkers::ast::Checker}; /// ## Options /// - `lint.pylint.max-returns` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.242")] +#[violation_metadata(stable_since = "v0.0.242", category = Category::Pedantic)] pub(crate) struct TooManyReturnStatements { returns: usize, max_returns: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_statements.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_statements.rs index b92ad563a9..c93db3e8d6 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_statements.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_statements.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Stmt; use ruff_python_ast::identifier::Identifier; use crate::Violation; +use crate::codes::Category; use crate::checkers::ast::Checker; use crate::rules::pylint::helpers::num_statements; @@ -49,7 +50,7 @@ use crate::rules::pylint::helpers::num_statements; /// ## Options /// - `lint.pylint.max-statements` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.240")] +#[violation_metadata(stable_since = "v0.0.240", category = Category::Pedantic)] pub(crate) struct TooManyStatements { statements: usize, max_statements: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/too_many_try_statements.rs b/crates/ruff_linter/src/rules/pylint/rules/too_many_try_statements.rs index 04bf93df38..c6e3626ce9 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/too_many_try_statements.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/too_many_try_statements.rs @@ -3,6 +3,7 @@ use ruff_python_ast::StmtTry; use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Violation; +use crate::codes::Category; use crate::checkers::ast::Checker; use crate::rules::pylint::helpers::num_statements; @@ -69,7 +70,7 @@ use crate::rules::pylint::helpers::num_statements; /// uses a different default setting. /// To replicate it exactly, set `lint.pylint.max-statements-in-try` to 1. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.14")] +#[violation_metadata(preview_since = "0.15.14", category = Category::Pedantic)] pub(crate) struct TooManyStatementsInTryClause { statements: usize, max_statements: usize, diff --git a/crates/ruff_linter/src/rules/pylint/rules/type_bivariance.rs b/crates/ruff_linter/src/rules/pylint/rules/type_bivariance.rs index 5713786083..e1b8d11af7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/type_bivariance.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/type_bivariance.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::type_param_name; /// ## What it does @@ -54,7 +55,7 @@ use crate::rules::pylint::helpers::type_param_name; /// - [PEP 483 – The Theory of Type Hints: Covariance and Contravariance](https://peps.python.org/pep-0483/#covariance-and-contravariance) /// - [PEP 484 – Type Hints: Covariance and contravariance](https://peps.python.org/pep-0484/#covariance-and-contravariance) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Correctness)] pub(crate) struct TypeBivariance { kind: VarKind, param_name: Option, diff --git a/crates/ruff_linter/src/rules/pylint/rules/type_name_incorrect_variance.rs b/crates/ruff_linter/src/rules/pylint/rules/type_name_incorrect_variance.rs index 4d1591abaa..bd0bfc984c 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/type_name_incorrect_variance.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/type_name_incorrect_variance.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::type_param_name; /// ## What it does @@ -43,7 +44,7 @@ use crate::rules::pylint::helpers::type_param_name; /// /// [PEP 484]: https://peps.python.org/pep-0484/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Style)] pub(crate) struct TypeNameIncorrectVariance { kind: VarKind, param_name: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/type_param_name_mismatch.rs b/crates/ruff_linter/src/rules/pylint/rules/type_param_name_mismatch.rs index 1a936c95d1..b4d66b7316 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/type_param_name_mismatch.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/type_param_name_mismatch.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::type_param_name; /// ## What it does @@ -39,7 +40,7 @@ use crate::rules::pylint::helpers::type_param_name; /// /// [PEP 484]:https://peps.python.org/pep-0484/#generics #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.277")] +#[violation_metadata(stable_since = "v0.0.277", category = Category::Suspicious)] pub(crate) struct TypeParamNameMismatch { kind: VarKind, var_name: String, diff --git a/crates/ruff_linter/src/rules/pylint/rules/unexpected_special_method_signature.rs b/crates/ruff_linter/src/rules/pylint/rules/unexpected_special_method_signature.rs index ea6ddb31c4..cfc9b403b6 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unexpected_special_method_signature.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unexpected_special_method_signature.rs @@ -8,6 +8,7 @@ use ruff_python_semantic::analyze::visibility::is_staticmethod; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; #[derive(Debug, Eq, PartialEq)] pub(crate) enum ExpectedParams { @@ -110,7 +111,7 @@ impl ExpectedParams { /// ## References /// - [Python documentation: Data model](https://docs.python.org/3/reference/datamodel.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.263")] +#[violation_metadata(stable_since = "v0.0.263", category = Category::Pedantic)] pub(crate) struct UnexpectedSpecialMethodSignature { method_name: String, expected_params: ExpectedParams, diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dict_index_lookup.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dict_index_lookup.rs index 9aec5e4303..ab3d1347ea 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dict_index_lookup.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dict_index_lookup.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr, StmtFor}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::SequenceIndexVisitor; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -31,7 +32,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// print(fruit_count) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct UnnecessaryDictIndexLookup; impl AlwaysFixableViolation for UnnecessaryDictIndexLookup { diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_direct_lambda_call.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_direct_lambda_call.rs index dd40fa11c5..882ae60713 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_direct_lambda_call.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_direct_lambda_call.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for unnecessary direct calls to lambda expressions. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Lambdas](https://docs.python.org/3/reference/expressions.html#lambda) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.153")] +#[violation_metadata(stable_since = "v0.0.153", category = Category::Complexity)] pub(crate) struct UnnecessaryDirectLambdaCall; impl Violation for UnnecessaryDirectLambdaCall { diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dunder_call.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dunder_call.rs index 7bbc469d03..df48beb9be 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dunder_call.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_dunder_call.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::rules::pylint::helpers::is_known_dunder_method; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -64,7 +65,7 @@ use ruff_python_ast::PythonVersion; /// return x > 2 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.12")] +#[violation_metadata(preview_since = "v0.1.12", category = Category::Complexity)] pub(crate) struct UnnecessaryDunderCall { method: String, replacement: Option, diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs index 711c448ab7..320a2ad0c1 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr, ExprLambda, Parameter, ParameterWithDef use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -53,7 +54,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// in: `foo(x=1, y=2)`. Since `func` does not define the arguments `x` and `y`, /// unlike the lambda, the call would raise a `TypeError`. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Style)] pub(crate) struct UnnecessaryLambda; impl Violation for UnnecessaryLambda { diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_list_index_lookup.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_list_index_lookup.rs index 49481b1634..27784c8920 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_list_index_lookup.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_list_index_lookup.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::SequenceIndexVisitor; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// print(letter) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct UnnecessaryListIndexLookup; impl AlwaysFixableViolation for UnnecessaryListIndexLookup { diff --git a/crates/ruff_linter/src/rules/pylint/rules/unreachable.rs b/crates/ruff_linter/src/rules/pylint/rules/unreachable.rs index efd0a0db3a..7aea757b93 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unreachable.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unreachable.rs @@ -9,6 +9,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for unreachable code. @@ -30,7 +31,7 @@ use crate::checkers::ast::Checker; /// return "reachable" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.0.0")] +#[violation_metadata(preview_since = "0.0.0", category = Category::Testing)] pub(crate) struct UnreachableCode { name: String, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/unspecified_encoding.rs b/crates/ruff_linter/src/rules/pylint/rules/unspecified_encoding.rs index 316d2e7f54..ef65fba6a8 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unspecified_encoding.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unspecified_encoding.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::add_argument; use crate::{AlwaysFixableViolation, Fix}; @@ -54,7 +55,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// /// [PEP 597]: https://peps.python.org/pep-0597/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.1")] +#[violation_metadata(preview_since = "v0.1.1", category = Category::Pedantic)] pub(crate) struct UnspecifiedEncoding { function_name: String, mode: ModeArgument, @@ -154,7 +155,7 @@ impl<'a> Callee<'a> { fn mode_argument(&self) -> ModeArgument { match self { Callee::Qualified(qualified_name) => match qualified_name.segments() { - ["" | "codecs" | "_io", "open"] => ModeArgument::Supported, + ["" | "builtins" | "codecs" | "_io", "open"] => ModeArgument::Supported, [ "tempfile", "TemporaryFile" | "NamedTemporaryFile" | "SpooledTemporaryFile", @@ -223,7 +224,7 @@ fn is_violation(call: &ast::ExprCall, qualified_name: &Callee) -> bool { } match qualified_name { Callee::Qualified(qualified_name) => match qualified_name.segments() { - ["" | "codecs" | "_io", "open"] => { + ["" | "builtins" | "codecs" | "_io", "open"] => { if let Some(mode_arg) = call.arguments.find_argument_value("mode", 1) { if is_binary_mode(mode_arg).unwrap_or(true) { // binary mode or unknown mode is no violation diff --git a/crates/ruff_linter/src/rules/pylint/rules/useless_else_on_loop.rs b/crates/ruff_linter/src/rules/pylint/rules/useless_else_on_loop.rs index 900a8d1e31..1a29036a24 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/useless_else_on_loop.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/useless_else_on_loop.rs @@ -11,6 +11,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::adjust_indentation; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -47,7 +48,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `break` and `continue` Statements, and `else` Clauses on Loops](https://docs.python.org/3/tutorial/controlflow.html#break-and-continue-statements-and-else-clauses-on-loops) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.156")] +#[violation_metadata(stable_since = "v0.0.156", category = Category::Suspicious)] pub(crate) struct UselessElseOnLoop; impl Violation for UselessElseOnLoop { diff --git a/crates/ruff_linter/src/rules/pylint/rules/useless_exception_statement.rs b/crates/ruff_linter/src/rules/pylint/rules/useless_exception_statement.rs index 674f0b0cfb..cf7d08f0bb 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/useless_exception_statement.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/useless_exception_statement.rs @@ -5,6 +5,7 @@ use ruff_python_stdlib::builtins; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_custom_exception_checking_enabled; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -40,7 +41,7 @@ use ruff_python_ast::PythonVersion; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct UselessExceptionStatement; impl Violation for UselessExceptionStatement { diff --git a/crates/ruff_linter/src/rules/pylint/rules/useless_import_alias.rs b/crates/ruff_linter/src/rules/pylint/rules/useless_import_alias.rs index 6b45d9cc64..45f5cd7c96 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/useless_import_alias.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/useless_import_alias.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -41,7 +42,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// - `lint.isort.required-imports` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.156")] +#[violation_metadata(stable_since = "v0.0.156", category = Category::Complexity)] pub(crate) struct UselessImportAlias { required_import_conflict: bool, } diff --git a/crates/ruff_linter/src/rules/pylint/rules/useless_return.rs b/crates/ruff_linter/src/rules/pylint/rules/useless_return.rs index 003b32a0e7..af71389e7a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/useless_return.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/useless_return.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Fix}; @@ -29,7 +30,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// print(5) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Complexity)] pub(crate) struct UselessReturn; impl AlwaysFixableViolation for UselessReturn { diff --git a/crates/ruff_linter/src/rules/pylint/rules/useless_with_lock.rs b/crates/ruff_linter/src/rules/pylint/rules/useless_with_lock.rs index a45f02cb13..f70e54a25b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/useless_with_lock.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/useless_with_lock.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for lock objects that are created and immediately discarded in @@ -48,7 +49,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `Lock Objects`](https://docs.python.org/3/library/threading.html#lock-objects) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct UselessWithLock; impl Violation for UselessWithLock { diff --git a/crates/ruff_linter/src/rules/pylint/rules/yield_from_in_async_function.rs b/crates/ruff_linter/src/rules/pylint/rules/yield_from_in_async_function.rs index 246870d5c0..79766ff1ff 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/yield_from_in_async_function.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/yield_from_in_async_function.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for uses of `yield from` in async functions. @@ -24,7 +25,7 @@ use crate::Violation; /// yield number /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct YieldFromInAsyncFunction; impl Violation for YieldFromInAsyncFunction { diff --git a/crates/ruff_linter/src/rules/pylint/rules/yield_in_init.rs b/crates/ruff_linter/src/rules/pylint/rules/yield_in_init.rs index 622129ef58..d7043cabba 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/yield_in_init.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/yield_in_init.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pylint::helpers::in_dunder_method; /// ## What it does @@ -29,7 +30,7 @@ use crate::rules::pylint::helpers::in_dunder_method; /// ## References /// - [CodeQL: `py-init-method-is-generator`](https://codeql.github.com/codeql-query-help/python/py-init-method-is-generator/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.245")] +#[violation_metadata(stable_since = "v0.0.245", category = Category::Correctness)] pub(crate) struct YieldInInit; impl Violation for YieldInInit { diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0129_assert_on_string_literal.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__assert-on-string-literal_assert_on_string_literal.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0129_assert_on_string_literal.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__assert-on-string-literal_assert_on_string_literal.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1142_await_outside_async.ipynb.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__await-outside-async_await_outside_async.ipynb.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1142_await_outside_async.ipynb.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__await-outside-async_await_outside_async.ipynb.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1142_await_outside_async.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__await-outside-async_await_outside_async.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1142_await_outside_async.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__await-outside-async_await_outside_async.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW3201_bad_dunder_method_name.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-dunder-method-name_bad_dunder_method_name.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW3201_bad_dunder_method_name.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-dunder-method-name_bad_dunder_method_name.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1501_bad_open_mode.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-open-mode_bad_open_mode.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1501_bad_open_mode.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-open-mode_bad_open_mode.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0211_bad_staticmethod_argument.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-staticmethod-argument_bad_staticmethod_argument.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0211_bad_staticmethod_argument.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-staticmethod-argument_bad_staticmethod_argument.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1310_bad_str_strip_call.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-str-strip-call_bad_str_strip_call.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1310_bad_str_strip_call.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-str-strip-call_bad_str_strip_call.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1300_bad_string_format_character.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-string-format-character_bad_string_format_character.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1300_bad_string_format_character.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-string-format-character_bad_string_format_character.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1307_bad_string_format_type.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-string-format-type_bad_string_format_type.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1307_bad_string_format_type.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bad-string-format-type_bad_string_format_type.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2502_bidirectional_unicode.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bidirectional-unicode_bidirectional_unicode.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2502_bidirectional_unicode.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__bidirectional-unicode_bidirectional_unicode.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0711_binary_op_exception.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__binary-op-exception_binary_op_exception.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0711_binary_op_exception.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__binary-op-exception_binary_op_exception.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1716_boolean_chained_comparison.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__boolean-chained-comparison_boolean_chained_comparison.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1716_boolean_chained_comparison.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__boolean-chained-comparison_boolean_chained_comparison.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__collapsible-else-if_collapsible_else_if.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__collapsible-else-if_collapsible_else_if.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC1901_compare_to_empty_string.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__compare-to-empty-string_compare_to_empty_string.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC1901_compare_to_empty_string.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__compare-to-empty-string_compare_to_empty_string.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0133_comparison_of_constant.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__comparison-of-constant_comparison_of_constant.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0133_comparison_of_constant.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__comparison-of-constant_comparison_of_constant.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0124_comparison_with_itself.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__comparison-with-itself_comparison_with_itself.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0124_comparison_with_itself.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__comparison-with-itself_comparison_with_itself.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0414_import_aliasing_2____init__.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__continue-in-finally_continue_in_finally.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0414_import_aliasing_2____init__.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__continue-in-finally_continue_in_finally.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0206_dict_index_missing_items.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__dict-index-missing-items_dict_index_missing_items.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0206_dict_index_missing_items.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__dict-index-missing-items_dict_index_missing_items.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__dict-iter-missing-items_dict_iter_missing_items.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__dict-iter-missing-items_dict_iter_missing_items.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0241_duplicate_bases.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__duplicate-bases_duplicate_bases.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0241_duplicate_bases.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__duplicate-bases_duplicate_bases.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2044_empty_comment.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__empty-comment_empty_comment.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2044_empty_comment.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__empty-comment_empty_comment.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2044_empty_comment_line_continuation.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__empty-comment_empty_comment_line_continuation.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2044_empty_comment_line_continuation.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__empty-comment_empty_comment_line_continuation.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1641_eq_without_hash.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__eq-without-hash_eq_without_hash.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1641_eq_without_hash.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__eq-without-hash_eq_without_hash.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0604_global_at_module_level.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-at-module-level_global_at_module_level.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0604_global_at_module_level.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-at-module-level_global_at_module_level.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0603_global_statement.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-statement_global_statement.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0603_global_statement.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-statement_global_statement.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0602_global_variable_not_assigned.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-variable-not-assigned_global_variable_not_assigned.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0602_global_variable_not_assigned.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__global-variable-not-assigned_global_variable_not_assigned.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1730_if_stmt_min_max.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__if-stmt-min-max_if_stmt_min_max.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1730_if_stmt_min_max.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__if-stmt-min-max_if_stmt_min_max.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0415_import_outside_top_level.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-outside-top-level_import_outside_top_level.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0415_import_outside_top_level.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-outside-top-level_import_outside_top_level.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2701_import_private_name__submodule____main__.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-private-name_import_private_name__submodule____main__.py.snap similarity index 61% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2701_import_private_name__submodule____main__.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-private-name_import_private_name__submodule____main__.py.snap index 4b0c527088..7430839d66 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2701_import_private_name__submodule____main__.py.snap +++ b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-private-name_import_private_name__submodule____main__.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/pylint/mod.rs -assertion_line: 256 --- PLC2701 Private name import `_a` --> __main__.py:2:6 @@ -84,3 +83,35 @@ PLC2701 Private name import `_bar` from external module `foo` 51 | 52 | from foo. _bar import baz | ^^^^ +53 | +54 | # PLC2701 exceptions: `os._exit` is considered public despite leading underscore. + | + +PLC2701 Private name import `_exit` from external module `another_module` + --> __main__.py:57:28 + | +55 | from os import _exit +56 | from os import _exit as process_exit +57 | from another_module import _exit as another_exit + | ^^^^^ +58 | from os import _private_member +59 | from os import _exit as os_exit, _other_private_member + | + +PLC2701 Private name import `_private_member` from external module `os` + --> __main__.py:58:16 + | +56 | from os import _exit as process_exit +57 | from another_module import _exit as another_exit +58 | from os import _private_member + | ^^^^^^^^^^^^^^^ +59 | from os import _exit as os_exit, _other_private_member + | + +PLC2701 Private name import `_other_private_member` from external module `os` + --> __main__.py:59:34 + | +57 | from another_module import _exit as another_exit +58 | from os import _private_member +59 | from os import _exit as os_exit, _other_private_member + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0406_import_self__module.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-self_import_self__module.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0406_import_self__module.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__import-self_import_self__module.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0605_invalid_all_format.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-all-format_invalid_all_format.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0605_invalid_all_format.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-all-format_invalid_all_format.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0604_invalid_all_object.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-all-object_invalid_all_object.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0604_invalid_all_object.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-all-object_invalid_all_object.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0304_invalid_return_type_bool.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-bool-return-type_invalid_return_type_bool.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0304_invalid_return_type_bool.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-bool-return-type_invalid_return_type_bool.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0308_invalid_return_type_bytes.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-bytes-return-type_invalid_return_type_bytes.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0308_invalid_return_type_bytes.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-bytes-return-type_invalid_return_type_bytes.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-backspace_invalid_characters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-backspace_invalid_characters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-backspace_invalid_characters_syntax_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-backspace_invalid_characters_syntax_error.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2513_invalid_characters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-esc_invalid_characters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2513_invalid_characters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-esc_invalid_characters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2514_invalid_characters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-nul_invalid_characters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2514_invalid_characters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-nul_invalid_characters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2512_invalid_characters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-sub_invalid_characters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2512_invalid_characters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-sub_invalid_characters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2515_invalid_characters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-zero-width-space_invalid_characters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2515_invalid_characters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-character-zero-width-space_invalid_characters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1508_invalid_envvar_default.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-envvar-default_invalid_envvar_default.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1508_invalid_envvar_default.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-envvar-default_invalid_envvar_default.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1507_invalid_envvar_value.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-envvar-value_invalid_envvar_value.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1507_invalid_envvar_value.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-envvar-value_invalid_envvar_value.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0309_invalid_return_type_hash.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-hash-return-type_invalid_return_type_hash.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0309_invalid_return_type_hash.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-hash-return-type_invalid_return_type_hash.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0305_invalid_return_type_index.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-index-return-type_invalid_return_type_index.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0305_invalid_return_type_index.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-index-return-type_invalid_return_type_index.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0303_invalid_return_type_length.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-length-return-type_invalid_return_type_length.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0303_invalid_return_type_length.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-length-return-type_invalid_return_type_length.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0307_invalid_return_type_str.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-str-return-type_invalid_return_type_str.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0307_invalid_return_type_str.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__invalid-str-return-type_invalid_return_type_str.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0208_iteration_over_set.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__iteration-over-set_iteration_over_set.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0208_iteration_over_set.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__iteration-over-set_iteration_over_set.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC1802_len_as_condition.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__len-test_len_as_condition.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC1802_len_as_condition.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__len-test_len_as_condition.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6201_literal_membership.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__literal-membership_literal_membership.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6201_literal_membership.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__literal-membership_literal_membership.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0118_load_before_global_declaration.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__load-before-global-declaration_load_before_global_declaration.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0118_load_before_global_declaration.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__load-before-global-declaration_load_before_global_declaration.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1206_logging_too_few_args.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__logging-too-few-args_logging_too_few_args.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1206_logging_too_few_args.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__logging-too-few-args_logging_too_few_args.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1205_logging_too_many_args.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__logging-too-many-args_logging_too_many_args.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1205_logging_too_many_args.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__logging-too-many-args_logging_too_many_args.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2004_magic_value_comparison.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__magic-value-comparison_magic_value_comparison.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR2004_magic_value_comparison.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__magic-value-comparison_magic_value_comparison.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0402_import_aliasing.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__manual-from-import_import_aliasing.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0402_import_aliasing.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__manual-from-import_import_aliasing.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0704_misplaced_bare_raise.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__misplaced-bare-raise_misplaced_bare_raise.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0704_misplaced_bare_raise.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__misplaced-bare-raise_misplaced_bare_raise.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0207_missing_maxsplit_arg.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__missing-maxsplit-arg_missing_maxsplit_arg.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0207_missing_maxsplit_arg.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__missing-maxsplit-arg_missing_maxsplit_arg.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE4703_modified_iterating_set.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__modified-iterating-set_modified_iterating_set.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE4703_modified_iterating_set.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__modified-iterating-set_modified_iterating_set.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0131_named_expr_without_context.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__named-expr-without-context_named_expr_without_context.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0131_named_expr_without_context.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__named-expr-without-context_named_expr_without_context.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0177_nan_comparison.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nan-comparison_nan_comparison.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0177_nan_comparison.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nan-comparison_nan_comparison.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW3301_nested_min_max.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nested-min-max_nested_min_max.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW3301_nested_min_max.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nested-min-max_nested_min_max.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0202_no_method_decorator.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-classmethod-decorator_no_method_decorator.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0202_no_method_decorator.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-classmethod-decorator_no_method_decorator.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6301_no_self_use.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-self-use_no_self_use.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6301_no_self_use.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-self-use_no_self_use.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0203_no_method_decorator.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-staticmethod-decorator_no_method_decorator.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0203_no_method_decorator.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__no-staticmethod-decorator_no_method_decorator.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2403_non_ascii_module_import.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-ascii-import-name_non_ascii_module_import.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2403_non_ascii_module_import.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-ascii-import-name_non_ascii_module_import.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2401_non_ascii_name.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-ascii-name_non_ascii_name.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2401_non_ascii_name.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-ascii-name_non_ascii_name.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6104_non_augmented_assignment.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-augmented-assignment_non_augmented_assignment.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR6104_non_augmented_assignment.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-augmented-assignment_non_augmented_assignment.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0237_non_slot_assignment.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-slot-assignment_non_slot_assignment.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0237_non_slot_assignment.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__non-slot-assignment_non_slot_assignment.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0115_nonlocal_and_global.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nonlocal-and-global_nonlocal_and_global.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0115_nonlocal_and_global.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nonlocal-and-global_nonlocal_and_global.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0117_nonlocal_without_binding.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nonlocal-without-binding_nonlocal_without_binding.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0117_nonlocal_without_binding.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__nonlocal-without-binding_nonlocal_without_binding.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0643_potential_index_error.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__potential-index-error_potential_index_error.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0643_potential_index_error.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__potential-index-error_potential_index_error.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__preview__PLW0133_useless_exception_statement.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__preview__useless-exception-statement_useless_exception_statement.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__preview__PLW0133_useless_exception_statement.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__preview__useless-exception-statement_useless_exception_statement.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0206_property_with_parameters.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__property-with-parameters_property_with_parameters.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0206_property_with_parameters.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__property-with-parameters_property_with_parameters.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0128_redeclared_assigned_name.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redeclared-assigned-name_redeclared_assigned_name.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0128_redeclared_assigned_name.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redeclared-assigned-name_redeclared_assigned_name.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1704_redefined_argument_from_local.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-argument-from-local_redefined_argument_from_local.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1704_redefined_argument_from_local.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-argument-from-local_redefined_argument_from_local.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW2901_redefined_loop_name.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-loop-name_redefined_loop_name.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW2901_redefined_loop_name.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-loop-name_redefined_loop_name.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0244_redefined_slots_in_subclass.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-slots-in-subclass_redefined_slots_in_subclass.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0244_redefined_slots_in_subclass.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__redefined-slots-in-subclass_redefined_slots_in_subclass.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1714_repeated_equality_comparison.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__repeated-equality-comparison_repeated_equality_comparison.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1714_repeated_equality_comparison.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__repeated-equality-comparison_repeated_equality_comparison.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1132_repeated_keyword_argument.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__repeated-keyword-argument_repeated_keyword_argument.py.snap similarity index 87% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1132_repeated_keyword_argument.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__repeated-keyword-argument_repeated_keyword_argument.py.snap index 0c08b9d9c2..d7ef675f09 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1132_repeated_keyword_argument.py.snap +++ b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__repeated-keyword-argument_repeated_keyword_argument.py.snap @@ -74,6 +74,7 @@ PLE1132 Repeated keyword argument: `c` 19 | func(a=11, b=21, **{"c": 31}, **{"c": 32}) | ^^^ 20 | func(a=11, b=21, **{"c": 31, "c": 32}) +21 | func(**{"a": 11}, a=21) | PLE1132 Repeated keyword argument: `c` @@ -83,3 +84,16 @@ PLE1132 Repeated keyword argument: `c` 19 | func(a=11, b=21, **{"c": 31}, **{"c": 32}) 20 | func(a=11, b=21, **{"c": 31, "c": 32}) | ^^^ +21 | func(**{"a": 11}, a=21) + | + +PLE1132 Repeated keyword argument: `a` + --> repeated_keyword_argument.py:21:9 + | +19 | func(a=11, b=21, **{"c": 31}, **{"c": 32}) +20 | func(a=11, b=21, **{"c": 31, "c": 32}) +21 | func(**{"a": 11}, a=21) + | ^^^ +22 | +23 | # Duplicate explicit keywords are syntax errors, not PLE1132 diagnostics. + | diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0101_return_in_init.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__return-in-init_return_in_init.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0101_return_in_init.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__return-in-init_return_in_init.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0127_self_assigning_variable.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__self-assigning-variable_self_assigning_variable.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0127_self_assigning_variable.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__self-assigning-variable_self_assigning_variable.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0642_self_or_cls_assignment.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__self-or-cls-assignment_self_or_cls_assignment.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0642_self_or_cls_assignment.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__self-or-cls-assignment_self_or_cls_assignment.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1507_shallow_copy_environ.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__shallow-copy-environ_shallow_copy_environ.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1507_shallow_copy_environ.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__shallow-copy-environ_shallow_copy_environ.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0205_single_string_slots.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__single-string-slots_single_string_slots.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0205_single_string_slots.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__single-string-slots_single_string_slots.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1519_singledispatch_method.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__singledispatch-method_singledispatch_method.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1519_singledispatch_method.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__singledispatch-method_singledispatch_method.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1520_singledispatchmethod_function.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__singledispatchmethod-function_singledispatchmethod_function.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1520_singledispatchmethod_function.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__singledispatchmethod-function_singledispatchmethod_function.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1708_stop_iteration_return.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__stop-iteration-return_stop_iteration_return.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1708_stop_iteration_return.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__stop-iteration-return_stop_iteration_return.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1509_subprocess_popen_preexec_fn.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__subprocess-popen-preexec-fn_subprocess_popen_preexec_fn.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1509_subprocess_popen_preexec_fn.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__subprocess-popen-preexec-fn_subprocess_popen_preexec_fn.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1510_subprocess_run_without_check.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__subprocess-run-without-check_subprocess_run_without_check.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1510_subprocess_run_without_check.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__subprocess-run-without-check_subprocess_run_without_check.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0245_super_without_brackets.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__super-without-brackets_super_without_brackets.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0245_super_without_brackets.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__super-without-brackets_super_without_brackets.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1712_swap_with_temporary_variable.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__swap-with-temporary-variable_swap_with_temporary_variable.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1712_swap_with_temporary_variable.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__swap-with-temporary-variable_swap_with_temporary_variable.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_0.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_0.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_0.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_1.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_1.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_1.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_10.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_10.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_10.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_10.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_11.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_11.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_11.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_11.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_12.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_12.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_12.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_12.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_13.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_13.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_13.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_13.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_14.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_14.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_14.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_14.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_15.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_15.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_15.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_15.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_16.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_16.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_16.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_16.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_2.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_2.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_2.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_3.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_3.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_3.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_4.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_4.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_4.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_5.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_5.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_5.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_5.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_6.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_6.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_6.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_6.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_7.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_7.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_7.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_7.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_8.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_8.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_8.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_8.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_9.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_9.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1722_sys_exit_alias_9.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__sys-exit-alias_sys_exit_alias_9.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0913_too_many_arguments.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-arguments_too_many_arguments.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0913_too_many_arguments.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-arguments_too_many_arguments.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0912_too_many_branches.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-branches_too_many_branches.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0912_too_many_branches.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-branches_too_many_branches.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1702_too_many_nested_blocks.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-nested-blocks_too_many_nested_blocks.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1702_too_many_nested_blocks.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-nested-blocks_too_many_nested_blocks.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0917_too_many_positional_arguments.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-positional-arguments_too_many_positional_arguments.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0917_too_many_positional_arguments.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-positional-arguments_too_many_positional_arguments.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0911_too_many_return_statements.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-return-statements_too_many_return_statements.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0911_too_many_return_statements.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-return-statements_too_many_return_statements.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0717_too_many_try_statements.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-statements-in-try-clause_too_many_try_statements.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0717_too_many_try_statements.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-statements-in-try-clause_too_many_try_statements.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0915_too_many_statements.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-statements_too_many_statements.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR0915_too_many_statements.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__too-many-statements_too_many_statements.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0131_type_bivariance.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-bivariance_type_bivariance.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0131_type_bivariance.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-bivariance_type_bivariance.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0105_type_name_incorrect_variance.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-name-incorrect-variance_type_name_incorrect_variance.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0105_type_name_incorrect_variance.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-name-incorrect-variance_type_name_incorrect_variance.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0132_type_param_name_mismatch.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-param-name-mismatch_type_param_name_mismatch.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0132_type_param_name_mismatch.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__type-param-name-mismatch_type_param_name_mismatch.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0302_unexpected_special_method_signature.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unexpected-special-method-signature_unexpected_special_method_signature.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0302_unexpected_special_method_signature.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unexpected-special-method-signature_unexpected_special_method_signature.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1733_unnecessary_dict_index_lookup.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-dict-index-lookup_unnecessary_dict_index_lookup.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1733_unnecessary_dict_index_lookup.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-dict-index-lookup_unnecessary_dict_index_lookup.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC3002_unnecessary_direct_lambda_call.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-direct-lambda-call_unnecessary_direct_lambda_call.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC3002_unnecessary_direct_lambda_call.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-direct-lambda-call_unnecessary_direct_lambda_call.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2801_unnecessary_dunder_call.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-dunder-call_unnecessary_dunder_call.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC2801_unnecessary_dunder_call.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-dunder-call_unnecessary_dunder_call.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0108_unnecessary_lambda.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-lambda_unnecessary_lambda.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0108_unnecessary_lambda.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-lambda_unnecessary_lambda.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1736_unnecessary_list_index_lookup.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-list-index-lookup_unnecessary_list_index_lookup.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1736_unnecessary_list_index_lookup.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unnecessary-list-index-lookup_unnecessary_list_index_lookup.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0101_unreachable.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unreachable-code_unreachable.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0101_unreachable.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unreachable-code_unreachable.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1514_unspecified_encoding.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unspecified-encoding_unspecified_encoding.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1514_unspecified_encoding.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__unspecified-encoding_unspecified_encoding.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0120_useless_else_on_loop.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-else-on-loop_useless_else_on_loop.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0120_useless_else_on_loop.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-else-on-loop_useless_else_on_loop.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0133_useless_exception_statement.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-exception-statement_useless_exception_statement.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW0133_useless_exception_statement.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-exception-statement_useless_exception_statement.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0414_import_aliasing.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-import-alias_import_aliasing.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLC0414_import_aliasing.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-import-alias_import_aliasing.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0116_continue_in_finally.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-import-alias_import_aliasing_2____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0116_continue_in_finally.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-import-alias_import_aliasing_2____init__.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1711_useless_return.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-return_useless_return.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR1711_useless_return.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-return_useless_return.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW2101_useless_with_lock.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-with-lock_useless_with_lock.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW2101_useless_with_lock.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__useless-with-lock_useless_with_lock.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1700_yield_from_in_async_function.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__yield-from-in-async-function_yield_from_in_async_function.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1700_yield_from_in_async_function.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__yield-from-in-async-function_yield_from_in_async_function.py.snap diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0100_yield_in_init.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__yield-in-init_yield_in_init.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE0100_yield_in_init.py.snap rename to crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__yield-in-init_yield_in_init.py.snap diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/convert_named_tuple_functional_to_class.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/convert_named_tuple_functional_to_class.rs index 37d4ad4f76..0a90e9656f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/convert_named_tuple_functional_to_class.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/convert_named_tuple_functional_to_class.rs @@ -14,6 +14,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -52,7 +53,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct ConvertNamedTupleFunctionalToClass { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/convert_typed_dict_functional_to_class.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/convert_typed_dict_functional_to_class.rs index f0c329d137..751f6f1a18 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/convert_typed_dict_functional_to_class.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/convert_typed_dict_functional_to_class.rs @@ -10,6 +10,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -63,7 +64,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// [Python keywords]: https://docs.python.org/3/reference/lexical_analysis.html#keywords /// [Dunder names]: https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Pedantic)] pub(crate) struct ConvertTypedDictFunctionalToClass { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/datetime_utc_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/datetime_utc_alias.rs index b229be1e48..748e8f66d2 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/datetime_utc_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/datetime_utc_alias.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -38,7 +39,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `datetime.UTC`](https://docs.python.org/3/library/datetime.html#datetime.UTC) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.192")] +#[violation_metadata(stable_since = "v0.0.192", category = Category::Style)] pub(crate) struct DatetimeTimezoneUTC; impl Violation for DatetimeTimezoneUTC { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs index a9ad1786f6..e96efa330d 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -52,7 +53,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def prop(self): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.21")] +#[violation_metadata(preview_since = "0.15.21", category = Category::Suspicious)] pub(crate) struct DeprecatedAbcDecorator { from: &'static str, to: &'static str, diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs index af859646de..19eb36d1c3 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -25,7 +26,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `xml.etree.ElementTree`](https://docs.python.org/3/library/xml.etree.elementtree.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.199")] +#[violation_metadata(stable_since = "v0.0.199", category = Category::Suspicious)] pub(crate) struct DeprecatedCElementTree; impl AlwaysFixableViolation for DeprecatedCElementTree { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs index 86c05b9e86..8cd4b12f13 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs @@ -9,6 +9,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pyupgrade::fixes; use crate::rules::pyupgrade::rules::unnecessary_future_import::is_import_required_by_isort; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -64,7 +65,7 @@ enum Deprecation { /// from collections.abc import Sequence /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.239")] +#[violation_metadata(stable_since = "v0.0.239", category = Category::Suspicious)] pub(crate) struct DeprecatedImport { deprecation: Deprecation, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs index 55d921cf42..30f5cc2fac 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs @@ -15,6 +15,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::matchers::{match_import, match_import_from, match_statement}; use crate::fix::codemods::CodegenStylist; use crate::rules::pyupgrade::rules::is_import_required_by_isort; @@ -55,7 +56,7 @@ pub(crate) enum MockReference { /// - [Python documentation: `unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) /// - [PyPI: `mock`](https://pypi.org/project/mock/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.206")] +#[violation_metadata(stable_since = "v0.0.206", category = Category::Suspicious)] pub(crate) struct DeprecatedMockImport { reference_type: MockReference, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs index 5804d543d4..707505f439 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs @@ -6,6 +6,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -39,7 +40,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python 3.11 documentation: Deprecated aliases](https://docs.python.org/3.11/library/unittest.html#deprecated-aliases) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Suspicious)] pub(crate) struct DeprecatedUnittestAlias { alias: String, target: String, diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/extraneous_parentheses.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/extraneous_parentheses.rs index 76b3ef86b2..9faa2658a3 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/extraneous_parentheses.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/extraneous_parentheses.rs @@ -6,6 +6,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -25,7 +26,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// print("Hello, world") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.228")] +#[violation_metadata(stable_since = "v0.0.228", category = Category::Complexity)] pub(crate) struct ExtraneousParentheses; impl AlwaysFixableViolation for ExtraneousParentheses { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 4e5b1ec075..b281478d9f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -16,6 +16,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pyflakes::format::FormatSummary; use crate::rules::pyupgrade::helpers::{curly_escape, curly_unescape}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -40,7 +41,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: f-strings](https://docs.python.org/3/reference/lexical_analysis.html#f-strings) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.224")] +#[violation_metadata(stable_since = "v0.0.224", category = Category::Complexity)] pub(crate) struct FString; impl Violation for FString { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs index c60a9efa14..e57ef1cb65 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs @@ -11,6 +11,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::matchers::{ match_attribute, match_call_mut, match_expression, transform_expression_text, }; @@ -47,7 +48,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax) /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.218")] +#[violation_metadata(stable_since = "v0.0.218", category = Category::Complexity)] pub(crate) struct FormatLiterals; impl Violation for FormatLiterals { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_with_maxsize_none.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_with_maxsize_none.rs index a7a0ef90ab..d3e2c912eb 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_with_maxsize_none.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_with_maxsize_none.rs @@ -5,6 +5,7 @@ use ruff_text_size::{Ranged, TextRange}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -45,7 +46,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `@functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.225")] +#[violation_metadata(stable_since = "v0.0.225", category = Category::Style)] pub(crate) struct LRUCacheWithMaxsizeNone; impl AlwaysFixableViolation for LRUCacheWithMaxsizeNone { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_without_parameters.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_without_parameters.rs index 0cbd19f442..a10581256c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_without_parameters.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/lru_cache_without_parameters.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Decorator, Expr}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -39,7 +40,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: `@functools.lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache) /// - [Let lru_cache be used as a decorator with no arguments](https://github.com/python/cpython/issues/80953) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct LRUCacheWithoutParameters; impl AlwaysFixableViolation for LRUCacheWithoutParameters { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs index 1ee8efc6fb..ca0e3d667e 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs @@ -41,6 +41,7 @@ pub(crate) use use_pep604_isinstance::*; pub(crate) use useless_class_metaclass_type::*; pub(crate) use useless_metaclass_type::*; pub(crate) use useless_object_inheritance::*; +pub(crate) use while_one::*; pub(crate) use yield_in_for_loop::*; mod convert_named_tuple_functional_to_class; @@ -86,4 +87,5 @@ mod use_pep604_isinstance; mod useless_class_metaclass_type; mod useless_metaclass_type; mod useless_object_inheritance; +mod while_one; mod yield_in_for_loop; diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/native_literals.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/native_literals.rs index 8ca73f6319..ba3f2451d0 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/native_literals.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/native_literals.rs @@ -7,6 +7,7 @@ use ruff_source_file::find_newline; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; #[derive(Debug, PartialEq, Eq, Copy, Clone)] @@ -141,7 +142,7 @@ impl fmt::Display for LiteralType { /// - [Python documentation: `bool`](https://docs.python.org/3/library/functions.html#bool) /// - [Python documentation: `complex`](https://docs.python.org/3/library/functions.html#complex) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.193")] +#[violation_metadata(stable_since = "v0.0.193", category = Category::Style)] pub(crate) struct NativeLiterals { literal_type: LiteralType, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/non_pep646_unpack.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/non_pep646_unpack.rs index 2a539bb278..3fec7abc9b 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/non_pep646_unpack.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/non_pep646_unpack.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{Expr, ExprSubscript, PythonVersion}; use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [PEP 646]: https://peps.python.org/pep-0646/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Complexity)] pub(crate) struct NonPEP646Unpack; impl Violation for NonPEP646Unpack { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/open_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/open_alias.rs index 4939511534..e625c3bc12 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/open_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/open_alias.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -33,7 +34,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `io.open`](https://docs.python.org/3/library/io.html#io.open) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.196")] +#[violation_metadata(stable_since = "v0.0.196", category = Category::Style)] pub(crate) struct OpenAlias; impl Violation for OpenAlias { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/os_error_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/os_error_alias.rs index 1f57816bc1..83936121f5 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/os_error_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/os_error_alias.rs @@ -6,6 +6,7 @@ use ruff_python_ast::name::{Name, UnqualifiedName}; use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::preview::is_up024_precise_highlighting_enabled; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -36,7 +37,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `OSError`](https://docs.python.org/3/library/exceptions.html#OSError) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.206")] +#[violation_metadata(stable_since = "v0.0.206", category = Category::Suspicious)] pub(crate) struct OSErrorAlias { name: Option, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs index 06ad8060e9..10e9cd34f2 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs @@ -11,6 +11,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{adjust_indentation, delete_stmt}; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -52,7 +53,7 @@ use ruff_python_semantic::SemanticModel; /// ## References /// - [Python documentation: `sys.version_info`](https://docs.python.org/3/library/sys.html#sys.version_info) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.240")] +#[violation_metadata(stable_since = "v0.0.240", category = Category::Suspicious)] pub(crate) struct OutdatedVersionBlock { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs index 44e74c8c87..f3075795be 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{ExprSubscript, StmtClassDef}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -131,7 +132,7 @@ use super::{ /// [source of confusion]: https://peps.python.org/pep-0695/#points-of-confusion /// [type alias]: https://docs.python.org/3/reference/simple_stmts.html#type-aliases #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct NonPEP695GenericClass { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs index 1f380060f4..332e6593a5 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs @@ -6,6 +6,7 @@ use ruff_python_ast::visitor::Visitor; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -84,7 +85,7 @@ use super::{DisplayTypeVars, TypeVarReferenceVisitor, check_type_vars, in_nested /// [UP049]: https://docs.astral.sh/ruff/rules/private-type-parameter/ /// [fail]: https://github.com/python/mypy/issues/18507 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct NonPEP695GenericFunction { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index e4224dc585..b664b5e9d2 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -8,6 +8,7 @@ use ruff_python_ast::{Expr, ExprCall, ExprName, Keyword, StmtAnnAssign, StmtAssi use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_type_var_default_enabled; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -89,7 +90,7 @@ use super::{ /// [UP047]: https://docs.astral.sh/ruff/rules/non-pep695-generic-function/ /// [UP049]: https://docs.astral.sh/ruff/rules/private-type-parameter/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.283")] +#[violation_metadata(stable_since = "v0.0.283", category = Category::Complexity)] pub(crate) struct NonPEP695TypeAlias { name: String, type_alias_kind: TypeAliasKind, diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/private_type_parameter.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/private_type_parameter.rs index c5a20c83dc..9d73615569 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/private_type_parameter.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/private_type_parameter.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Binding; use ruff_python_stdlib::identifiers::is_identifier; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Applicability, Fix, FixAvailability, Violation}; use crate::{ checkers::ast::Checker, @@ -66,7 +67,7 @@ use crate::{ /// [UP046]: https://docs.astral.sh/ruff/rules/non-pep695-generic-class /// [PYI018]: https://docs.astral.sh/ruff/rules/unused-private-type-var #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Style)] pub(crate) struct PrivateTypeParameter { kind: ParamKind, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs index fc9de0b4bb..7396addf26 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs @@ -15,6 +15,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::pyupgrade::helpers::curly_escape; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -75,7 +76,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `printf`-style String Formatting](https://docs.python.org/3/library/stdtypes.html#old-string-formatting) /// - [Python documentation: `str.format`](https://docs.python.org/3/library/stdtypes.html#str.format) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Style)] pub(crate) struct PrintfStringFormatting; impl Violation for PrintfStringFormatting { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs index 91a93a4feb..8fd5b0fb05 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs @@ -6,6 +6,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -87,7 +88,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [TC008]: https://docs.astral.sh/ruff/rules/quoted-type-alias/ /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.242")] +#[violation_metadata(stable_since = "v0.0.242", category = Category::Correctness)] pub(crate) struct QuotedAnnotation; impl AlwaysFixableViolation for QuotedAnnotation { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/redundant_open_modes.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/redundant_open_modes.rs index ec10105cb1..11c2b53b73 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/redundant_open_modes.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/redundant_open_modes.rs @@ -6,6 +6,7 @@ use ruff_python_stdlib::open_mode::OpenMode; use ruff_text_size::{Ranged, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `open`](https://docs.python.org/3/library/functions.html#open) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Pedantic)] pub(crate) struct RedundantOpenModes { replacement: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_stdout_stderr.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_stdout_stderr.rs index 6ce403647d..e9d4b32641 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_stdout_stderr.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_stdout_stderr.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -43,7 +44,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python 3.7 release notes](https://docs.python.org/3/whatsnew/3.7.html#subprocess) /// - [Python documentation: `subprocess.run`](https://docs.python.org/3/library/subprocess.html#subprocess.run) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.199")] +#[violation_metadata(stable_since = "v0.0.199", category = Category::Complexity)] pub(crate) struct ReplaceStdoutStderr; impl Violation for ReplaceStdoutStderr { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_str_enum.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_str_enum.rs index 27241b6928..14c8ce4214 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_str_enum.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_str_enum.rs @@ -4,6 +4,7 @@ use ruff_python_ast::identifier::Identifier; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -81,7 +82,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [breaking change]: https://blog.pecar.me/python-enum #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct ReplaceStrEnum { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_universal_newlines.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_universal_newlines.rs index b7b13c7b66..fe23f88c53 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/replace_universal_newlines.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/replace_universal_newlines.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -35,7 +36,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python 3.7 release notes](https://docs.python.org/3/whatsnew/3.7.html#subprocess) /// - [Python documentation: `subprocess.run`](https://docs.python.org/3/library/subprocess.html#subprocess.run) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.196")] +#[violation_metadata(stable_since = "v0.0.196", category = Category::Style)] pub(crate) struct ReplaceUniversalNewlines; impl AlwaysFixableViolation for ReplaceUniversalNewlines { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/super_call_with_parameters.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/super_call_with_parameters.rs index 8acf96cc74..bd66ed782c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/super_call_with_parameters.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/super_call_with_parameters.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{Scope, ScopeKind, SemanticModel}; use ruff_text_size::{Ranged, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -52,7 +53,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `super`](https://docs.python.org/3/library/functions.html#super) /// - [super/MRO, Python's most misunderstood feature.](https://www.youtube.com/watch?v=X1PQ7zzltz4) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct SuperCallWithParameters; impl Violation for SuperCallWithParameters { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/timeout_error_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/timeout_error_alias.rs index a3afb73988..f1168e96c5 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/timeout_error_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/timeout_error_alias.rs @@ -8,6 +8,7 @@ use ruff_python_ast::name::{Name, UnqualifiedName}; use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -45,7 +46,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `TimeoutError`](https://docs.python.org/3/library/exceptions.html#TimeoutError) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct TimeoutErrorAlias { name: Option, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/type_of_primitive.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/type_of_primitive.rs index 08acc9850d..3f679f9a14 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/type_of_primitive.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/type_of_primitive.rs @@ -3,6 +3,7 @@ use ruff_python_ast::Expr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -30,7 +31,7 @@ use crate::rules::pyupgrade::types::Primitive; /// - [Python documentation: `type()`](https://docs.python.org/3/library/functions.html#type) /// - [Python documentation: Built-in types](https://docs.python.org/3/library/stdtypes.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct TypeOfPrimitive { primitive: Primitive, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs index 175c760374..c92263fd06 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -31,7 +32,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `typing.Text`](https://docs.python.org/3/library/typing.html#typing.Text) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.195")] +#[violation_metadata(stable_since = "v0.0.195", category = Category::Suspicious)] pub(crate) struct TypingTextStrAlias { module: TypingModule, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unicode_kind_prefix.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unicode_kind_prefix.rs index 8e162fea30..c6537cb473 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unicode_kind_prefix.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unicode_kind_prefix.rs @@ -3,6 +3,7 @@ use ruff_python_ast::StringLiteral; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -25,7 +26,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: Unicode HOWTO](https://docs.python.org/3/howto/unicode.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.201")] +#[violation_metadata(stable_since = "v0.0.201", category = Category::Complexity)] pub(crate) struct UnicodeKindPrefix; impl AlwaysFixableViolation for UnicodeKindPrefix { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_builtin_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_builtin_import.rs index a53d878d8a..ee6d2a6205 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_builtin_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_builtin_import.rs @@ -5,6 +5,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::rules::pyupgrade::rules::is_import_required_by_isort; use crate::{AlwaysFixableViolation, Fix}; @@ -41,7 +42,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## References /// - [Python documentation: The Python Standard Library](https://docs.python.org/3/library/index.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.211")] +#[violation_metadata(stable_since = "v0.0.211", category = Category::Suspicious)] pub(crate) struct UnnecessaryBuiltinImport { pub names: Vec, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_class_parentheses.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_class_parentheses.rs index ac1253fa54..2284645e85 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_class_parentheses.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_class_parentheses.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -30,7 +31,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// This rule's fix is marked as unsafe if it would delete any comments /// within the parentheses range. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Style)] pub(crate) struct UnnecessaryClassParentheses; impl AlwaysFixableViolation for UnnecessaryClassParentheses { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs index e2e8f10f60..00d94c7d13 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PEP 3120]: https://peps.python.org/pep-3120/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Complexity)] pub(crate) struct UTF8EncodingDeclaration; impl AlwaysFixableViolation for UTF8EncodingDeclaration { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs index 87644b0981..302a3068cd 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -60,7 +61,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// - [Python documentation: `typing.Generator`](https://docs.python.org/3/library/typing.html#typing.Generator) /// - [Python documentation: `typing.AsyncGenerator`](https://docs.python.org/3/library/typing.html#typing.AsyncGenerator) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Complexity)] pub(crate) struct UnnecessaryDefaultTypeArgs; impl AlwaysFixableViolation for UnnecessaryDefaultTypeArgs { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs index 8c418ae40c..d468b53f58 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs @@ -8,30 +8,35 @@ use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, pad, remove_argument}; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does -/// Checks for unnecessary calls to `encode` as UTF-8. +/// Checks for unnecessary calls to `encode` as UTF-8 and unnecessary explicit +/// UTF-8 encoding arguments. /// /// ## Why is this bad? -/// UTF-8 is the default encoding in Python, so there is no need to call -/// `encode` when UTF-8 is the desired encoding. Instead, use a bytes literal. +/// UTF-8 is the default encoding in Python, so there is no need to pass an +/// explicit UTF-8 encoding to `encode`. For ASCII literals, use a bytes literal +/// instead; for other strings, omit the explicit encoding argument. /// /// ## Example /// ```python /// "foo".encode("utf-8") +/// "unicode text©".encode(encoding="utf-8") /// ``` /// /// Use instead: /// ```python /// b"foo" +/// "unicode text©".encode() /// ``` /// /// ## References /// - [Python documentation: `str.encode`](https://docs.python.org/3/library/stdtypes.html#str.encode) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Complexity)] pub(crate) struct UnnecessaryEncodeUTF8 { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs index 85777ae17d..7f5220f951 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_future_import.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::{NameImport, Scope}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Applicability, Fix}; @@ -44,7 +45,7 @@ use crate::{AlwaysFixableViolation, Applicability, Fix}; /// ## References /// - [Python documentation: `__future__` — Future statement definitions](https://docs.python.org/3/library/__future__.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Complexity)] pub(crate) struct UnnecessaryFutureImport<'a> { pub names: &'a [&'a str], } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unpacked_list_comprehension.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unpacked_list_comprehension.rs index 7cb86fe030..75bb72b039 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unpacked_list_comprehension.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unpacked_list_comprehension.rs @@ -1,6 +1,7 @@ use ruff_macros::ViolationMetadata; use crate::Violation; +use crate::codes::Category; /// ## Removed /// There's no [evidence](https://github.com/astral-sh/ruff/issues/12754) that generators are @@ -28,7 +29,7 @@ use crate::Violation; /// - [Python documentation: Generator expressions](https://docs.python.org/3/reference/expressions.html#generator-expressions) /// - [Python documentation: List comprehensions](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions) #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.8.0")] +#[violation_metadata(removed_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct UnpackedListComprehension; impl Violation for UnpackedListComprehension { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs index 458be30990..c43cbbb785 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::ModuleMember; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::preview::is_up006_future_annotations_fix_enabled; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -62,7 +63,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// [PEP 585]: https://peps.python.org/pep-0585/ /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Suspicious)] pub(crate) struct NonPEP585Annotation { from: String, to: String, diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs index 63745d8e43..a176ba5f77 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs @@ -8,7 +8,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -use crate::codes::Rule; +use crate::codes::{Category, Rule}; use crate::fix::edits::pad; use crate::preview::is_pep604_future_annotations_fix_enabled; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -64,7 +64,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// [PEP 604]: https://peps.python.org/pep-0604/ /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct NonPEP604AnnotationUnion; impl Violation for NonPEP604AnnotationUnion { @@ -128,7 +128,7 @@ impl Violation for NonPEP604AnnotationUnion { /// [PEP 604]: https://peps.python.org/pep-0604/ /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Style)] pub(crate) struct NonPEP604AnnotationOptional; impl Violation for NonPEP604AnnotationOptional { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_isinstance.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_isinstance.rs index b817a55676..e9e85aa40b 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_isinstance.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_isinstance.rs @@ -6,6 +6,7 @@ use ruff_python_ast::helpers::pep_604_union; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; #[derive(Debug, PartialEq, Eq, Copy, Clone)] @@ -72,7 +73,7 @@ impl CallKind { /// [PEP 604]: https://peps.python.org/pep-0604/ /// [PEP 695]: https://peps.python.org/pep-0695/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.13.0")] +#[violation_metadata(removed_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct NonPEP604Isinstance { kind: CallKind, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_class_metaclass_type.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_class_metaclass_type.rs index 20d3f64461..1f1ca95582 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_class_metaclass_type.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_class_metaclass_type.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Fix, FixAvailability, Violation}; use ruff_diagnostics::Applicability; @@ -29,7 +30,7 @@ use ruff_text_size::Ranged; /// ## References /// - [PEP 3115 – Metaclasses in Python 3000](https://peps.python.org/pep-3115/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Correctness)] pub(crate) struct UselessClassMetaclassType { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_metaclass_type.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_metaclass_type.rs index 2d2d7ac868..08d48e8d68 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_metaclass_type.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_metaclass_type.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix; use crate::{AlwaysFixableViolation, Fix}; @@ -29,7 +30,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## References /// - [PEP 3115 – Metaclasses in Python 3000](https://peps.python.org/pep-3115/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Style)] pub(crate) struct UselessMetaclassType; impl AlwaysFixableViolation for UselessMetaclassType { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_object_inheritance.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_object_inheritance.rs index a1b0d900f8..638afaa4cd 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/useless_object_inheritance.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/useless_object_inheritance.rs @@ -4,6 +4,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{AlwaysFixableViolation, Fix}; @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Fix}; /// ## References /// - [PEP 3115 – Metaclasses in Python 3000](https://peps.python.org/pep-3115/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Complexity)] pub(crate) struct UselessObjectInheritance { name: String, } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/while_one.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/while_one.rs new file mode 100644 index 0000000000..166ca89769 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/while_one.rs @@ -0,0 +1,69 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, Expr, Number}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::codes::Category; +use crate::{AlwaysFixableViolation, Edit, Fix}; + +/// ## What it does +/// Checks for `while` loops that use `1` as their condition. +/// +/// ## Why is this bad? +/// `while 1:` is a Python 2 idiom, where `True` was a global that could be +/// rebound and so had to be loaded and tested on every iteration. In Python 3 +/// `True` is a keyword, so both spellings compile to the same bytecode and +/// `while True:` is clearer about the loop being infinite. +/// +/// ## Example +/// ```python +/// while 1: +/// print("Hello, world!") +/// ``` +/// +/// Use instead: +/// ```python +/// while True: +/// print("Hello, world!") +/// ``` +/// +/// ## References +/// - [Python documentation: `while`](https://docs.python.org/3/reference/compound_stmts.html#the-while-statement) +/// - [PEP 285 – Adding a bool type](https://peps.python.org/pep-0285/) +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "0.16.3", category = Category::Style)] +pub(crate) struct WhileOne; + +impl AlwaysFixableViolation for WhileOne { + #[derive_message_formats] + fn message(&self) -> String { + "Use `while True:` instead of `while 1:`".to_string() + } + + fn fix_title(&self) -> String { + "Replace with `True`".to_string() + } +} + +/// UP048 +pub(crate) fn while_one(checker: &Checker, while_stmt: &ast::StmtWhile) { + let Expr::NumberLiteral(ast::ExprNumberLiteral { + value: Number::Int(value), + .. + }) = &*while_stmt.test + else { + return; + }; + + // Also covers other spellings of one, such as `0x1`. + if value.as_u8() != Some(1) { + return; + } + + let range = while_stmt.test.range(); + let mut diagnostic = checker.report_diagnostic(WhileOne, range); + diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( + "True".to_string(), + range, + ))); +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/yield_in_for_loop.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/yield_in_for_loop.rs index 7c8d900da4..7e92d7c357 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/yield_in_for_loop.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/yield_in_for_loop.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: The `yield` statement](https://docs.python.org/3/reference/simple_stmts.html#the-yield-statement) /// - [PEP 380 – Syntax for Delegating to a Subgenerator](https://peps.python.org/pep-0380/) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.210")] +#[violation_metadata(stable_since = "v0.0.210", category = Category::Style)] pub(crate) struct YieldInForLoop; impl Violation for YieldInForLoop { diff --git a/crates/ruff_linter/src/rules/refurb/helpers.rs b/crates/ruff_linter/src/rules/refurb/helpers.rs index 74aec79c3f..f6375410a3 100644 --- a/crates/ruff_linter/src/rules/refurb/helpers.rs +++ b/crates/ruff_linter/src/rules/refurb/helpers.rs @@ -8,6 +8,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; use crate::rules::flake8_async::rules::blocking_open_call::is_open_call_from_pathlib; +use crate::rules::flake8_use_pathlib::helpers::is_file_descriptor; use crate::{Applicability, Edit, Fix}; /// Format a code snippet to call `name.method()`. @@ -284,6 +285,12 @@ fn find_file_open<'a>( // Match positional arguments, get filename and mode. let (filename, pos_mode) = match_open_args(args)?; + // `open` accepts a file descriptor, but `Path` does not, so a `pathlib` replacement + // would fail at runtime. `PTH123` skips these for the same reason. + if is_file_descriptor(filename, semantic) { + return None; + } + // Match keyword arguments, get keyword arguments to forward and possibly mode. let (keywords, kw_mode) = match_open_keywords(keywords, read_mode, python_version)?; diff --git a/crates/ruff_linter/src/rules/refurb/mod.rs b/crates/ruff_linter/src/rules/refurb/mod.rs index a1338ad22b..342ea1a832 100644 --- a/crates/ruff_linter/src/rules/refurb/mod.rs +++ b/crates/ruff_linter/src/rules/refurb/mod.rs @@ -58,7 +58,7 @@ mod tests { #[test_case(Rule::SubclassBuiltin, Path::new("FURB189.py"))] #[test_case(Rule::FromisoformatReplaceZ, Path::new("FURB162.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("refurb").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), diff --git a/crates/ruff_linter/src/rules/refurb/rules/bit_count.rs b/crates/ruff_linter/src/rules/refurb/rules/bit_count.rs index 796453fff5..8dfbe7b2e7 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/bit_count.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/bit_count.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, Reso use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -39,7 +40,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## References /// - [Python documentation:`int.bit_count`](https://docs.python.org/3/library/stdtypes.html#int.bit_count) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct BitCount { existing: SourceCodeSnippet, replacement: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/refurb/rules/check_and_remove_from_set.rs b/crates/ruff_linter/src/rules/refurb/rules/check_and_remove_from_set.rs index 8289a6a640..8c1fc505b4 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/check_and_remove_from_set.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/check_and_remove_from_set.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::is_set; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -38,9 +39,9 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ``` /// /// ## References -/// - [Python documentation: `set.discard()`](https://docs.python.org/3/library/stdtypes.html?highlight=list#frozenset.discard) +/// - [Python documentation: `set.discard()`](https://docs.python.org/3/library/stdtypes.html#set.discard) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct CheckAndRemoveFromSet { element: SourceCodeSnippet, set: String, diff --git a/crates/ruff_linter/src/rules/refurb/rules/delete_full_slice.rs b/crates/ruff_linter/src/rules/refurb/rules/delete_full_slice.rs index 08ffd45463..47f8118b48 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/delete_full_slice.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/delete_full_slice.rs @@ -1,17 +1,17 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr}; use ruff_python_semantic::SemanticModel; -use ruff_python_semantic::analyze::typing::{is_dict, is_list}; +use ruff_python_semantic::analyze::typing::is_list; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::refurb::helpers::generate_method_call; /// ## What it does -/// Checks for `del` statements that delete the entire slice of a list or -/// dictionary. +/// Checks for `del` statements that delete the entire slice of a list. /// /// ## Why is this bad? /// It is faster and more succinct to remove all items via the `clear()` @@ -19,32 +19,28 @@ use crate::rules::refurb::helpers::generate_method_call; /// /// ## Known problems /// This rule is prone to false negatives due to type inference limitations, -/// as it will only detect lists and dictionaries that are instantiated as -/// literals or annotated with a type annotation. +/// as it will only detect lists that are instantiated as literals or annotated +/// with a type annotation. /// /// ## Example /// ```python -/// names = {"key": "value"} /// nums = [1, 2, 3] /// -/// del names[:] /// del nums[:] /// ``` /// /// Use instead: /// ```python -/// names = {"key": "value"} /// nums = [1, 2, 3] /// -/// names.clear() /// nums.clear() /// ``` /// /// ## References -/// - [Python documentation: Mutable Sequence Types](https://docs.python.org/3/library/stdtypes.html?highlight=list#mutable-sequence-types) -/// - [Python documentation: `dict.clear()`](https://docs.python.org/3/library/stdtypes.html?highlight=list#dict.clear) +/// - [Python documentation: Mutable Sequence Types](https://docs.python.org/3/library/stdtypes.html#typesseq-mutable) +/// - [Python documentation: `list.clear()`](https://docs.python.org/3/library/stdtypes.html#sequence.clear) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.287")] +#[violation_metadata(preview_since = "v0.0.287", category = Category::Complexity)] pub(crate) struct DeleteFullSlice; impl Violation for DeleteFullSlice { @@ -81,7 +77,7 @@ pub(crate) fn delete_full_slice(checker: &Checker, delete: &ast::StmtDelete) { } } -/// Match `del expr[:]` where `expr` is a list or a dict. +/// Match `del expr[:]` where `expr` is a list. fn match_full_slice<'a>(expr: &'a Expr, semantic: &SemanticModel) -> Option<&'a ast::ExprName> { // Check that it is `del expr[...]`. let subscript = expr.as_subscript_expr()?; @@ -100,10 +96,10 @@ fn match_full_slice<'a>(expr: &'a Expr, semantic: &SemanticModel) -> Option<&'a return None; } - // It should only apply to variables that are known to be lists or dicts. + // It should only apply to variables that are known to be lists. let name = subscript.value.as_name_expr()?; let binding = semantic.binding(semantic.only_binding(name)?); - if !(is_dict(binding, semantic) || is_list(binding, semantic)) { + if !is_list(binding, semantic) { return None; } diff --git a/crates/ruff_linter/src/rules/refurb/rules/for_loop_set_mutations.rs b/crates/ruff_linter/src/rules/refurb/rules/for_loop_set_mutations.rs index e55aca2d5e..3a27cafed5 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/for_loop_set_mutations.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/for_loop_set_mutations.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{Expr, Stmt, StmtFor}; use ruff_python_semantic::analyze::typing; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::refurb::helpers::IterLocation; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -44,7 +45,7 @@ use crate::rules::refurb::helpers::parenthesize_loop_iter_if_necessary; /// ## References /// - [Python documentation: `set`](https://docs.python.org/3/library/stdtypes.html#set) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.5")] +#[violation_metadata(preview_since = "v0.3.5", category = Category::Complexity)] pub(crate) struct ForLoopSetMutations { method_name: &'static str, batch_method_name: &'static str, diff --git a/crates/ruff_linter/src/rules/refurb/rules/for_loop_writes.rs b/crates/ruff_linter/src/rules/refurb/rules/for_loop_writes.rs index 23d24788ca..8ae5f693dd 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/for_loop_writes.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/for_loop_writes.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::{Binding, ScopeId, SemanticModel, TypingOnlyBindingsSt use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::refurb::helpers::IterLocation; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; @@ -47,7 +48,7 @@ use crate::rules::refurb::helpers::parenthesize_loop_iter_if_necessary; /// ## References /// - [Python documentation: `io.IOBase.writelines`](https://docs.python.org/3/library/io.html#io.IOBase.writelines) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct ForLoopWrites { name: String, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/fromisoformat_replace_z.rs b/crates/ruff_linter/src/rules/refurb/rules/fromisoformat_replace_z.rs index b2b3193d9e..fb1bd2fefa 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/fromisoformat_replace_z.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/fromisoformat_replace_z.rs @@ -8,6 +8,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -67,7 +68,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [iso-8601]: https://www.iso.org/obp/ui/#iso:std:iso:8601 /// [fromisoformat]: https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct FromisoformatReplaceZ; impl AlwaysFixableViolation for FromisoformatReplaceZ { diff --git a/crates/ruff_linter/src/rules/refurb/rules/fstring_number_format.rs b/crates/ruff_linter/src/rules/refurb/rules/fstring_number_format.rs index 0c834fe237..1c0c284753 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/fstring_number_format.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/fstring_number_format.rs @@ -4,6 +4,7 @@ use ruff_source_file::find_newline; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -32,7 +33,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// or introduce syntax errors. The fix for integer literals is also marked as unsafe /// if the expression contains comments that would be removed by the fix. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Pedantic)] pub(crate) struct FStringNumberFormat { replacement: Option, base: Base, diff --git a/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs b/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs index 8194ac87d4..6b2f851b89 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs @@ -3,6 +3,7 @@ use ruff_python_ast::ExprStringLiteral; use ruff_text_size::TextRange; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -29,7 +30,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: String constants](https://docs.python.org/3/library/string.html#string-constants) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.7.0")] +#[violation_metadata(preview_since = "0.7.0", category = Category::Complexity)] pub(crate) struct HardcodedStringCharset { name: &'static str, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/hashlib_digest_hex.rs b/crates/ruff_linter/src/rules/refurb/rules/hashlib_digest_hex.rs index 4365053279..127a476fdf 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/hashlib_digest_hex.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/hashlib_digest_hex.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -35,7 +36,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `hashlib`](https://docs.python.org/3/library/hashlib.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct HashlibDigestHex; impl Violation for HashlibDigestHex { diff --git a/crates/ruff_linter/src/rules/refurb/rules/if_exp_instead_of_or_operator.rs b/crates/ruff_linter/src/rules/refurb/rules/if_exp_instead_of_or_operator.rs index 89cb790093..d80b93ae24 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/if_exp_instead_of_or_operator.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/if_exp_instead_of_or_operator.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -42,7 +43,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// (assuming `foo()` returns a truthy value), but only once in /// `foo() or bar()`. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Complexity)] pub(crate) struct IfExpInsteadOfOrOperator; impl Violation for IfExpInsteadOfOrOperator { diff --git a/crates/ruff_linter/src/rules/refurb/rules/if_expr_min_max.rs b/crates/ruff_linter/src/rules/refurb/rules/if_expr_min_max.rs index 577efa94da..53e5baea04 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/if_expr_min_max.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/if_expr_min_max.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, CmpOp, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -36,10 +37,10 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// This rule's fix is marked as safe, unless the expression contains comments. /// /// ## References -/// - [Python documentation: `min`](https://docs.python.org/3.11/library/functions.html#min) -/// - [Python documentation: `max`](https://docs.python.org/3.11/library/functions.html#max) +/// - [Python documentation: `min`](https://docs.python.org/3/library/functions.html#min) +/// - [Python documentation: `max`](https://docs.python.org/3/library/functions.html#max) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct IfExprMinMax { min_max: MinMax, expression: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/refurb/rules/implicit_cwd.rs b/crates/ruff_linter/src/rules/refurb/rules/implicit_cwd.rs index dffe4feee6..eddd159cb5 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/implicit_cwd.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/implicit_cwd.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr, ExprAttribute, ExprCall}; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::{checkers::ast::Checker, importer::ImportRequest}; @@ -29,7 +30,7 @@ use crate::{checkers::ast::Checker, importer::ImportRequest}; /// ## References /// - [Python documentation: `Path.cwd`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.cwd) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct ImplicitCwd; impl Violation for ImplicitCwd { diff --git a/crates/ruff_linter/src/rules/refurb/rules/int_on_sliced_str.rs b/crates/ruff_linter/src/rules/refurb/rules/int_on_sliced_str.rs index f84fdac8ed..5a56954827 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/int_on_sliced_str.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/int_on_sliced_str.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{Expr, ExprCall, Identifier}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -48,7 +49,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `int`](https://docs.python.org/3/library/functions.html#int) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct IntOnSlicedStr { base: u8, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/isinstance_type_none.rs b/crates/ruff_linter/src/rules/refurb/rules/isinstance_type_none.rs index c909bd044a..c011f6a386 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/isinstance_type_none.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/isinstance_type_none.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::refurb::helpers::replace_with_identity_check; use crate::{FixAvailability, Violation}; @@ -33,7 +34,7 @@ use crate::{FixAvailability, Violation}; /// - [Python documentation: `type`](https://docs.python.org/3/library/functions.html#type) /// - [Python documentation: Identity comparisons](https://docs.python.org/3/reference/expressions.html#is-not) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct IsinstanceTypeNone; impl Violation for IsinstanceTypeNone { diff --git a/crates/ruff_linter/src/rules/refurb/rules/list_reverse_copy.rs b/crates/ruff_linter/src/rules/refurb/rules/list_reverse_copy.rs index b9f894177e..09c2130eb1 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/list_reverse_copy.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/list_reverse_copy.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -47,7 +48,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: More on Lists](https://docs.python.org/3/tutorial/datastructures.html#more-on-lists) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Pedantic)] pub(crate) struct ListReverseCopy { name: String, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/math_constant.rs b/crates/ruff_linter/src/rules/refurb/rules/math_constant.rs index 8672237644..35b8478825 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/math_constant.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/math_constant.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{self as ast, Number}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -28,7 +29,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `math` constants](https://docs.python.org/3/library/math.html#constants) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.6")] +#[violation_metadata(preview_since = "v0.1.6", category = Category::Correctness)] pub(crate) struct MathConstant { literal: String, constant: &'static str, diff --git a/crates/ruff_linter/src/rules/refurb/rules/metaclass_abcmeta.rs b/crates/ruff_linter/src/rules/refurb/rules/metaclass_abcmeta.rs index 540599dba1..94ab857379 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/metaclass_abcmeta.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/metaclass_abcmeta.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::analyze; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -51,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: `abc.ABC`](https://docs.python.org/3/library/abc.html#abc.ABC) /// - [Python documentation: `abc.ABCMeta`](https://docs.python.org/3/library/abc.html#abc.ABCMeta) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.0")] +#[violation_metadata(preview_since = "v0.2.0", category = Category::Complexity)] pub(crate) struct MetaClassABCMeta; impl AlwaysFixableViolation for MetaClassABCMeta { diff --git a/crates/ruff_linter/src/rules/refurb/rules/print_empty_string.rs b/crates/ruff_linter/src/rules/refurb/rules/print_empty_string.rs index ee803ba0e0..3fae3e0fb3 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/print_empty_string.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/print_empty_string.rs @@ -6,6 +6,7 @@ use ruff_python_trivia::CommentRanges; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -38,7 +39,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `print`](https://docs.python.org/3/library/functions.html#print) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct PrintEmptyString { reason: Reason, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/read_whole_file.rs b/crates/ruff_linter/src/rules/refurb/rules/read_whole_file.rs index e9be6994c5..504c163c56 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/read_whole_file.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/read_whole_file.rs @@ -8,6 +8,7 @@ use ruff_python_codegen::Generator; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::importer::ImportRequest; use crate::rules::refurb::helpers::{FileOpen, OpenArgument, find_file_opens}; @@ -41,7 +42,7 @@ use crate::{FixAvailability, Violation}; /// - [Python documentation: `Path.read_bytes`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_bytes) /// - [Python documentation: `Path.read_text`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_text) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.2")] +#[violation_metadata(preview_since = "v0.1.2", category = Category::Pedantic)] pub(crate) struct ReadWholeFile<'a> { filename: SourceCodeSnippet, suggestion: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/refurb/rules/readlines_in_for.rs b/crates/ruff_linter/src/rules/refurb/rules/readlines_in_for.rs index 943a013cbb..bac6fc12a2 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/readlines_in_for.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/readlines_in_for.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::is_io_base_expr; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad_end; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -48,7 +49,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `io.IOBase.readlines`](https://docs.python.org/3/library/io.html#io.IOBase.readlines) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct ReadlinesInFor; impl AlwaysFixableViolation for ReadlinesInFor { diff --git a/crates/ruff_linter/src/rules/refurb/rules/redundant_log_base.rs b/crates/ruff_linter/src/rules/refurb/rules/redundant_log_base.rs index 35774cde28..17d8b0dc05 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/redundant_log_base.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/redundant_log_base.rs @@ -6,6 +6,7 @@ use ruff_python_ast::{self as ast, Expr, Number}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `math.log10`](https://docs.python.org/3/library/math.html#math.log10) /// - [Python documentation: `math.e`](https://docs.python.org/3/library/math.html#math.e) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct RedundantLogBase { base: Base, arg: String, diff --git a/crates/ruff_linter/src/rules/refurb/rules/regex_flag_alias.rs b/crates/ruff_linter/src/rules/refurb/rules/regex_flag_alias.rs index 80254d27ba..fe911b73ce 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/regex_flag_alias.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/regex_flag_alias.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -32,7 +33,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Style)] pub(crate) struct RegexFlagAlias { flag: RegexFlag, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs index 46ebc97674..66797c40ba 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs @@ -13,6 +13,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::{ImportRequest, Importer}; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -69,7 +70,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [descriptors]: https://docs.python.org/3/howto/descriptor.html #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.1.9")] +#[violation_metadata(preview_since = "v0.1.9", category = Category::Pedantic)] pub(crate) struct ReimplementedOperator { operator: Operator, target: FunctionLikeKind, diff --git a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_starmap.rs b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_starmap.rs index 5e317dd62f..6d9b9df936 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_starmap.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_starmap.rs @@ -8,6 +8,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -50,7 +51,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// [PEP 709]: https://peps.python.org/pep-0709/ /// [#7771]: https://github.com/astral-sh/ruff/issues/7771 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.291")] +#[violation_metadata(preview_since = "v0.0.291", category = Category::Pedantic)] pub(crate) struct ReimplementedStarmap; impl Violation for ReimplementedStarmap { diff --git a/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs b/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs index afe64d5f1e..360a98d31c 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::{Binding, BindingId, SemanticModel}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -45,7 +46,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: More on Lists](https://docs.python.org/3/tutorial/datastructures.html#more-on-lists) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.287")] +#[violation_metadata(preview_since = "v0.0.287", category = Category::Performance)] pub(crate) struct RepeatedAppend { name: String, replacement: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/refurb/rules/repeated_global.rs b/crates/ruff_linter/src/rules/refurb/rules/repeated_global.rs index 4ecae24fe6..ac041e94a9 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/repeated_global.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/repeated_global.rs @@ -5,6 +5,7 @@ use ruff_python_ast::Stmt; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// - [Python documentation: the `global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) /// - [Python documentation: the `nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.4.9")] +#[violation_metadata(preview_since = "v0.4.9", category = Category::Formatting)] pub(crate) struct RepeatedGlobal { global_kind: GlobalKind, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs b/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs index 136dcf025d..feceeb5c89 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -42,7 +43,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: Comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) /// - [Python documentation: Membership test operations](https://docs.python.org/3/reference/expressions.html#membership-test-operations) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Complexity)] pub(crate) struct SingleItemMembershipTest { membership_test: MembershipTest, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/slice_copy.rs b/crates/ruff_linter/src/rules/refurb/rules/slice_copy.rs index a6d4abb102..8267920e97 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/slice_copy.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/slice_copy.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::{Binding, SemanticModel}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::refurb::helpers::generate_method_call; @@ -41,7 +42,7 @@ use crate::rules::refurb::helpers::generate_method_call; /// ## References /// - [Python documentation: Mutable Sequence Types](https://docs.python.org/3/library/stdtypes.html#mutable-sequence-types) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.290")] +#[violation_metadata(preview_since = "v0.0.290", category = Category::Style)] pub(crate) struct SliceCopy; impl Violation for SliceCopy { diff --git a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs index 20f82a1caa..ecc50f5a4a 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -42,7 +43,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## Fix safety /// This rule's fix is marked as safe, unless the expression contains comments. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Complexity)] pub(crate) struct SliceToRemovePrefixOrSuffix { affix_kind: AffixKind, stmt_or_expression: StmtOrExpr, diff --git a/crates/ruff_linter/src/rules/refurb/rules/sorted_min_max.rs b/crates/ruff_linter/src/rules/refurb/rules/sorted_min_max.rs index 5a3f8ad36c..cb04fa1de9 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/sorted_min_max.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/sorted_min_max.rs @@ -9,6 +9,7 @@ use crate::Fix; use crate::FixAvailability; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of `sorted()` to retrieve the minimum or maximum value in @@ -53,7 +54,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `min`](https://docs.python.org/3/library/functions.html#min) /// - [Python documentation: `max`](https://docs.python.org/3/library/functions.html#max) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Complexity)] pub(crate) struct SortedMinMax { min_max: MinMax, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs b/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs index f44adf4374..6cbdc3247b 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{Arguments, StmtClassDef, helpers::map_subscript}; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::{checkers::ast::Checker, importer::ImportRequest}; @@ -60,7 +61,7 @@ use crate::{checkers::ast::Checker, importer::ImportRequest}; /// /// - [Python documentation: `collections`](https://docs.python.org/3/library/collections.html) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.7.3")] +#[violation_metadata(preview_since = "0.7.3", category = Category::Suspicious)] pub(crate) struct SubclassBuiltin { subclass: String, replacement: String, diff --git a/crates/ruff_linter/src/rules/refurb/rules/type_none_comparison.rs b/crates/ruff_linter/src/rules/refurb/rules/type_none_comparison.rs index 7057458aef..5779569050 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/type_none_comparison.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/type_none_comparison.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::SemanticModel; use crate::AlwaysFixableViolation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::refurb::helpers::replace_with_identity_check; /// ## What it does @@ -32,7 +33,7 @@ use crate::rules::refurb::helpers::replace_with_identity_check; /// - [Python documentation: `type`](https://docs.python.org/3/library/functions.html#type) /// - [Python documentation: Identity comparisons](https://docs.python.org/3/reference/expressions.html#is-not) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Complexity)] pub(crate) struct TypeNoneComparison { replacement: IdentityCheck, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/unnecessary_enumerate.rs b/crates/ruff_linter/src/rules/refurb/rules/unnecessary_enumerate.rs index cee69f3e45..fb95f81199 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/unnecessary_enumerate.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/unnecessary_enumerate.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::analyze::typing::{is_dict, is_list, is_set, is_tuple}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -58,7 +59,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `range`](https://docs.python.org/3/library/stdtypes.html#range) /// - [Python documentation: `len`](https://docs.python.org/3/library/functions.html#len) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.0.291")] +#[violation_metadata(preview_since = "v0.0.291", category = Category::Complexity)] pub(crate) struct UnnecessaryEnumerate { subset: EnumerateSubset, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/unnecessary_from_float.rs b/crates/ruff_linter/src/rules/refurb/rules/unnecessary_from_float.rs index 734618392b..ce1a1a6241 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/unnecessary_from_float.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/unnecessary_from_float.rs @@ -5,6 +5,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::linter::float::as_non_finite_float_string_literal; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -61,7 +62,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `decimal`](https://docs.python.org/3/library/decimal.html) /// - [Python documentation: `fractions`](https://docs.python.org/3/library/fractions.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Pedantic)] pub(crate) struct UnnecessaryFromFloat { method_name: MethodName, constructor: Constructor, diff --git a/crates/ruff_linter/src/rules/refurb/rules/verbose_decimal_constructor.rs b/crates/ruff_linter/src/rules/refurb/rules/verbose_decimal_constructor.rs index 3923e66ace..5bbe86db66 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/verbose_decimal_constructor.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/verbose_decimal_constructor.rs @@ -8,6 +8,7 @@ use ruff_python_trivia::PythonWhitespace; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::linter::float::as_non_finite_float_string_literal; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `decimal`](https://docs.python.org/3/library/decimal.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct VerboseDecimalConstructor { replacement: String, } diff --git a/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs b/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs index 83328f650b..afa96adfc8 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{ use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::importer::ImportRequest; use crate::rules::refurb::helpers::{FileOpen, OpenArgument, find_file_opens}; @@ -41,7 +42,7 @@ use crate::{FixAvailability, Locator, Violation}; /// - [Python documentation: `Path.write_bytes`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.write_bytes) /// - [Python documentation: `Path.write_text`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.write_text) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.3.6")] +#[violation_metadata(preview_since = "v0.3.6", category = Category::Pedantic)] pub(crate) struct WriteWholeFile<'a> { filename: SourceCodeSnippet, suggestion: SourceCodeSnippet, diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB161_FURB161.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__bit-count_FURB161.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB161_FURB161.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__bit-count_FURB161.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB132_FURB132.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__check-and-remove-from-set_FURB132.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB132_FURB132.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__check-and-remove-from-set_FURB132.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB131_FURB131.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__delete-full-slice_FURB131.py.snap similarity index 50% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB131_FURB131.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__delete-full-slice_FURB131.py.snap index eed82175cc..edcbd44630 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB131_FURB131.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__delete-full-slice_FURB131.py.snap @@ -16,21 +16,6 @@ help: Replace with `clear()` | note: This is an unsafe fix and may change runtime behavior -FURB131 [*] Prefer `clear` over deleting a full slice - --> FURB131.py:15:1 - | -14 | # FURB131 -15 | del names[:] - | ^^^^^^^^^^^^ -help: Replace with `clear()` - | -14 | # FURB131 - - del names[:] -15 + names.clear() -16 | - | -note: This is an unsafe fix and may change runtime behavior - FURB131 Prefer `clear` over deleting a full slice --> FURB131.py:19:1 | @@ -39,14 +24,6 @@ FURB131 Prefer `clear` over deleting a full slice | ^^^^^^^^^^^^^^ help: Replace with `clear()` -FURB131 Prefer `clear` over deleting a full slice - --> FURB131.py:23:1 - | -22 | # FURB131 -23 | del y, names[:], x - | ^^^^^^^^^^^^^^^^^^ -help: Replace with `clear()` - FURB131 [*] Prefer `clear` over deleting a full slice --> FURB131.py:28:5 | @@ -63,22 +40,6 @@ help: Replace with `clear()` | note: This is an unsafe fix and may change runtime behavior -FURB131 [*] Prefer `clear` over deleting a full slice - --> FURB131.py:33:5 - | -31 | def yes_two(x: dict[int, str]): -32 | # FURB131 -33 | del x[:] - | ^^^^^^^^ -help: Replace with `clear()` - | -32 | # FURB131 - - del x[:] -33 + x.clear() -34 | - | -note: This is an unsafe fix and may change runtime behavior - FURB131 [*] Prefer `clear` over deleting a full slice --> FURB131.py:38:5 | @@ -95,41 +56,6 @@ help: Replace with `clear()` | note: This is an unsafe fix and may change runtime behavior -FURB131 [*] Prefer `clear` over deleting a full slice - --> FURB131.py:43:5 - | -41 | def yes_four(x: Dict[int, str]): -42 | # FURB131 -43 | del x[:] - | ^^^^^^^^ -help: Replace with `clear()` - | -42 | # FURB131 - - del x[:] -43 + x.clear() -44 | - | -note: This is an unsafe fix and may change runtime behavior - -FURB131 [*] Prefer `clear` over deleting a full slice - --> FURB131.py:48:5 - | -46 | def yes_five(x: Dict[int, str]): -47 | # FURB131 -48 | del x[:] - | ^^^^^^^^ -49 | -50 | x = 1 - | -help: Replace with `clear()` - | -47 | # FURB131 - - del x[:] -48 + x.clear() -49 | - | -note: This is an unsafe fix and may change runtime behavior - FURB131 [*] Prefer `clear` over deleting a full slice --> FURB131.py:58:1 | diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB116_FURB116.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__f-string-number-format_FURB116.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB116_FURB116.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__f-string-number-format_FURB116.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB142_FURB142.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__for-loop-set-mutations_FURB142.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB142_FURB142.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__for-loop-set-mutations_FURB142.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB122_FURB122.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__for-loop-writes_FURB122.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB122_FURB122.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__for-loop-writes_FURB122.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB162_FURB162.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__fromisoformat-replace-z_FURB162.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB162_FURB162.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__fromisoformat-replace-z_FURB162.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__hardcoded-string-charset_FURB156.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__hardcoded-string-charset_FURB156.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB181_FURB181.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__hashlib-digest-hex_FURB181.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB181_FURB181.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__hashlib-digest-hex_FURB181.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB110_FURB110.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__if-exp-instead-of-or-operator_FURB110.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB110_FURB110.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__if-exp-instead-of-or-operator_FURB110.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB136_FURB136.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__if-expr-min-max_FURB136.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB136_FURB136.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__if-expr-min-max_FURB136.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB177_FURB177.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__implicit-cwd_FURB177.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB177_FURB177.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__implicit-cwd_FURB177.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB166_FURB166.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__int-on-sliced-str_FURB166.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB166_FURB166.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__int-on-sliced-str_FURB166.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB168_FURB168.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__isinstance-type-none_FURB168.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB168_FURB168.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__isinstance-type-none_FURB168.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB187_FURB187.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__list-reverse-copy_FURB187.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB187_FURB187.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__list-reverse-copy_FURB187.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB152_FURB152.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__math-constant_FURB152.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB152_FURB152.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__math-constant_FURB152.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB180_FURB180.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__meta-class-abc-meta_FURB180.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB180_FURB180.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__meta-class-abc-meta_FURB180.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB105_FURB105.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__print-empty-string_FURB105.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB105_FURB105.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__print-empty-string_FURB105.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_0.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_0.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_0.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_1.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_1.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_1.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__read-whole-file_FURB101_2.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB129_FURB129.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__readlines-in-for_FURB129.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB129_FURB129.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__readlines-in-for_FURB129.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB163_FURB163.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__redundant-log-base_FURB163.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB163_FURB163.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__redundant-log-base_FURB163.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB167_FURB167.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__regex-flag-alias_FURB167.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB167_FURB167.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__regex-flag-alias_FURB167.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__reimplemented-operator_FURB118.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__reimplemented-operator_FURB118.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB140_FURB140.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__reimplemented-starmap_FURB140.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB140_FURB140.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__reimplemented-starmap_FURB140.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB113_FURB113.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__repeated-append_FURB113.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB113_FURB113.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__repeated-append_FURB113.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB154_FURB154.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__repeated-global_FURB154.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB154_FURB154.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__repeated-global_FURB154.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB171_FURB171_0.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__single-item-membership-test_FURB171_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB171_FURB171_0.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__single-item-membership-test_FURB171_0.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB171_FURB171_1.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__single-item-membership-test_FURB171_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB171_FURB171_1.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__single-item-membership-test_FURB171_1.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB145_FURB145.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__slice-copy_FURB145.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB145_FURB145.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__slice-copy_FURB145.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__slice-to-remove-prefix-or-suffix_FURB188.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__slice-to-remove-prefix-or-suffix_FURB188.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB192_FURB192.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__sorted-min-max_FURB192.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB192_FURB192.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__sorted-min-max_FURB192.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB192_FURB192_1.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__sorted-min-max_FURB192_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB192_FURB192_1.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__sorted-min-max_FURB192_1.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB189_FURB189.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__subclass-builtin_FURB189.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB189_FURB189.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__subclass-builtin_FURB189.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB169_FURB169.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__type-none-comparison_FURB169.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB169_FURB169.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__type-none-comparison_FURB169.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB148_FURB148.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__unnecessary-enumerate_FURB148.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB148_FURB148.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__unnecessary-enumerate_FURB148.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB164_FURB164.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__unnecessary-from-float_FURB164.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB164_FURB164.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__unnecessary-from-float_FURB164.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB157_FURB157.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__verbose-decimal-constructor_FURB157.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB157_FURB157.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__verbose-decimal-constructor_FURB157.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_0.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_0.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_0.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_1.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_1.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_1.py.snap diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_2.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103_2.py.snap rename to crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write-whole-file_FURB103_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/helpers.rs b/crates/ruff_linter/src/rules/ruff/helpers.rs index 13bed53eb1..6481ff3e82 100644 --- a/crates/ruff_linter/src/rules/ruff/helpers.rs +++ b/crates/ruff_linter/src/rules/ruff/helpers.rs @@ -293,7 +293,7 @@ pub(super) fn has_default_copy_semantics( /// Returns `true` if the given function is an instantiation of a class that implements the /// descriptor protocol. /// -/// See: +/// See: pub(super) fn is_descriptor_class(func: &Expr, semantic: &SemanticModel) -> bool { semantic.lookup_attribute(func).is_some_and(|id| { let BindingKind::ClassDefinition(scope_id) = semantic.binding(id).kind else { @@ -318,7 +318,18 @@ pub(super) fn is_ctypes_structure_fields( ) -> bool { let is_ctypes_structure = analyze::class::any_qualified_base_class(class_def, semantic, |qualified_name| { - matches!(qualified_name.segments(), ["ctypes", "Structure"]) + matches!( + qualified_name.segments(), + [ + "ctypes", + "Structure" + | "BigEndianStructure" + | "LittleEndianStructure" + | "Union" + | "BigEndianUnion" + | "LittleEndianUnion" + ] + ) }); let is_fields = matches!( diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 580044684d..6635c112dd 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -125,7 +125,7 @@ mod tests { #[test_case(Rule::NonEmptyInitModule, Path::new("RUF067/modules/__init__.py"))] #[test_case(Rule::NonEmptyInitModule, Path::new("RUF067/modules/okay.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("ruff").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code), @@ -289,7 +289,7 @@ mod tests { fn implicit_optional_py39(path: &Path) -> Result<()> { let snapshot = format!( "PY39_{}_{}", - Rule::ImplicitOptional.noqa_code(), + Rule::ImplicitOptional.name(), path.to_string_lossy() ); let diagnostics = test_path( @@ -769,7 +769,7 @@ mod tests { #[test_case(Rule::InvalidPyprojectToml, Path::new("various_invalid"))] #[test_case(Rule::InvalidPyprojectToml, Path::new("pep639"))] fn invalid_pyproject_toml(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let snapshot = format!("{}_{}", rule_code.name(), path.to_string_lossy()); let messages = test_toml_path( Path::new("ruff/pyproject_toml") .join(path) @@ -805,11 +805,7 @@ mod tests { )] #[test_case(Rule::UnnecessaryIf, Path::new("RUF050_basedpython.by"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "preview__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("preview__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("ruff").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code).with_preview_mode(), @@ -822,7 +818,7 @@ mod tests { fn preview_rules_py37(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "preview__py37__{}_{}", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); let diagnostics = test_path( @@ -839,7 +835,7 @@ mod tests { fn preview_rules_py38(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "preview__py38__{}_{}", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy() ); let diagnostics = test_path( @@ -865,7 +861,7 @@ mod tests { let snapshot = format!( "custom_dummy_var_regexp_preset__{}_{}_{}", - rule_code.noqa_code(), + rule_code.name(), path.to_string_lossy(), id, ); @@ -882,11 +878,7 @@ mod tests { #[test_case(Rule::StarmapZip, Path::new("RUF058_2.py"))] fn map_strict_py314(rule_code: Rule, path: &Path) -> Result<()> { - let snapshot = format!( - "py314__{}_{}", - rule_code.noqa_code(), - path.to_string_lossy() - ); + let snapshot = format!("py314__{}_{}", rule_code.name(), path.to_string_lossy()); let diagnostics = test_path( Path::new("ruff").join(path).as_path(), &settings::LinterSettings::for_rule(rule_code) diff --git a/crates/ruff_linter/src/rules/ruff/rules/access_annotations_from_class_dict.rs b/crates/ruff_linter/src/rules/ruff/rules/access_annotations_from_class_dict.rs index a3ddb2b259..8da952e106 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/access_annotations_from_class_dict.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/access_annotations_from_class_dict.rs @@ -1,4 +1,5 @@ use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{FixAvailability, Violation}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{Expr, ExprCall, ExprSubscript, PythonVersion}; @@ -73,7 +74,7 @@ use ruff_text_size::Ranged; /// ## References /// - [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Suspicious)] pub(crate) struct AccessAnnotationsFromClassDict { python_version: PythonVersion, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/ambiguous_unicode_character.rs b/crates/ruff_linter/src/rules/ruff/rules/ambiguous_unicode_character.rs index ba1c696d27..d71c349e31 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/ambiguous_unicode_character.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/ambiguous_unicode_character.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Locator; use crate::Violation; use crate::checkers::ast::{Checker, LintContext}; +use crate::codes::Category; use crate::preview::is_unicode_to_unicode_confusables_enabled; use crate::rules::ruff::rules::Context; use crate::rules::ruff::rules::confusables::confusable; @@ -46,7 +47,7 @@ use crate::rules::ruff::rules::confusables::confusable; /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Security)] pub(crate) struct AmbiguousUnicodeCharacterString { confusable: char, representant: char, @@ -100,7 +101,7 @@ impl Violation for AmbiguousUnicodeCharacterString { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.102")] +#[violation_metadata(stable_since = "v0.0.102", category = Category::Security)] pub(crate) struct AmbiguousUnicodeCharacterDocstring { confusable: char, representant: char, @@ -154,7 +155,7 @@ impl Violation for AmbiguousUnicodeCharacterDocstring { /// /// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.108")] +#[violation_metadata(stable_since = "v0.0.108", category = Category::Security)] pub(crate) struct AmbiguousUnicodeCharacterComment { confusable: char, representant: char, diff --git a/crates/ruff_linter/src/rules/ruff/rules/assert_with_print_message.rs b/crates/ruff_linter/src/rules/ruff/rules/assert_with_print_message.rs index 186b09cb4a..bab669f7b6 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/assert_with_print_message.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/assert_with_print_message.rs @@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Python documentation: `assert`](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Correctness)] pub(crate) struct AssertWithPrintMessage; impl AlwaysFixableViolation for AssertWithPrintMessage { diff --git a/crates/ruff_linter/src/rules/ruff/rules/assignment_in_assert.rs b/crates/ruff_linter/src/rules/ruff/rules/assignment_in_assert.rs index 5787eaf6bf..e31ad0846c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/assignment_in_assert.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/assignment_in_assert.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for named assignment expressions (e.g., `x := 0`) in `assert` @@ -48,7 +49,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `-O`](https://docs.python.org/3/using/cmdline.html#cmdoption-O) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Suspicious)] pub(crate) struct AssignmentInAssert; impl Violation for AssignmentInAssert { diff --git a/crates/ruff_linter/src/rules/ruff/rules/asyncio_dangling_task.rs b/crates/ruff_linter/src/rules/ruff/rules/asyncio_dangling_task.rs index 857844b974..ce4fb135d9 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/asyncio_dangling_task.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/asyncio_dangling_task.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `asyncio.create_task` and `asyncio.ensure_future` calls @@ -67,7 +68,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `asyncio.create_task`](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task) /// - [Python documentation: `asyncio.TaskGroup`](https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.247")] +#[violation_metadata(stable_since = "v0.0.247", category = Category::Pedantic)] pub(crate) struct AsyncioDanglingTask { expr: String, method: Method, diff --git a/crates/ruff_linter/src/rules/ruff/rules/class_with_mixed_type_vars.rs b/crates/ruff_linter/src/rules/ruff/rules/class_with_mixed_type_vars.rs index 7b023de830..bf9ab3d45e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/class_with_mixed_type_vars.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/class_with_mixed_type_vars.rs @@ -8,6 +8,7 @@ use ruff_python_ast::{ use ruff_python_semantic::SemanticModel; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::rules::pyupgrade::rules::pep695::{ DisplayTypeVars, TypeParamKind, TypeVar, expr_name_to_type_var, find_generic, @@ -62,7 +63,7 @@ use ruff_python_ast::PythonVersion; /// [PEP 695]: https://peps.python.org/pep-0695/ /// [type parameter lists]: https://docs.python.org/3/reference/compound_stmts.html#type-params #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Correctness)] pub(crate) struct ClassWithMixedTypeVars; impl Violation for ClassWithMixedTypeVars { diff --git a/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs b/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs index a0d667802a..2977bc1a8f 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr, ExprContext, Operator}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -43,7 +44,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [PEP 448 – Additional Unpacking Generalizations](https://peps.python.org/pep-0448/) /// - [Python documentation: Sequence Types — `list`, `tuple`, `range`](https://docs.python.org/3/library/stdtypes.html#sequence-types-list-tuple-range) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.227")] +#[violation_metadata(stable_since = "v0.0.227", category = Category::Pedantic)] pub(crate) struct CollectionLiteralConcatenation { expression: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/dataclass_enum.rs b/crates/ruff_linter/src/rules/ruff/rules/dataclass_enum.rs index 04bcb06662..31907942ca 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/dataclass_enum.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/dataclass_enum.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::class::is_enumeration; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::helpers::{DataclassKind, dataclass_kind}; /// ## What it does @@ -45,7 +46,7 @@ use crate::rules::ruff::helpers::{DataclassKind, dataclass_kind}; /// ## References /// - [Python documentation: Enum HOWTO § Dataclass support](https://docs.python.org/3/howto/enum.html#dataclass-support) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Correctness)] pub(crate) struct DataclassEnum; impl Violation for DataclassEnum { diff --git a/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs b/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs index 7de91824ce..2ba408e350 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs @@ -7,6 +7,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -33,7 +34,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// of the `Decimal` instance that is constructed. This can lead to unexpected /// behavior if your program relies on the previous value (whether deliberately or not). #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Suspicious)] pub(crate) struct DecimalFromFloatLiteral; impl AlwaysFixableViolation for DecimalFromFloatLiteral { diff --git a/crates/ruff_linter/src/rules/ruff/rules/default_factory_kwarg.rs b/crates/ruff_linter/src/rules/ruff/rules/default_factory_kwarg.rs index d70bf3a470..b18d8dd009 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/default_factory_kwarg.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/default_factory_kwarg.rs @@ -9,6 +9,7 @@ use ruff_text_size::Ranged; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::fix::snippet::SourceCodeSnippet; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -51,7 +52,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// defaultdict(list) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Correctness)] pub(crate) struct DefaultFactoryKwarg { default_factory: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/duplicate_entry_in_dunder_all.rs b/crates/ruff_linter/src/rules/ruff/rules/duplicate_entry_in_dunder_all.rs index a16edf98b7..e6cf2baa87 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/duplicate_entry_in_dunder_all.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/duplicate_entry_in_dunder_all.rs @@ -6,6 +6,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::{FixAvailability, Violation}; @@ -48,7 +49,7 @@ use crate::{FixAvailability, Violation}; /// ] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Correctness)] pub(crate) struct DuplicateEntryInDunderAll; impl Violation for DuplicateEntryInDunderAll { diff --git a/crates/ruff_linter/src/rules/ruff/rules/explicit_f_string_type_conversion.rs b/crates/ruff_linter/src/rules/ruff/rules/explicit_f_string_type_conversion.rs index b5dea310a2..b3240c7ab5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/explicit_f_string_type_conversion.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/explicit_f_string_type_conversion.rs @@ -9,6 +9,7 @@ use ruff_python_ast::{self as ast, Expr, OperatorPrecedence}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::cst::helpers::space; use crate::cst::matchers::{ match_call_mut, match_formatted_string, match_formatted_string_expression, transform_expression, @@ -45,7 +46,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// This rule's fix is marked as unsafe if the call expression contains /// comments that would be deleted by applying the fix. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.267")] +#[violation_metadata(stable_since = "v0.0.267", category = Category::Complexity)] pub(crate) struct ExplicitFStringTypeConversion; impl Violation for ExplicitFStringTypeConversion { diff --git a/crates/ruff_linter/src/rules/ruff/rules/fallible_context_manager.rs b/crates/ruff_linter/src/rules/ruff/rules/fallible_context_manager.rs index 8eec1a5a31..893b30a092 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/fallible_context_manager.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/fallible_context_manager.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `@contextlib.contextmanager` decorated functions that contain @@ -49,7 +50,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `contextlib.contextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.14")] +#[violation_metadata(preview_since = "0.15.14", category = Category::Suspicious)] pub(crate) struct FallibleContextManager; impl Violation for FallibleContextManager { diff --git a/crates/ruff_linter/src/rules/ruff/rules/falsy_dict_get_fallback.rs b/crates/ruff_linter/src/rules/ruff/rules/falsy_dict_get_fallback.rs index c4a708cd69..a2055d50b4 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/falsy_dict_get_fallback.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/falsy_dict_get_fallback.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Applicability, Fix, FixAvailability, Violation}; @@ -38,9 +39,9 @@ use crate::{Applicability, Fix, FixAvailability, Violation}; /// shown in the [documentation], `dict.get` takes two positional-only arguments, so invalid cases /// are identified by the presence of more than two arguments or any keyword arguments. /// -/// [documentation]: https://docs.python.org/3.13/library/stdtypes.html#dict.get +/// [documentation]: https://docs.python.org/3/library/stdtypes.html#dict.get #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.5")] +#[violation_metadata(preview_since = "0.8.5", category = Category::Complexity)] pub(crate) struct FalsyDictGetFallback; impl Violation for FalsyDictGetFallback { diff --git a/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs b/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs index 94dda556b1..fa73c2054c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::linter::float::is_infinity_string_literal; /// ## What it does @@ -101,7 +102,7 @@ use crate::linter::float::is_infinity_string_literal; /// - [NumPy documentation: `numpy.allclose`](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html#numpy-allclose) /// - [PyTorch documentation: `torch.isclose`](https://docs.pytorch.org/docs/stable/generated/torch.isclose.html#torch-isclose) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.1")] +#[violation_metadata(preview_since = "0.15.1", category = Category::Suspicious)] pub(crate) struct FloatEqualityComparison<'a> { left: &'a str, right: &'a str, diff --git a/crates/ruff_linter/src/rules/ruff/rules/fstring_percent_format.rs b/crates/ruff_linter/src/rules/ruff/rules/fstring_percent_format.rs index 790c003d32..ca8576da01 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/fstring_percent_format.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/fstring_percent_format.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for uses of the `%` operator on f-strings. @@ -27,7 +28,7 @@ use crate::checkers::ast::Checker; /// f"hello {first} {second}" /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.8")] +#[violation_metadata(preview_since = "0.15.8", category = Category::Suspicious)] pub(crate) struct FStringPercentFormat; impl Violation for FStringPercentFormat { diff --git a/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs b/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs index eeba747dbb..3b4b4d5051 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/function_call_in_dataclass_default.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::helpers::{ AttrsAutoAttribs, DataclassKind, dataclass_kind, is_basedpython_data_class, is_class_var_annotation, is_dataclass_field, is_descriptor_class, is_frozen_dataclass, @@ -61,7 +62,7 @@ use crate::rules::ruff::helpers::{ /// ## Options /// - `lint.flake8-bugbear.extend-immutable-calls` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Suspicious)] pub(crate) struct FunctionCallInDataclassDefaultArgument { name: Option, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/if_key_in_dict_del.rs b/crates/ruff_linter/src/rules/ruff/rules/if_key_in_dict_del.rs index 9b5078f5b9..58416d52b4 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/if_key_in_dict_del.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/if_key_in_dict_del.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{CmpOp, Expr, ExprName, ExprSubscript, Stmt, StmtIf}; use ruff_python_semantic::analyze::typing; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; type Key = Expr; @@ -32,7 +33,7 @@ type Dict = ExprName; /// ## Fix safety /// This rule's fix is marked as safe, unless the if statement contains comments. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Complexity)] pub(crate) struct IfKeyInDictDel; impl AlwaysFixableViolation for IfKeyInDictDel { diff --git a/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs b/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs index 46a29cd05e..b60a0feccd 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/implicit_classvar_in_dataclass.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::helpers::{DataclassKind, dataclass_kind}; /// ## What it does @@ -52,7 +53,7 @@ use crate::rules::ruff::helpers::{DataclassKind, dataclass_kind}; /// ## Options /// - [`lint.dummy-variable-rgx`] #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.7")] +#[violation_metadata(preview_since = "0.9.7", category = Category::Suspicious)] pub(crate) struct ImplicitClassVarInDataclass; impl Violation for ImplicitClassVarInDataclass { diff --git a/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs b/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs index 11bff1e410..b8df5b7aeb 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs @@ -9,6 +9,7 @@ use ruff_python_ast::{self as ast, Expr, Operator, Parameters}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -89,7 +90,7 @@ use crate::rules::ruff::typing::type_hint_explicitly_allows_none; /// /// [PEP 484]: https://peps.python.org/pep-0484/#union-types #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Suspicious)] pub(crate) struct ImplicitOptional { conversion_type: ConversionType, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/in_empty_collection.rs b/crates/ruff_linter/src/rules/ruff/rules/in_empty_collection.rs index 146740a4d2..42d2ef693e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/in_empty_collection.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/in_empty_collection.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for membership tests on empty collections (such as `list`, `tuple`, `set`, or `dict`). @@ -25,7 +26,7 @@ use crate::checkers::ast::Checker; /// print("got it!") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Correctness)] pub(crate) struct InEmptyCollection; impl Violation for InEmptyCollection { diff --git a/crates/ruff_linter/src/rules/ruff/rules/incorrect_decorator_order.rs b/crates/ruff_linter/src/rules/ruff/rules/incorrect_decorator_order.rs index 50e06d151a..55c46bec3c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/incorrect_decorator_order.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/incorrect_decorator_order.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for incorrect ordering of decorators on functions and methods. @@ -44,7 +45,7 @@ use crate::checkers::ast::Checker; /// - [Python documentation: `abc.abstractmethod`](https://docs.python.org/3/library/abc.html#abc.abstractmethod) /// - [Python documentation: `contextlib.contextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.14")] +#[violation_metadata(preview_since = "0.15.14", category = Category::Correctness)] pub(crate) struct IncorrectDecoratorOrder { outer_decorator: KnownDecorator, inner_decorator: KnownDecorator, diff --git a/crates/ruff_linter/src/rules/ruff/rules/incorrectly_parenthesized_tuple_in_subscript.rs b/crates/ruff_linter/src/rules/ruff/rules/incorrectly_parenthesized_tuple_in_subscript.rs index 0c676e465c..4fcb24b9a4 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/incorrectly_parenthesized_tuple_in_subscript.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/incorrectly_parenthesized_tuple_in_subscript.rs @@ -3,6 +3,7 @@ use ruff_python_ast::{Expr, ExprSubscript, PythonVersion}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -38,7 +39,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## Options /// - `lint.ruff.parenthesize-tuple-in-subscript` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.5.7")] +#[violation_metadata(preview_since = "0.5.7", category = Category::Pedantic)] pub(crate) struct IncorrectlyParenthesizedTupleInSubscript { prefer_parentheses: bool, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/indented_form_feed.rs b/crates/ruff_linter/src/rules/ruff/rules/indented_form_feed.rs index d0e579367d..b62f93b15a 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/indented_form_feed.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/indented_form_feed.rs @@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_source_file::Line; use ruff_text_size::{TextRange, TextSize}; +use crate::codes::Category; use crate::{Violation, checkers::ast::LintContext}; /// ## What it does @@ -31,7 +32,7 @@ use crate::{Violation, checkers::ast::LintContext}; /// /// [lexical-analysis-indentation]: https://docs.python.org/3/reference/lexical_analysis.html#indentation #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.6")] +#[violation_metadata(preview_since = "0.9.6", category = Category::Correctness)] pub(crate) struct IndentedFormFeed; impl Violation for IndentedFormFeed { diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_assert_message_literal_argument.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_assert_message_literal_argument.rs index 1d025bd9fe..c18a353198 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_assert_message_literal_argument.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_assert_message_literal_argument.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for invalid use of literals in assert message arguments. @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// assert len(fruits) == 2 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Suspicious)] pub(crate) struct InvalidAssertMessageLiteralArgument; impl Violation for InvalidAssertMessageLiteralArgument { diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_formatter_suppression_comment.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_formatter_suppression_comment.rs index c2ddbcdf72..bd600baa72 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_formatter_suppression_comment.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_formatter_suppression_comment.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::delete_comment; use crate::{AlwaysFixableViolation, Fix}; @@ -55,7 +56,7 @@ use super::suppression_comment_visitor::{ /// This fix is always marked as unsafe because it deletes the invalid suppression comment, /// rather than trying to move it to a valid position, which the user more likely intended. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Suspicious)] pub(crate) struct InvalidFormatterSuppressionComment { reason: IgnoredReason, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs index f97743c2dc..81401243ad 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs @@ -6,6 +6,7 @@ use std::fmt; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for indexed access to lists, strings, tuples, bytes, and comprehensions @@ -26,7 +27,7 @@ use crate::checkers::ast::Checker; /// var = [1, 2, 3][0] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Correctness)] pub(crate) struct InvalidIndexType { value_type: String, index_type: String, diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs index 3c64e79ce6..1f6472ab22 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs @@ -6,6 +6,7 @@ use toml::de::DeTable; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::{TextRange, TextSize}; +use crate::codes::Category; use crate::{FixAvailability, Violation, checkers::ast::LintContext}; /// ## What it does @@ -37,7 +38,7 @@ use crate::{FixAvailability, Violation, checkers::ast::LintContext}; /// - [Specification of `[build-system]` in pyproject.toml](https://peps.python.org/pep-0518/) /// - [Draft but implemented license declaration extensions](https://peps.python.org/pep-0639) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.271")] +#[violation_metadata(stable_since = "v0.0.271", category = Category::Correctness)] pub(crate) struct InvalidPyprojectToml { pub message: String, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_rule_code.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_rule_code.rs index 7fd564196a..12926e92cd 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_rule_code.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_rule_code.rs @@ -3,6 +3,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::fix::edits::delete_comment; use crate::noqa::{Code, Directive, FileNoqaDirectives}; use crate::noqa::{Codes, NoqaDirectives}; @@ -61,7 +62,7 @@ impl InvalidRuleCodeKind { /// /// - `lint.external` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct InvalidRuleCode { pub(crate) rule_code: String, pub(crate) kind: InvalidRuleCodeKind, diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_suppression_comment.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_suppression_comment.rs index 7e8f3426a8..29dc0b79a6 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_suppression_comment.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_suppression_comment.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::AlwaysFixableViolation; +use crate::codes::Category; use crate::suppression::{InvalidSuppressionKind, ParseErrorKind}; /// ## What it does @@ -25,7 +26,7 @@ use crate::suppression::{InvalidSuppressionKind, ParseErrorKind}; /// ## References /// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct InvalidSuppressionComment { pub(crate) kind: InvalidSuppressionCommentKind, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/legacy_form_pytest_raises.rs b/crates/ruff_linter/src/rules/ruff/rules/legacy_form_pytest_raises.rs index 531542d139..43f874ec81 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/legacy_form_pytest_raises.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/legacy_form_pytest_raises.rs @@ -8,6 +8,7 @@ use ruff_source_file::UniversalNewlines; use ruff_text_size::{Ranged, TextRange}; use std::fmt; +use crate::codes::Category; use crate::{FixAvailability, Violation, checkers::ast::Checker}; /// ## What it does @@ -45,7 +46,7 @@ use crate::{FixAvailability, Violation, checkers::ast::Checker}; /// - [`pytest` documentation: `pytest.warns`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-warns) /// - [`pytest` documentation: `pytest.deprecated_call`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-deprecated-call) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Style)] pub(crate) struct LegacyFormPytestRaises { context_type: PytestContextType, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/logging_eager_conversion.rs b/crates/ruff_linter/src/rules/ruff/rules/logging_eager_conversion.rs index 1945816daf..c7fee1d9f8 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/logging_eager_conversion.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/logging_eager_conversion.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_logging_format::rules::{LoggingCallType, find_logging_call}; /// ## What it does @@ -62,7 +63,7 @@ use crate::rules::flake8_logging_format::rules::{LoggingCallType, find_logging_c /// - [Python documentation: `logging`](https://docs.python.org/3/library/logging.html) /// - [Python documentation: Optimization](https://docs.python.org/3/howto/logging.html#optimization) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.13.2")] +#[violation_metadata(preview_since = "0.13.2", category = Category::Performance)] pub(crate) struct LoggingEagerConversion { format_conversion: FormatConversion, function_name: Option<&'static str>, diff --git a/crates/ruff_linter/src/rules/ruff/rules/map_int_version_parsing.rs b/crates/ruff_linter/src/rules/ruff/rules/map_int_version_parsing.rs index ac9f317cc8..23b313530d 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/map_int_version_parsing.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/map_int_version_parsing.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for calls of the form `map(int, __version__.split("."))`. @@ -35,7 +36,7 @@ use crate::checkers::ast::Checker; /// /// [version-specifier]: https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Suspicious)] pub(crate) struct MapIntVersionParsing; impl Violation for MapIntVersionParsing { diff --git a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs index 5d4e9cb6e3..c6c3cc0195 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs @@ -11,6 +11,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::fastapi::rules::is_fastapi_route_call; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -66,7 +67,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// [gettext]: https://docs.python.org/3/library/gettext.html /// [FastAPI path]: https://fastapi.tiangolo.com/tutorial/path-params/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.2.1")] +#[violation_metadata(preview_since = "v0.2.1", category = Category::Suspicious)] pub(crate) struct MissingFStringSyntax; impl AlwaysFixableViolation for MissingFStringSyntax { diff --git a/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs b/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs index c25d17057d..93c2f5646b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::helpers::{ dataclass_kind, has_default_copy_semantics, is_class_var_annotation, is_ctypes_structure_fields, is_final_annotation, is_special_attribute, @@ -85,7 +86,7 @@ use crate::rules::ruff::helpers::{ /// /// [ClassVar]: https://docs.python.org/3/library/typing.html#typing.ClassVar #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.273")] +#[violation_metadata(stable_since = "v0.0.273", category = Category::Suspicious)] pub(crate) struct MutableClassDefault; impl Violation for MutableClassDefault { diff --git a/crates/ruff_linter/src/rules/ruff/rules/mutable_dataclass_default.rs b/crates/ruff_linter/src/rules/ruff/rules/mutable_dataclass_default.rs index b60e91e0ca..604084a6eb 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mutable_dataclass_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mutable_dataclass_default.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::preview::is_mutable_default_in_dataclass_field_enabled; use crate::rules::ruff::helpers::{ dataclass_kind, is_basedpython_data_class, is_class_var_annotation, is_dataclass_field, @@ -62,7 +63,7 @@ use crate::rules::ruff::helpers::{ /// mutable_default: ClassVar[list[int]] = [] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.262")] +#[violation_metadata(stable_since = "v0.0.262", category = Category::Suspicious)] pub(crate) struct MutableDataclassDefault; impl Violation for MutableDataclassDefault { diff --git a/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs b/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs index 747600f2e8..02348e60e7 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs @@ -1,3 +1,4 @@ +use crate::codes::Category; use crate::fix::edits::fresh_binding_name; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Expr}; @@ -49,7 +50,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `dict.fromkeys`](https://docs.python.org/3/library/stdtypes.html#dict.fromkeys) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.5.0")] +#[violation_metadata(stable_since = "0.5.0", category = Category::Suspicious)] pub(crate) struct MutableFromkeysValue; impl Violation for MutableFromkeysValue { diff --git a/crates/ruff_linter/src/rules/ruff/rules/needless_else.rs b/crates/ruff_linter/src/rules/ruff/rules/needless_else.rs index f8022372b8..753f84472c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/needless_else.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/needless_else.rs @@ -9,6 +9,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -31,7 +32,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// bar() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.9.3")] +#[violation_metadata(preview_since = "0.9.3", category = Category::Style)] pub(crate) struct NeedlessElse; impl AlwaysFixableViolation for NeedlessElse { diff --git a/crates/ruff_linter/src/rules/ruff/rules/never_union.rs b/crates/ruff_linter/src/rules/ruff/rules/never_union.rs index b507466acc..09f8aa4a12 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/never_union.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/never_union.rs @@ -6,6 +6,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -40,7 +41,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `typing.Never`](https://docs.python.org/3/library/typing.html#typing.Never) /// - [Python documentation: `typing.NoReturn`](https://docs.python.org/3/library/typing.html#typing.NoReturn) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Correctness)] pub(crate) struct NeverUnion { never_like: NeverLike, union_like: UnionLike, diff --git a/crates/ruff_linter/src/rules/ruff/rules/non_empty_init_module.rs b/crates/ruff_linter/src/rules/ruff/rules/non_empty_init_module.rs index ff51b04643..a56a4ab4ae 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/non_empty_init_module.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/non_empty_init_module.rs @@ -4,6 +4,7 @@ use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_python_semantic::analyze::typing::is_type_checking_block; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Violation, checkers::ast::Checker}; /// ## What it does @@ -73,7 +74,7 @@ use crate::{Violation, checkers::ast::Checker}; /// /// [PEP-562]: https://peps.python.org/pep-0562/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.14.11")] +#[violation_metadata(preview_since = "0.14.11", category = Category::Pedantic)] pub(crate) struct NonEmptyInitModule { strictly_empty_init_modules: bool, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/non_octal_permissions.rs b/crates/ruff_linter/src/rules/ruff/rules/non_octal_permissions.rs index 205835d18e..f1df3b4dfa 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/non_octal_permissions.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/non_octal_permissions.rs @@ -6,6 +6,7 @@ use ruff_python_semantic::{SemanticModel, analyze}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{FixAvailability, Violation}; /// ## What it does @@ -72,7 +73,7 @@ use crate::{FixAvailability, Violation}; /// /// A fix is only available if the integer literal matches a set of common modes. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct NonOctalPermissions; impl Violation for NonOctalPermissions { diff --git a/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs b/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs index 5bf985fb65..812f610adf 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs @@ -7,6 +7,7 @@ use ruff_python_semantic::analyze::typing::traverse_union; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::pad; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -33,7 +34,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Python documentation: `typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional) /// - [Python documentation: `None`](https://docs.python.org/3/library/constants.html#None) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.16.0")] +#[violation_metadata(stable_since = "0.16.0", category = Category::Style)] pub(crate) struct NoneNotAtEndOfUnion; impl Violation for NoneNotAtEndOfUnion { diff --git a/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs index 47c210a160..b46c887f68 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs @@ -4,6 +4,7 @@ use ruff_diagnostics::{Edit, Fix}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::{Ranged, TextRange}; +use crate::codes::Category; use crate::{ FixAvailability, Locator, Violation, checkers::ast::LintContext, codes::Rule, noqa::Directive, suppression::Suppressions, @@ -58,7 +59,7 @@ use crate::{ /// `unused-noqa` for a rule that will remove these and allow the remaining codes to be moved into a /// `ruff: ignore` comment. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.22")] +#[violation_metadata(preview_since = "0.15.22", category = Category::Pedantic)] pub(crate) struct NoqaComments { file_level: bool, } @@ -191,7 +192,9 @@ impl std::fmt::Display for Codes<'_> { "{}", rules .iter() - .map(Rule::noqa_code) + .map(|rule| rule + .noqa_code() + .map_or_else(|| rule.name().to_string(), |code| code.to_string())) .sorted() .dedup() .join(", ") diff --git a/crates/ruff_linter/src/rules/ruff/rules/os_path_commonprefix.rs b/crates/ruff_linter/src/rules/ruff/rules/os_path_commonprefix.rs index 7f687ead25..9e0f6835a3 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/os_path_commonprefix.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/os_path_commonprefix.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -67,7 +68,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// - [Why `os.path.commonprefix` is deprecated](https://sethmlarson.dev/deprecate-confusing-apis-like-os-path-commonprefix) /// - [CPython deprecation issue](https://github.com/python/cpython/issues/144347) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.6")] +#[violation_metadata(preview_since = "0.15.6", category = Category::Suspicious)] pub(crate) struct OsPathCommonprefix; impl Violation for OsPathCommonprefix { diff --git a/crates/ruff_linter/src/rules/ruff/rules/parenthesize_chained_operators.rs b/crates/ruff_linter/src/rules/ruff/rules/parenthesize_chained_operators.rs index ebb85cf307..76d207a66a 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/parenthesize_chained_operators.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/parenthesize_chained_operators.rs @@ -4,6 +4,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -34,7 +35,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// y = (d and e) or f /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct ParenthesizeChainedOperators; impl AlwaysFixableViolation for ParenthesizeChainedOperators { diff --git a/crates/ruff_linter/src/rules/ruff/rules/post_init_default.rs b/crates/ruff_linter/src/rules/ruff/rules/post_init_default.rs index a345b2fa24..af51755c4e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/post_init_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/post_init_default.rs @@ -9,6 +9,7 @@ use ruff_python_trivia::{indentation_at_offset, textwrap}; use ruff_source_file::LineRanges; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::{checkers::ast::Checker, importer::ImportRequest}; @@ -75,7 +76,7 @@ use crate::rules::ruff::helpers::{DataclassKind, dataclass_kind}; /// /// [documentation]: https://docs.python.org/3/library/dataclasses.html#init-only-variables #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Suspicious)] pub(crate) struct PostInitDefault; impl Violation for PostInitDefault { diff --git a/crates/ruff_linter/src/rules/ruff/rules/property_without_return.rs b/crates/ruff_linter/src/rules/ruff/rules/property_without_return.rs index 91619a5b98..efd0556da6 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/property_without_return.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/property_without_return.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{Expr, Stmt, StmtFunctionDef}; use ruff_python_semantic::analyze::{function_type, visibility}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{FixAvailability, Violation}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{FixAvailability, Violation}; /// ## References /// - [Python documentation: The property class](https://docs.python.org/3/library/functions.html#property) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.14.7")] +#[violation_metadata(preview_since = "0.14.7", category = Category::Correctness)] pub(crate) struct PropertyWithoutReturn { name: String, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/pytest_fixture_autouse.rs b/crates/ruff_linter/src/rules/ruff/rules/pytest_fixture_autouse.rs index 928bbd00de..a42c0aaae5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/pytest_fixture_autouse.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/pytest_fixture_autouse.rs @@ -5,13 +5,9 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pytest_style::helpers::is_pytest_fixture; -/// ## Removed -/// This rule has been removed because it is highly opinionated and may encourage unidiomatic pytest -/// usage. It may be reintroduced in the future under a different category but was not a good fit -/// for the `RUF` category. -/// /// ## What it does /// Checks for `pytest` fixtures that set the parameter `autouse=True` in the decorator constructor. /// @@ -58,14 +54,14 @@ use crate::rules::flake8_pytest_style::helpers::is_pytest_fixture; /// /// ```toml /// [tool.ruff.lint.per-file-ignores] -/// "!**/conftest.py" = ["RUF076"] +/// "!**/conftest.py" = ["pytest-fixture-autouse"] /// ``` /// /// ## References /// - [`pytest` documentation: Sharing fixtures across classes, modules, packages or session](https://docs.pytest.org/en/stable/how-to/fixtures.html#scope-sharing-fixtures-across-classes-modules-packages-or-session) /// - [`pytest` documentation: Fixtures can request other fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html#fixtures-can-request-other-fixtures) #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.15.20")] +#[violation_metadata(preview_since = "0.16.5", category = Category::Restriction)] pub(crate) struct PytestFixtureAutouse; impl Violation for PytestFixtureAutouse { @@ -75,7 +71,7 @@ impl Violation for PytestFixtureAutouse { } } -/// RUF076 +/// `pytest-fixture-autouse` pub(crate) fn pytest_fixture_autouse(checker: &Checker, decorators: &[Decorator]) { for decorator in decorators { if !is_pytest_fixture(decorator, checker) { diff --git a/crates/ruff_linter/src/rules/ruff/rules/pytest_raises_ambiguous_pattern.rs b/crates/ruff_linter/src/rules/ruff/rules/pytest_raises_ambiguous_pattern.rs index 612a832bb8..55d20312ab 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/pytest_raises_ambiguous_pattern.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/pytest_raises_ambiguous_pattern.rs @@ -3,6 +3,7 @@ use ruff_python_ast as ast; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::flake8_pytest_style::rules::is_pytest_raises; /// ## What it does @@ -64,7 +65,7 @@ use crate::rules::flake8_pytest_style::rules::is_pytest_raises; /// - [Python documentation: `re.escape`](https://docs.python.org/3/library/re.html#re.escape) /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Style)] pub(crate) struct PytestRaisesAmbiguousPattern; impl Violation for PytestRaisesAmbiguousPattern { diff --git a/crates/ruff_linter/src/rules/ruff/rules/quadratic_list_summation.rs b/crates/ruff_linter/src/rules/ruff/rules/quadratic_list_summation.rs index a4497604d4..fc8136f876 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/quadratic_list_summation.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/quadratic_list_summation.rs @@ -8,6 +8,7 @@ use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -69,7 +70,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [microbenchmarks]: https://github.com/astral-sh/ruff/issues/5073#issuecomment-1591836349 #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.285")] +#[violation_metadata(stable_since = "v0.0.285", category = Category::Performance)] pub(crate) struct QuadraticListSummation { fix_style: QuadraticListSummationFixStyle, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/redirected_noqa.rs b/crates/ruff_linter/src/rules/ruff/rules/redirected_noqa.rs index 3b895126b1..545acabe3c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/redirected_noqa.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/redirected_noqa.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::noqa::{Codes, Directive, FileNoqaDirectives, NoqaDirectives}; use crate::rule_redirects::get_redirect_target; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -25,7 +26,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// x = eval(command) # noqa: S307 /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.6.0")] +#[violation_metadata(stable_since = "0.6.0", category = Category::Suspicious)] pub(crate) struct RedirectedNOQA { original: String, target: String, diff --git a/crates/ruff_linter/src/rules/ruff/rules/redundant_bool_literal.rs b/crates/ruff_linter/src/rules/ruff/rules/redundant_bool_literal.rs index 4111f9522c..10ef4769b1 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/redundant_bool_literal.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/redundant_bool_literal.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use bitflags::bitflags; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -55,7 +56,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// [#14764]: https://github.com/python/mypy/issues/14764 /// [#5421]: https://github.com/microsoft/pyright/issues/5421 #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.0")] +#[violation_metadata(preview_since = "0.8.0", category = Category::Complexity)] pub(crate) struct RedundantBoolLiteral { seen_others: bool, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs index 9179f982c8..009b931acd 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs @@ -7,6 +7,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::TomlSourceType; use ruff_text_size::{TextLen, TextRange, TextSize}; +use crate::codes::Category; use crate::{ AlwaysFixableViolation, checkers::ast::LintContext, codes::Rule, preview::is_human_readable_names_enabled, rule_redirects::get_redirect_target, @@ -35,7 +36,7 @@ use crate::{ /// select = ["unused-import"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.22")] +#[violation_metadata(preview_since = "0.15.22", category = Category::Pedantic)] pub(crate) struct RuleCodesInSelectors { selector: &'static str, name: &'static str, diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs index 3c386f6ab6..29320edb1f 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::AlwaysFixableViolation; +use crate::codes::Category; /// ## What it does /// @@ -25,7 +26,7 @@ use crate::AlwaysFixableViolation; /// import os # ruff: ignore[unused-import] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.22")] +#[violation_metadata(preview_since = "0.15.22", category = Category::Style)] pub(crate) struct RuleCodesInSuppressionComments; impl AlwaysFixableViolation for RuleCodesInSuppressionComments { diff --git a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_all.rs b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_all.rs index f2dbf87474..6ccf465fa9 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_all.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_all.rs @@ -4,6 +4,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::TextRange; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::rules::sequence_sorting::{ MultilineStringSequenceValue, SequenceKind, SortClassification, SortingStyle, sort_single_line_elements_sequence, @@ -89,7 +90,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// iteration order of the items in `__all__`, in which case this /// rule's fix could theoretically cause breakage. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Style)] pub(crate) struct UnsortedDunderAll; impl Violation for UnsortedDunderAll { diff --git a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs index 0fcd0ca5fb..b6c2d12833 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs @@ -10,6 +10,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::rules::sequence_sorting::{ CommentComplexity, MultilineStringSequenceValue, SequenceKind, SortClassification, SortingStyle, sort_single_line_elements_sequence, @@ -83,7 +84,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// `__slots__` definition occurs, in which case this rule's fix could /// theoretically cause breakage. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.8.0")] +#[violation_metadata(stable_since = "0.8.0", category = Category::Style)] pub(crate) struct UnsortedDunderSlots { class_name: ast::name::Name, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/starmap_zip.rs b/crates/ruff_linter/src/rules/ruff/rules/starmap_zip.rs index 1205767a2e..6dc434ca38 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/starmap_zip.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/starmap_zip.rs @@ -5,6 +5,7 @@ use ruff_python_ast::{Expr, ExprCall, token::parenthesized_range}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -41,7 +42,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// This rule will emit a diagnostic but not suggest a fix if `map` has been shadowed from its /// builtin binding. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct StarmapZip; impl Violation for StarmapZip { diff --git a/crates/ruff_linter/src/rules/ruff/rules/static_key_dict_comprehension.rs b/crates/ruff_linter/src/rules/ruff/rules/static_key_dict_comprehension.rs index 3cb7aa5633..79406ad4c1 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/static_key_dict_comprehension.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/static_key_dict_comprehension.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## Removed /// This rule was implemented in `flake8-bugbear` and has been remapped to [B035] @@ -28,7 +29,7 @@ use crate::Violation; /// /// [B035]: https://docs.astral.sh/ruff/rules/static-key-dict-comprehension/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.2.0")] +#[violation_metadata(removed_since = "v0.2.0", category = Category::Correctness)] pub(crate) struct RuffStaticKeyDictComprehension; impl Violation for RuffStaticKeyDictComprehension { diff --git a/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs b/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs index d261e05ef8..3bf20078ca 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs @@ -19,6 +19,7 @@ use ruff_text_size::TextSize; use crate::Locator; use crate::checkers::ast::LintContext; +use crate::codes::Category; use crate::registry::Rule; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -69,7 +70,7 @@ pub(crate) trait TestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.0")] +#[violation_metadata(stable_since = "0.0.0", category = Category::Testing)] pub(crate) struct StableTestRule; impl Violation for StableTestRule { @@ -103,7 +104,7 @@ impl TestRule for StableTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.0")] +#[violation_metadata(stable_since = "0.0.0", category = Category::Testing)] pub(crate) struct StableTestRuleSafeFix; impl Violation for StableTestRuleSafeFix { @@ -142,7 +143,7 @@ impl TestRule for StableTestRuleSafeFix { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.0")] +#[violation_metadata(stable_since = "0.0.0", category = Category::Testing)] pub(crate) struct StableTestRuleUnsafeFix; impl Violation for StableTestRuleUnsafeFix { @@ -184,7 +185,7 @@ impl TestRule for StableTestRuleUnsafeFix { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.0")] +#[violation_metadata(stable_since = "0.0.0", category = Category::Testing)] pub(crate) struct StableTestRuleDisplayOnlyFix; impl Violation for StableTestRuleDisplayOnlyFix { @@ -229,7 +230,7 @@ impl TestRule for StableTestRuleDisplayOnlyFix { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.0.0")] +#[violation_metadata(preview_since = "0.0.0", category = Category::Testing)] pub(crate) struct PreviewTestRule; impl Violation for PreviewTestRule { @@ -263,7 +264,7 @@ impl TestRule for PreviewTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(deprecated_since = "0.0.0")] +#[violation_metadata(deprecated_since = "0.0.0", category = Category::Testing)] pub(crate) struct DeprecatedTestRule; impl Violation for DeprecatedTestRule { @@ -297,7 +298,7 @@ impl TestRule for DeprecatedTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(deprecated_since = "0.0.0")] +#[violation_metadata(deprecated_since = "0.0.0", category = Category::Testing)] pub(crate) struct AnotherDeprecatedTestRule; impl Violation for AnotherDeprecatedTestRule { @@ -334,7 +335,7 @@ impl TestRule for AnotherDeprecatedTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.0.0")] +#[violation_metadata(removed_since = "0.0.0", category = Category::Testing)] pub(crate) struct RemovedTestRule; impl Violation for RemovedTestRule { @@ -368,7 +369,7 @@ impl TestRule for RemovedTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.0.0")] +#[violation_metadata(removed_since = "0.0.0", category = Category::Testing)] pub(crate) struct AnotherRemovedTestRule; impl Violation for AnotherRemovedTestRule { @@ -402,7 +403,7 @@ impl TestRule for AnotherRemovedTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.0.0")] +#[violation_metadata(removed_since = "0.0.0", category = Category::Testing)] pub(crate) struct RedirectedFromTestRule; impl Violation for RedirectedFromTestRule { @@ -436,7 +437,7 @@ impl TestRule for RedirectedFromTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.0.0")] +#[violation_metadata(stable_since = "0.0.0", category = Category::Testing)] pub(crate) struct RedirectedToTestRule; impl Violation for RedirectedToTestRule { @@ -470,7 +471,7 @@ impl TestRule for RedirectedToTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.0.0")] +#[violation_metadata(removed_since = "0.0.0", category = Category::Testing)] pub(crate) struct RedirectedFromPrefixTestRule; impl Violation for RedirectedFromPrefixTestRule { @@ -507,7 +508,7 @@ impl TestRule for RedirectedFromPrefixTestRule { /// bar /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.0.0")] +#[violation_metadata(preview_since = "0.0.0", category = Category::Testing)] pub(crate) struct PanicyTestRule; impl Violation for PanicyTestRule { diff --git a/crates/ruff_linter/src/rules/ruff/rules/unmatched_suppression_comment.rs b/crates/ruff_linter/src/rules/ruff/rules/unmatched_suppression_comment.rs index c813d98c5e..7ddd05f9c0 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unmatched_suppression_comment.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unmatched_suppression_comment.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## What it does /// Checks for unmatched range suppression comments @@ -31,7 +32,7 @@ use crate::Violation; /// ## References /// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Suspicious)] pub(crate) struct UnmatchedSuppressionComment; impl Violation for UnmatchedSuppressionComment { diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_assign_before_yield.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_assign_before_yield.rs index 3e2e952455..a927dba61c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_assign_before_yield.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_assign_before_yield.rs @@ -9,6 +9,7 @@ use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits; use crate::rules::flake8_return::has_conditional_body; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -40,7 +41,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// variable assignment changes the local variable bindings visible to /// `locals()` and debuggers when the generator is suspended at the `yield`. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.3")] +#[violation_metadata(preview_since = "0.15.3", category = Category::Pedantic)] pub(crate) struct UnnecessaryAssignBeforeYield { name: String, is_yield_from: bool, diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_cast_to_int.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_cast_to_int.rs index 212a901222..bc29718a9f 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_cast_to_int.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_cast_to_int.rs @@ -1,14 +1,12 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast::token::{Tokens, parenthesized_range}; use ruff_python_ast::{Arguments, Expr, ExprCall}; +use ruff_python_edits::unwrapped_call_argument; use ruff_python_semantic::SemanticModel; use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType}; -use ruff_python_trivia::{CommentRanges, lines_after_ignoring_trivia}; -use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; -use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::ruff::rules::unnecessary_round::{ InferredType, NdigitsValue, RoundedValue, rounded_and_ndigits, }; @@ -45,7 +43,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// overriding the `__round__`, `__ceil__`, `__floor__`, or `__trunc__` dunder methods /// such that they don't return an integer. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Complexity)] pub(crate) struct UnnecessaryCastToInt; impl AlwaysFixableViolation for UnnecessaryCastToInt { @@ -80,50 +78,20 @@ pub(crate) fn unnecessary_cast_to_int(checker: &Checker, call: &ExprCall) { return; }; - let fix = unwrap_int_expression( + let parent = checker + .semantic() + .current_expression_parent() + .map_or_else(|| checker.semantic().current_statement().into(), Into::into); + let content = unwrapped_call_argument( call, argument, - applicability, - checker.semantic(), - checker.locator(), + Some(parent), checker.tokens(), - checker.comment_ranges(), checker.source(), ); - checker - .report_diagnostic(UnnecessaryCastToInt, call.range()) - .set_fix(fix); -} - -/// Creates a fix that replaces `int(expression)` with `expression`. -#[expect(clippy::too_many_arguments)] -fn unwrap_int_expression( - call: &ExprCall, - argument: &Expr, - applicability: Applicability, - semantic: &SemanticModel, - locator: &Locator, - tokens: &Tokens, - comment_ranges: &CommentRanges, - source: &str, -) -> Fix { - let content = if let Some(range) = - parenthesized_range(argument.into(), (&call.arguments).into(), tokens) - { - locator.slice(range).to_string() - } else { - let parenthesize = semantic.current_expression_parent().is_some() - || argument.is_named_expr() - || locator.count_lines(argument.range()) > 0; - if parenthesize && !has_own_parentheses(argument, tokens, source) { - format!("({})", locator.slice(argument.range())) - } else { - locator.slice(argument.range()).to_string() - } - }; - // Since we're deleting the complement of the argument range within - // the call range, we have to check both ends for comments. + // Comments outside the argument's range can be lost when removing the call, + // so check both ends. // // For example: // ```python @@ -134,6 +102,7 @@ fn unwrap_int_expression( // ) // ``` let applicability = { + let comment_ranges = checker.comment_ranges(); let call_to_arg_start = TextRange::new(call.start(), argument.start()); let arg_to_call_end = TextRange::new(argument.end(), call.end()); if comment_ranges.intersects(call_to_arg_start) @@ -146,7 +115,10 @@ fn unwrap_int_expression( }; let edit = Edit::range_replacement(content, call.range()); - Fix::applicable_edit(edit, applicability) + let fix = Fix::applicable_edit(edit, applicability); + checker + .report_diagnostic(UnnecessaryCastToInt, call.range()) + .set_fix(fix); } /// Returns `Some` if `call` in `int(call(...))` is a method that returns an `int` @@ -253,48 +225,3 @@ fn round_applicability(arguments: &Arguments, semantic: &SemanticModel) -> Optio _ => None, } } - -/// Returns `true` if the given [`Expr`] has its own parentheses (e.g., `()`, `[]`, `{}`). -fn has_own_parentheses(expr: &Expr, tokens: &Tokens, source: &str) -> bool { - match expr { - Expr::ListComp(_) - | Expr::SetComp(_) - | Expr::DictComp(_) - | Expr::List(_) - | Expr::Set(_) - | Expr::Dict(_) => true, - Expr::Call(call_expr) => { - // A call where the function and parenthesized - // argument(s) appear on separate lines - // requires outer parentheses. That is: - // ``` - // (f - // (10)) - // ``` - // is different than - // ``` - // f - // (10) - // ``` - let func_end = - parenthesized_range(call_expr.func.as_ref().into(), call_expr.into(), tokens) - .unwrap_or(call_expr.func.range()) - .end(); - lines_after_ignoring_trivia(func_end, source) == 0 - } - Expr::Subscript(subscript_expr) => { - // Same as above - let subscript_end = parenthesized_range( - subscript_expr.value.as_ref().into(), - subscript_expr.into(), - tokens, - ) - .unwrap_or(subscript_expr.value.range()) - .end(); - lines_after_ignoring_trivia(subscript_end, source) == 0 - } - Expr::Generator(generator) => generator.parenthesized, - Expr::Tuple(tuple) => tuple.parenthesized, - _ => false, - } -} diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs index a7ef1e398f..6a0d827e40 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_if.rs @@ -12,6 +12,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix, fix}; /// ## What it does @@ -59,7 +60,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix, fix}; /// - [`empty-type-checking-block (TC005)`]: Detects empty `if TYPE_CHECKING` /// blocks specifically. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.8")] +#[violation_metadata(preview_since = "0.15.8", category = Category::Complexity)] pub(crate) struct UnnecessaryIf; impl AlwaysFixableViolation for UnnecessaryIf { diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs index 06e72cbd98..ac3605d09b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs @@ -7,6 +7,7 @@ use ruff_python_stdlib::builtins::is_iterator; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::snippet::SourceCodeSnippet; use crate::{AlwaysFixableViolation, Edit, Fix}; @@ -54,7 +55,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## References /// - [Iterators and Iterables in Python: Run Efficient Iterations](https://realpython.com/python-iterators-iterables/#when-to-use-an-iterator-in-python) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.278")] +#[violation_metadata(stable_since = "v0.0.278", category = Category::Performance)] pub(crate) struct UnnecessaryIterableAllocationForFirstElement { iterable: SourceCodeSnippet, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_key_check.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_key_check.rs index 0888cf2acd..6a7cefdf26 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_key_check.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_key_check.rs @@ -8,6 +8,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -34,7 +35,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// This rule's fix is marked as safe, unless the expression contains comments /// or may have side effects. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.2.0")] +#[violation_metadata(stable_since = "v0.2.0", category = Category::Complexity)] pub(crate) struct UnnecessaryKeyCheck; impl AlwaysFixableViolation for UnnecessaryKeyCheck { diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_literal_within_deque_call.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_literal_within_deque_call.rs index 2659e71555..028c321d50 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_literal_within_deque_call.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_literal_within_deque_call.rs @@ -7,6 +7,7 @@ use ruff_python_ast::{self as ast, Expr}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::fix::edits::{Parentheses, remove_argument}; use crate::{Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `collections.deque`](https://docs.python.org/3/library/collections.html#collections.deque) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.15.0")] +#[violation_metadata(stable_since = "0.15.0", category = Category::Complexity)] pub(crate) struct UnnecessaryEmptyIterableWithinDequeCall { has_maxlen: bool, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_nested_literal.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_nested_literal.rs index 6776b497a3..a5b6550826 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_nested_literal.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_nested_literal.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::analyze::typing::traverse_literal; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -59,7 +60,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// /// [PEP 586]: https://peps.python.org/pep-0586/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.10.0")] +#[violation_metadata(stable_since = "0.10.0", category = Category::Style)] pub(crate) struct UnnecessaryNestedLiteral; impl Violation for UnnecessaryNestedLiteral { diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs index 58d36b95df..3342a3bb37 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs @@ -9,6 +9,7 @@ use ruff_python_semantic::{Modules, SemanticModel}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -55,7 +56,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python Regular Expression HOWTO: Common Problems - Use String Methods](https://docs.python.org/3/howto/regex.html#use-string-methods) #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.1")] +#[violation_metadata(preview_since = "0.8.1", category = Category::Complexity)] pub(crate) struct UnnecessaryRegularExpression { replacement: Option, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_round.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_round.rs index e21e14432d..583c3ad0f6 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_round.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_round.rs @@ -1,13 +1,13 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{Arguments, Expr, ExprCall, ExprNumberLiteral, Number}; +use ruff_python_edits::unwrapped_call_argument; use ruff_python_semantic::SemanticModel; use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType}; use ruff_python_semantic::analyze::typing; -use ruff_source_file::find_newline; use ruff_text_size::Ranged; -use crate::Locator; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// ## What it does @@ -34,7 +34,7 @@ use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; /// The fix is marked unsafe if it is not possible to guarantee that the first argument of /// `round()` is of type `int`, or if the fix deletes comments. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.12.0")] +#[violation_metadata(stable_since = "0.12.0", category = Category::Complexity)] pub(crate) struct UnnecessaryRound; impl AlwaysFixableViolation for UnnecessaryRound { @@ -98,7 +98,18 @@ pub(crate) fn unnecessary_round(checker: &Checker, call: &ExprCall) { applicability = Applicability::Unsafe; } - let edit = unwrap_round_call(call, rounded, checker.semantic(), checker.locator()); + let parent = checker + .semantic() + .current_expression_parent() + .map_or_else(|| checker.semantic().current_statement().into(), Into::into); + let content = unwrapped_call_argument( + call, + rounded, + Some(parent), + checker.tokens(), + checker.source(), + ); + let edit = Edit::range_replacement(content, call.range()); let fix = Fix::applicable_edit(edit, applicability); checker @@ -209,21 +220,3 @@ pub(super) fn rounded_and_ndigits<'a>( Some((rounded, rounded_kind, ndigits_kind)) } - -fn unwrap_round_call( - call: &ExprCall, - rounded: &Expr, - semantic: &SemanticModel, - locator: &Locator, -) -> Edit { - let rounded_expr = locator.slice(rounded.range()); - let has_parent_expr = semantic.current_expression_parent().is_some(); - let new_content = - if has_parent_expr || rounded.is_named_expr() || find_newline(rounded_expr).is_some() { - format!("({rounded_expr})") - } else { - rounded_expr.to_string() - }; - - Edit::range_replacement(new_content, call.range()) -} diff --git a/crates/ruff_linter/src/rules/ruff/rules/unraw_re_pattern.rs b/crates/ruff_linter/src/rules/ruff/rules/unraw_re_pattern.rs index 74c23da2a9..ce28d5b5a8 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unraw_re_pattern.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unraw_re_pattern.rs @@ -10,6 +10,7 @@ use ruff_python_semantic::{Modules, SemanticModel}; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -59,7 +60,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// re.compile(r"foo\bar") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.0")] +#[violation_metadata(preview_since = "0.8.0", category = Category::Pedantic)] pub(crate) struct UnrawRePattern { module: RegexModule, func: String, diff --git a/crates/ruff_linter/src/rules/ruff/rules/unsafe_markup_use.rs b/crates/ruff_linter/src/rules/ruff/rules/unsafe_markup_use.rs index 3adf4bc5d1..0daff04f45 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unsafe_markup_use.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unsafe_markup_use.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## Removed /// This rule was implemented in `bandit` and has been remapped to @@ -73,7 +74,7 @@ use crate::Violation; /// [markupsafe-markup]: https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup /// [flake8-markupsafe]: https://github.com/vmagamedov/flake8-markupsafe #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "0.10.0")] +#[violation_metadata(removed_since = "0.10.0", category = Category::Security)] pub(crate) struct RuffUnsafeMarkupUse { name: String, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs b/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs index 2b898822de..afc75536c0 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs @@ -8,6 +8,7 @@ use ruff_python_semantic::analyze::function_type::is_stub; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::fastapi::rules::is_fastapi_route; @@ -36,7 +37,7 @@ use crate::rules::fastapi::rules::is_fastapi_route; /// bar() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "v0.4.0")] +#[violation_metadata(preview_since = "v0.4.0", category = Category::Pedantic)] pub(crate) struct UnusedAsync { name: String, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/unused_noqa.rs b/crates/ruff_linter/src/rules/ruff/rules/unused_noqa.rs index ea5aed73f0..7bacfaf66b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unused_noqa.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unused_noqa.rs @@ -3,6 +3,7 @@ use itertools::Itertools; use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::AlwaysFixableViolation; +use crate::codes::Category; #[derive(Debug, PartialEq, Eq, Default)] pub(crate) struct UnusedCodes<'a> { @@ -87,7 +88,7 @@ impl UnusedNOQAKind { /// /// [RUF102]: https://docs.astral.sh/ruff/rules/invalid-rule-code/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.155")] +#[violation_metadata(stable_since = "v0.0.155", category = Category::Suspicious)] pub(crate) struct UnusedNOQA<'a> { pub codes: Option>, pub kind: UnusedNOQAKind, diff --git a/crates/ruff_linter/src/rules/ruff/rules/unused_unpacked_variable.rs b/crates/ruff_linter/src/rules/ruff/rules/unused_unpacked_variable.rs index c851e22e85..625771bde2 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unused_unpacked_variable.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unused_unpacked_variable.rs @@ -3,6 +3,7 @@ use ruff_python_semantic::Binding; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::renamer::ShadowedKind; use crate::{Edit, Fix, FixAvailability, Violation}; @@ -46,7 +47,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// [F841]: https://docs.astral.sh/ruff/rules/unused-variable/ #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.13.0")] +#[violation_metadata(stable_since = "0.13.0", category = Category::Suspicious)] pub(crate) struct UnusedUnpackedVariable { pub name: String, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/used_dummy_variable.rs b/crates/ruff_linter/src/rules/ruff/rules/used_dummy_variable.rs index 343249a9a7..09000bbee4 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/used_dummy_variable.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/used_dummy_variable.rs @@ -4,6 +4,7 @@ use ruff_python_semantic::{Binding, BindingId, BindingKind, ScopeKind}; use ruff_python_stdlib::identifiers::is_identifier; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Fix, FixAvailability, Violation}; use crate::{ checkers::ast::Checker, @@ -68,7 +69,7 @@ use crate::{ /// /// [PEP 8]: https://peps.python.org/pep-0008/ #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.8.2")] +#[violation_metadata(preview_since = "0.8.2", category = Category::Pedantic)] pub(crate) struct UsedDummyVariable { name: String, shadowed_kind: Option, diff --git a/crates/ruff_linter/src/rules/ruff/rules/useless_finally.rs b/crates/ruff_linter/src/rules/ruff/rules/useless_finally.rs index bcd2798584..e8af70dfce 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/useless_finally.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/useless_finally.rs @@ -9,6 +9,7 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does @@ -59,7 +60,7 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// so RUF072 must remove it first /// - [`useless-try-except`][TRY203]: Flags `try/except` that only re-raises #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "0.15.8")] +#[violation_metadata(preview_since = "0.15.8", category = Category::Complexity)] pub(crate) struct UselessFinally; impl Violation for UselessFinally { diff --git a/crates/ruff_linter/src/rules/ruff/rules/useless_if_else.rs b/crates/ruff_linter/src/rules/ruff/rules/useless_if_else.rs index bbaa49f0fa..db8e262a19 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/useless_if_else.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/useless_if_else.rs @@ -4,6 +4,7 @@ use ruff_python_ast::comparable::ComparableExpr; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for useless `if`-`else` conditions with identical arms. @@ -22,7 +23,7 @@ use crate::checkers::ast::Checker; /// foo = x /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.9.0")] +#[violation_metadata(stable_since = "0.9.0", category = Category::Correctness)] pub(crate) struct UselessIfElse; impl Violation for UselessIfElse { diff --git a/crates/ruff_linter/src/rules/ruff/rules/zip_instead_of_pairwise.rs b/crates/ruff_linter/src/rules/ruff/rules/zip_instead_of_pairwise.rs index 70cb828790..b126de3750 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/zip_instead_of_pairwise.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/zip_instead_of_pairwise.rs @@ -2,6 +2,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, Arguments, Expr, Int}; use ruff_text_size::Ranged; +use crate::codes::Category; use crate::{Edit, Fix, FixAvailability, Violation}; use crate::{checkers::ast::Checker, importer::ImportRequest}; @@ -40,7 +41,7 @@ use crate::{checkers::ast::Checker, importer::ImportRequest}; /// ## References /// - [Python documentation: `itertools.pairwise`](https://docs.python.org/3/library/itertools.html#itertools.pairwise) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.257")] +#[violation_metadata(stable_since = "v0.0.257", category = Category::Complexity)] pub(crate) struct ZipInsteadOfPairwise; impl Violation for ZipInsteadOfPairwise { diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_implicit-optional_RUF013_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_implicit-optional_RUF013_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_implicit-optional_RUF013_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_implicit-optional_RUF013_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF030_RUF030.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__assert-with-print-message_RUF030.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF030_RUF030.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__assert-with-print-message_RUF030.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF018_RUF018.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__assignment-in-assert_RUF018.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF018_RUF018.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__assignment-in-assert_RUF018.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF006_RUF006.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__asyncio-dangling-task_RUF006.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF006_RUF006.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__asyncio-dangling-task_RUF006.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF053_RUF053.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__class-with-mixed-type-vars_RUF053.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF053_RUF053.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__class-with-mixed-type-vars_RUF053.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF005_RUF005.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__collection-literal-concatenation_RUF005.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF005_RUF005.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__collection-literal-concatenation_RUF005.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF005_RUF005_slices.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__collection-literal-concatenation_RUF005_slices.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF005_RUF005_slices.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__collection-literal-concatenation_RUF005_slices.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__used-dummy-variable_RUF052_0.py_1.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__used-dummy-variable_RUF052_0.py_1.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__RUF052_RUF052_0.py_2.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__used-dummy-variable_RUF052_0.py_2.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__RUF052_RUF052_0.py_2.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__used-dummy-variable_RUF052_0.py_2.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF049_RUF049.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__dataclass-enum_RUF049.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF049_RUF049.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__dataclass-enum_RUF049.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF032_RUF032.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__decimal-from-float-literal_RUF032.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF032_RUF032.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__decimal-from-float-literal_RUF032.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF026_RUF026.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__default-factory-kwarg_RUF026.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF026_RUF026.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__default-factory-kwarg_RUF026.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF068_RUF068.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__duplicate-entry-in-dunder-all_RUF068.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF068_RUF068.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__duplicate-entry-in-dunder-all_RUF068.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF010_RUF010.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__explicit-f-string-type-conversion_RUF010.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF010_RUF010.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__explicit-f-string-type-conversion_RUF010.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF056_RUF056.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__falsy-dict-get-fallback_RUF056.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF056_RUF056.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__falsy-dict-get-fallback_RUF056.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_attrs.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_attrs.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008_deferred.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_attrs_auto_attribs.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008_deferred.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_attrs_auto_attribs.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_deferred.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_deferred.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_deferred.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__function-call-in-dataclass-default-argument_RUF009_deferred.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF051_RUF051.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__if-key-in-dict-del_RUF051.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF051_RUF051.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__if-key-in-dict-del_RUF051.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs_auto_attribs.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF009_RUF009_attrs_auto_attribs.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_3.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_3.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_3.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_4.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_4.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_4.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__implicit-optional_RUF013_4.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF060_RUF060.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__in-empty-collection_RUF060.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF060_RUF060.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__in-empty-collection_RUF060.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF074_RUF074.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__incorrect-decorator-order_RUF074.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF074_RUF074.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__incorrect-decorator-order_RUF074.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF031_RUF031.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__incorrectly-parenthesized-tuple-in-subscript_RUF031.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF031_RUF031.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__incorrectly-parenthesized-tuple-in-subscript_RUF031.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF040_RUF040.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-assert-message-literal-argument_RUF040.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF040_RUF040.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-assert-message-literal-argument_RUF040.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF028_RUF028.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-formatter-suppression-comment_RUF028.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF028_RUF028.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-formatter-suppression-comment_RUF028.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-index-type_RUF016.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-index-type_RUF016.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_bleach.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_bleach.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_bleach.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_bleach.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_invalid_author.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_invalid_author.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_invalid_author.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_invalid_author.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012_deferred.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_maturin.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012_deferred.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_maturin.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_pep639.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF013_RUF013_2.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_pep639.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_various_invalid.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_various_invalid.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_various_invalid.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-pyproject-toml_various_invalid.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF102_RUF102.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-rule-code_RUF102.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF102_RUF102.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-rule-code_RUF102.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-rule-code_RUF102_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__invalid-rule-code_RUF102_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_deprecated_call.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_deprecated_call.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_deprecated_call.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_deprecated_call.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_raises.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_raises.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_raises.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_raises.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_warns.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_warns.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF061_RUF061_warns.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__legacy-form-pytest-raises_RUF061_warns.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF065_RUF065_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__logging-eager-conversion_RUF065_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF065_RUF065_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__logging-eager-conversion_RUF065_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF065_RUF065_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__logging-eager-conversion_RUF065_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF065_RUF065_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__logging-eager-conversion_RUF065_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF048_RUF048.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__map-int-version-parsing_RUF048.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF048_RUF048.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__map-int-version-parsing_RUF048.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF048_RUF048_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__map-int-version-parsing_RUF048_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF048_RUF048_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__map-int-version-parsing_RUF048_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF027_RUF027_2.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF067_RUF067__modules__okay.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF067_RUF067__modules__okay.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__missing-f-string-syntax_RUF027_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-class-default_RUF012.py.snap similarity index 76% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-class-default_RUF012.py.snap index 5cc2fd8c41..f36fb52b8a 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-class-default_RUF012.py.snap @@ -139,3 +139,58 @@ RUF012 Mutable default value for class attribute 148 | ("attr_set", ctypes.c_uint64), | help: Consider initializing in `__init__` or annotating with `typing.ClassVar` + +RUF012 Mutable default value for class attribute + --> RUF012.py:155:12 + | +154 | class LES(ctypes.LittleEndianStructure): +155 | test = [""] + | ^^^^ +156 | _fields_ = [ +157 | ("attr_set", ctypes.c_uint64), + | +help: Consider initializing in `__init__` or annotating with `typing.ClassVar` + +RUF012 Mutable default value for class attribute + --> RUF012.py:164:12 + | +163 | class BES(ctypes.BigEndianStructure): +164 | test = [""] + | ^^^^ +165 | _fields_ = [ +166 | ("attr_set", ctypes.c_uint64), + | +help: Consider initializing in `__init__` or annotating with `typing.ClassVar` + +RUF012 Mutable default value for class attribute + --> RUF012.py:173:12 + | +172 | class U(ctypes.Union): +173 | test = [""] + | ^^^^ +174 | _fields_ = [ +175 | ("a", LES), + | +help: Consider initializing in `__init__` or annotating with `typing.ClassVar` + +RUF012 Mutable default value for class attribute + --> RUF012.py:180:12 + | +179 | class LEU(ctypes.LittleEndianUnion): +180 | test = [""] + | ^^^^ +181 | _fields_ = [ +182 | ("a", LES), + | +help: Consider initializing in `__init__` or annotating with `typing.ClassVar` + +RUF012 Mutable default value for class attribute + --> RUF012.py:187:12 + | +186 | class BEU(ctypes.BigEndianUnion): +187 | test = [""] + | ^^^^ +188 | _fields_ = [ +189 | ("a", LES), + | +help: Consider initializing in `__init__` or annotating with `typing.ClassVar` diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF102_RUF102_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-class-default_RUF012_deferred.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF102_RUF102_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-class-default_RUF012_deferred.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008_attrs.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008_attrs.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF008_RUF008_attrs.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008_attrs.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_maturin.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008_deferred.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_maturin.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-dataclass-default_RUF008_deferred.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF024_RUF024.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-fromkeys-value_RUF024.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF024_RUF024.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__mutable-fromkeys-value_RUF024.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_for.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_for.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_for.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_for.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_if.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_if.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_if.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_if.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_try.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_try.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_try.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_try.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_while.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_while.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF047_RUF047_while.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__needless-else_RUF047_while.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF020_RUF020.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__never-union_RUF020.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF020_RUF020.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__never-union_RUF020.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF067_RUF067__modules____init__.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-empty-init-module_RUF067__modules____init__.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF067_RUF067__modules____init__.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-empty-init-module_RUF067__modules____init__.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_pep639.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-empty-init-module_RUF067__modules__okay.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF200_pep639.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-empty-init-module_RUF067__modules__okay.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF064_RUF064.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-octal-permissions_RUF064.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF064_RUF064.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__non-octal-permissions_RUF064.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF036_RUF036.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__none-not-at-end-of-union_RUF036.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF036_RUF036.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__none-not-at-end-of-union_RUF036.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF036_RUF036.pyi.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__none-not-at-end-of-union_RUF036.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF036_RUF036.pyi.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__none-not-at-end-of-union_RUF036.pyi.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF021_RUF021.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__parenthesize-chained-operators_RUF021.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF021_RUF021.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__parenthesize-chained-operators_RUF021.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF033_RUF033.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__post-init-default_RUF033.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF033_RUF033.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__post-init-default_RUF033.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF073_RUF073.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__f-string-percent-format_RUF073.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF073_RUF073.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__f-string-percent-format_RUF073.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF069_RUF069.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__float-equality-comparison_RUF069.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF069_RUF069.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__float-equality-comparison_RUF069.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008_basedpython.by.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__function-call-in-dataclass-default-argument_RUF009_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008_basedpython.by.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__function-call-in-dataclass-default-argument_RUF009_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF045_RUF045.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__implicit-class-var-in-dataclass_RUF045.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF045_RUF045.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__implicit-class-var-in-dataclass_RUF045.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF054_RUF054.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__indented-form-feed_RUF054.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF054_RUF054.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__indented-form-feed_RUF054.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF012_RUF012_basedpython.by.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-class-default_RUF012_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF012_RUF012_basedpython.by.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-class-default_RUF012_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008_attrs.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008_attrs.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF008_RUF008_attrs.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008_attrs.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF009_RUF009_basedpython.by.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF009_RUF009_basedpython.by.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__mutable-dataclass-default_RUF008_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF071_RUF071.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__os-path-commonprefix_RUF071.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF071_RUF071.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__os-path-commonprefix_RUF071.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py37__RUF039_RUF039_py_version_sensitive.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py37__unraw-re-pattern_RUF039_py_version_sensitive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py37__RUF039_RUF039_py_version_sensitive.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py37__unraw-re-pattern_RUF039_py_version_sensitive.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py38__RUF039_RUF039_py_version_sensitive.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py38__unraw-re-pattern_RUF039_py_version_sensitive.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py38__RUF039_RUF039_py_version_sensitive.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__py38__unraw-re-pattern_RUF039_py_version_sensitive.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF070_RUF070.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-assign-before-yield_RUF070.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF070_RUF070.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-assign-before-yield_RUF070.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF050_RUF050_basedpython.by.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-if_RUF050_basedpython.by.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF050_RUF050_basedpython.by.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-if_RUF050_basedpython.by.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_2.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_3.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF055_RUF055_3.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unnecessary-regular-expression_RUF055_3.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF039_RUF039.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unraw-re-pattern_RUF039.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF039_RUF039.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unraw-re-pattern_RUF039.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF039_RUF039_concat.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unraw-re-pattern_RUF039_concat.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF039_RUF039_concat.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__unraw-re-pattern_RUF039_concat.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF072_RUF072.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__useless-finally_RUF072.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF072_RUF072.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__useless-finally_RUF072.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF066_RUF066.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__property-without-return_RUF066.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF066_RUF066.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__property-without-return_RUF066.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__py314__RUF058_RUF058_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__py314__starmap-zip_RUF058_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__py314__RUF058_RUF058_2.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__py314__starmap-zip_RUF058_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF043_RUF043.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__pytest-raises-ambiguous-pattern_RUF043.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF043_RUF043.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__pytest-raises-ambiguous-pattern_RUF043.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF017_RUF017_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__quadratic-list-summation_RUF017_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF017_RUF017_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__quadratic-list-summation_RUF017_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF017_RUF017_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__quadratic-list-summation_RUF017_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF017_RUF017_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__quadratic-list-summation_RUF017_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__range_suppressions.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__range_suppressions.snap index c65ef006ef..f22767b936 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__range_suppressions.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__range_suppressions.snap @@ -300,7 +300,6 @@ RUF102 [*] Invalid rule code in suppression: YF829 96 | # ruff: enable[F841, RQW320] 97 | # ruff: enable[YF829] | ----- -help: Add non-Ruff rule codes to the `lint.external` configuration option help: Remove the suppression comment | 92 | # Unknown rule codes @@ -324,7 +323,6 @@ RUF102 [*] Invalid rule code in suppression: RQW320 | ------ 97 | # ruff: enable[YF829] | -help: Add non-Ruff rule codes to the `lint.external` configuration option help: Remove the rule code `RQW320` | 93 | # ruff: disable[YF829] diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF101_RUF101_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redirected-noqa_RUF101_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF101_RUF101_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redirected-noqa_RUF101_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF101_RUF101_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redirected-noqa_RUF101_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF101_RUF101_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redirected-noqa_RUF101_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF038_RUF038.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redundant-bool-literal_RUF038.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF038_RUF038.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redundant-bool-literal_RUF038.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF038_RUF038.pyi.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redundant-bool-literal_RUF038.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF038_RUF038.pyi.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__redundant-bool-literal_RUF038.pyi.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF058_RUF058_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__starmap-zip_RUF058_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF058_RUF058_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__starmap-zip_RUF058_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF058_RUF058_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__starmap-zip_RUF058_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF058_RUF058_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__starmap-zip_RUF058_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046.py.snap similarity index 83% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046.py.snap index cd45cd92f8..1c3668cf02 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046.py.snap @@ -890,7 +890,7 @@ RUF046 [*] Value being cast to `int` is already an integer 169 | | (1)) | |____^ 170 | -171 | int(round # a comment +171 | # Attribute access within the callee can also rely on the outer parentheses. | help: Remove unnecessary `int` call | @@ -901,113 +901,110 @@ help: Remove unnecessary `int` call | RUF046 [*] Value being cast to `int` is already an integer - --> RUF046.py:171:1 + --> RUF046.py:172:1 | -169 | (1)) -170 | -171 | / int(round # a comment -172 | | # and another comment -173 | | (10) -174 | | ) - | |_^ -175 | -176 | int(round (17)) # this is safe without parens +171 | # Attribute access within the callee can also rely on the outer parentheses. +172 | / int(math +173 | | .floor(1.5)) + | |____________^ +174 | +175 | int(round # a comment | help: Remove unnecessary `int` call | -170 | - - int(round # a comment -171 + (round # a comment -172 | # and another comment - - (10) - - ) -173 + (10)) -174 | +171 | # Attribute access within the callee can also rely on the outer parentheses. + - int(math +172 + (math +173 | .floor(1.5)) | +note: This is an unsafe fix and may change runtime behavior RUF046 [*] Value being cast to `int` is already an integer - --> RUF046.py:176:1 + --> RUF046.py:175:1 | -174 | ) -175 | -176 | int(round (17)) # this is safe without parens - | ^^^^^^^^^^^^^^^ -177 | -178 | int( round ( +173 | .floor(1.5)) +174 | +175 | / int(round # a comment +176 | | # and another comment +177 | | (10) +178 | | ) + | |_^ +179 | +180 | int(round (17)) # this is safe without parens | help: Remove unnecessary `int` call | -175 | - - int(round (17)) # this is safe without parens -176 + round (17) # this is safe without parens -177 | +174 | + - int(round # a comment +175 + (round # a comment +176 | # and another comment + - (10) + - ) +177 + (10)) +178 | | RUF046 [*] Value being cast to `int` is already an integer - --> RUF046.py:178:1 + --> RUF046.py:180:1 | -176 | int(round (17)) # this is safe without parens -177 | -178 | / int( round ( -179 | | 17 -180 | | )) # this is also safe without parens - | |______________^ +178 | ) +179 | +180 | int(round (17)) # this is safe without parens + | ^^^^^^^^^^^^^^^ 181 | -182 | int((round) # Comment +182 | int( round ( | help: Remove unnecessary `int` call | -177 | - - int( round ( -178 + round ( -179 | 17 - - )) # this is also safe without parens -180 + ) # this is also safe without parens +179 | + - int(round (17)) # this is safe without parens +180 + round (17) # this is safe without parens 181 | | RUF046 [*] Value being cast to `int` is already an integer --> RUF046.py:182:1 | -180 | )) # this is also safe without parens +180 | int(round (17)) # this is safe without parens 181 | -182 | / int((round) # Comment -183 | | (42) -184 | | ) - | |_^ +182 | / int( round ( +183 | | 17 +184 | | )) # this is also safe without parens + | |______________^ 185 | -186 | int((round # Comment +186 | int((round) # Comment | help: Remove unnecessary `int` call | 181 | - - int((round) # Comment - - (42) - - ) -182 + ((round) # Comment -183 + (42)) -184 | + - int( round ( +182 + round ( +183 | 17 + - )) # this is also safe without parens +184 + ) # this is also safe without parens +185 | | RUF046 [*] Value being cast to `int` is already an integer --> RUF046.py:186:1 | -184 | ) +184 | )) # this is also safe without parens 185 | -186 | / int((round # Comment -187 | | )(42) +186 | / int((round) # Comment +187 | | (42) 188 | | ) | |_^ 189 | -190 | int( # Unsafe fix because of this comment +190 | int((round # Comment | help: Remove unnecessary `int` call | 185 | - - int((round # Comment -186 + (round # Comment -187 | )(42) + - int((round) # Comment + - (42) - ) +186 + ((round) # Comment +187 + (42)) 188 | | @@ -1016,79 +1013,151 @@ RUF046 [*] Value being cast to `int` is already an integer | 188 | ) 189 | -190 | / int( # Unsafe fix because of this comment -191 | | ( # Comment -192 | | (round -193 | | ) # Comment -194 | | )(42) -195 | | ) +190 | / int((round # Comment +191 | | )(42) +192 | | ) | |_^ -196 | -197 | int( +193 | +194 | int( # Unsafe fix because of this comment | help: Remove unnecessary `int` call | 189 | + - int((round # Comment +190 + (round # Comment +191 | )(42) + - ) +192 | + | + +RUF046 [*] Value being cast to `int` is already an integer + --> RUF046.py:194:1 + | +192 | ) +193 | +194 | / int( # Unsafe fix because of this comment +195 | | ( # Comment +196 | | (round +197 | | ) # Comment +198 | | )(42) +199 | | ) + | |_^ +200 | +201 | int( + | +help: Remove unnecessary `int` call + | +193 | - int( # Unsafe fix because of this comment -190 | ( # Comment -191 | (round -192 | ) # Comment -193 | )(42) +194 | ( # Comment +195 | (round +196 | ) # Comment +197 | )(42) - ) -194 | +198 | | note: This is an unsafe fix and may change runtime behavior RUF046 [*] Value being cast to `int` is already an integer - --> RUF046.py:197:1 + --> RUF046.py:201:1 | -195 | ) -196 | -197 | / int( -198 | | round( -199 | | 42 -200 | | ) # unsafe fix because of this comment -201 | | ) +199 | ) +200 | +201 | / int( +202 | | round( +203 | | 42 +204 | | ) # unsafe fix because of this comment +205 | | ) | |_^ -202 | -203 | int( +206 | +207 | int( | help: Remove unnecessary `int` call | -196 | +200 | - int( - round( -197 + round( -198 | 42 +201 + round( +202 | 42 - ) # unsafe fix because of this comment - ) -199 + ) -200 | +203 + ) +204 | | note: This is an unsafe fix and may change runtime behavior RUF046 [*] Value being cast to `int` is already an integer - --> RUF046.py:203:1 + --> RUF046.py:207:1 | -201 | ) -202 | -203 | / int( -204 | | round( -205 | | 42 -206 | | ) -207 | | # unsafe fix because of this comment -208 | | ) +205 | ) +206 | +207 | / int( +208 | | round( +209 | | 42 +210 | | ) +211 | | # unsafe fix because of this comment +212 | | ) | |_^ +213 | +214 | # Integer attribute access still requires parentheses. + | help: Remove unnecessary `int` call | -202 | +206 | - int( - round( -203 + round( -204 | 42 - - ) +207 + round( +208 | 42 +209 | ) - # unsafe fix because of this comment - ) -205 + ) +210 | | note: This is an unsafe fix and may change runtime behavior + +RUF046 [*] Value being cast to `int` is already an integer + --> RUF046.py:215:1 + | +214 | # Integer attribute access still requires parentheses. +215 | int(1).real + | ^^^^^^ +216 | +217 | # Parentheses separate the replacement from adjacent keywords. + | +help: Remove unnecessary `int` call + | +214 | # Integer attribute access still requires parentheses. + - int(1).real +215 + (1).real +216 | + | + +RUF046 [*] Value being cast to `int` is already an integer + --> RUF046.py:218:1 + | +217 | # Parentheses separate the replacement from adjacent keywords. +218 | int(1)and True + | ^^^^^^ +219 | +220 | def parenthesized_callee(): + | +help: Remove unnecessary `int` call + | +217 | # Parentheses separate the replacement from adjacent keywords. + - int(1)and True +218 + (1)and True +219 | + | + +RUF046 [*] Value being cast to `int` is already an integer + --> RUF046.py:221:11 + | +220 | def parenthesized_callee(): +221 | return(int)(1) + | ^^^^^^^^ +help: Remove unnecessary `int` call + | +220 | def parenthesized_callee(): + - return(int)(1) +221 + return(1) + | diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046_CR.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046_CR.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046_CR.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046_CR.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046_LF.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046_LF.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF046_RUF046_LF.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-cast-to-int_RUF046_LF.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF037_RUF037.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-empty-iterable-within-deque-call_RUF037.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF037_RUF037.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-empty-iterable-within-deque-call_RUF037.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-if_RUF050.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF050_RUF050.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-if_RUF050.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF015_RUF015.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-iterable-allocation-for-first-element_RUF015.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF015_RUF015.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-iterable-allocation-for-first-element_RUF015.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF019_RUF019.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-key-check_RUF019.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF019_RUF019.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-key-check_RUF019.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF041_RUF041.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-nested-literal_RUF041.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF041_RUF041.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-nested-literal_RUF041.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF041_RUF041.pyi.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-nested-literal_RUF041.pyi.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF041_RUF041.pyi.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-nested-literal_RUF041.pyi.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF057_RUF057.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-round_RUF057.py.snap similarity index 78% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF057_RUF057.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-round_RUF057.py.snap index 6cfe46c736..798c39f39a 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF057_RUF057.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unnecessary-round_RUF057.py.snap @@ -242,3 +242,90 @@ help: Remove unnecessary `round` call 82 | | note: This is an unsafe fix and may change runtime behavior + +RUF057 [*] Value being rounded is already an integer + --> RUF057.py:90:1 + | +89 | # Assignment expressions remain parenthesized after the call is removed. +90 | round(value := 1) + | ^^^^^^^^^^^^^^^^^ +91 | +92 | # Integer attribute access still requires parentheses. + | +help: Remove unnecessary `round` call + | +89 | # Assignment expressions remain parenthesized after the call is removed. + - round(value := 1) +90 + (value := 1) +91 | + | + +RUF057 [*] Value being rounded is already an integer + --> RUF057.py:93:1 + | +92 | # Integer attribute access still requires parentheses. +93 | round(1).real + | ^^^^^^^^ +94 | +95 | # Parentheses separate the replacement from adjacent keywords. + | +help: Remove unnecessary `round` call + | +92 | # Integer attribute access still requires parentheses. + - round(1).real +93 + (1).real +94 | + | + +RUF057 [*] Value being rounded is already an integer + --> RUF057.py:96:1 + | +95 | # Parentheses separate the replacement from adjacent keywords. +96 | round(1)and True + | ^^^^^^^^ +97 | +98 | def parenthesized_callee(): + | +help: Remove unnecessary `round` call + | +95 | # Parentheses separate the replacement from adjacent keywords. + - round(1)and True +96 + (1)and True +97 | + | + +RUF057 [*] Value being rounded is already an integer + --> RUF057.py:99:11 + | + 98 | def parenthesized_callee(): + 99 | return(round)(1) + | ^^^^^^^^^^ +100 | +101 | # Preserve comments within explicit argument parentheses. + | +help: Remove unnecessary `round` call + | +98 | def parenthesized_callee(): + - return(round)(1) +99 + return(1) +100 | + | + +RUF057 [*] Value being rounded is already an integer + --> RUF057.py:102:1 + | +101 | # Preserve comments within explicit argument parentheses. +102 | / round(( # Keep the argument comment. +103 | | 1 +104 | | )) + | |__^ +help: Remove unnecessary `round` call + | +101 | # Preserve comments within explicit argument parentheses. + - round(( # Keep the argument comment. +102 + ( # Keep the argument comment. +103 | 1 + - )) +104 + ) + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF022_RUF022.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unsorted-dunder-all_RUF022.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF022_RUF022.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unsorted-dunder-all_RUF022.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF023_RUF023.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unsorted-dunder-slots_RUF023.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF023_RUF023.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unsorted-dunder-slots_RUF023.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF029_RUF029.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-async_RUF029.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF029_RUF029.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-async_RUF029.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_0.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_2.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_2.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_2.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_3.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_3.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF059_RUF059_3.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__unused-unpacked-variable_RUF059_3.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__RUF052_RUF052_0.py_1.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__used-dummy-variable_RUF052_0.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__custom_dummy_var_regexp_preset__RUF052_RUF052_0.py_1.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__used-dummy-variable_RUF052_0.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__used-dummy-variable_RUF052_1.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF052_RUF052_1.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__used-dummy-variable_RUF052_1.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF034_RUF034.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__useless-if-else_RUF034.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF034_RUF034.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__useless-if-else_RUF034.py.snap diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF007_RUF007.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__zip-instead-of-pairwise_RUF007.py.snap similarity index 100% rename from crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF007_RUF007.py.snap rename to crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__zip-instead-of-pairwise_RUF007.py.snap diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/error_instead_of_exception.rs b/crates/ruff_linter/src/rules/tryceratops/rules/error_instead_of_exception.rs index 891bcb342c..5b880d0639 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/error_instead_of_exception.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/error_instead_of_exception.rs @@ -6,6 +6,7 @@ use ruff_python_stdlib::logging::LoggingLevel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::importer::ImportRequest; use crate::rules::tryceratops::helpers::LoggerCandidateVisitor; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; @@ -56,7 +57,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `logging.exception`](https://docs.python.org/3/library/logging.html#logging.exception) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct ErrorInsteadOfException; impl Violation for ErrorInsteadOfException { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_args.rs b/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_args.rs index d66b4457db..be0c4a3037 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_args.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_args.rs @@ -4,6 +4,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for long exception messages that are not defined in the exception @@ -44,7 +45,7 @@ use crate::checkers::ast::Checker; /// raise CantBeNegative(x) /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Pedantic)] pub(crate) struct RaiseVanillaArgs; impl Violation for RaiseVanillaArgs { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_class.rs b/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_class.rs index 3de68c5f30..a1c69d0606 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_class.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/raise_vanilla_class.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for code that raises `Exception` or `BaseException` directly. @@ -53,7 +54,7 @@ use crate::checkers::ast::Checker; /// logger.error("Oops") /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.236")] +#[violation_metadata(stable_since = "v0.0.236", category = Category::Style)] pub(crate) struct RaiseVanillaClass; impl Violation for RaiseVanillaClass { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/raise_within_try.rs b/crates/ruff_linter/src/rules/tryceratops/rules/raise_within_try.rs index cacab604c7..9f04312430 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/raise_within_try.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/raise_within_try.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `raise` statements within `try` blocks. The only `raise`s @@ -50,7 +51,7 @@ use crate::checkers::ast::Checker; /// raise /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.233")] +#[violation_metadata(stable_since = "v0.0.233", category = Category::Pedantic)] pub(crate) struct RaiseWithinTry; impl Violation for RaiseWithinTry { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/reraise_no_cause.rs b/crates/ruff_linter/src/rules/tryceratops/rules/reraise_no_cause.rs index 320869e13c..cc2c2889a4 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/reraise_no_cause.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/reraise_no_cause.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use crate::Violation; +use crate::codes::Category; /// ## Removed /// This rule is identical to [B904] which should be used instead. @@ -37,7 +38,7 @@ use crate::Violation; /// /// [B904]: https://docs.astral.sh/ruff/rules/raise-without-from-inside-except/ #[derive(ViolationMetadata)] -#[violation_metadata(removed_since = "v0.2.0")] +#[violation_metadata(removed_since = "v0.2.0", category = Category::Pedantic)] pub(crate) struct ReraiseNoCause; /// TRY200 diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/try_consider_else.rs b/crates/ruff_linter/src/rules/tryceratops/rules/try_consider_else.rs index 317777e9fb..192dbbf161 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/try_consider_else.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/try_consider_else.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for `return` statements in `try` blocks. @@ -51,7 +52,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: Errors and Exceptions](https://docs.python.org/3/tutorial/errors.html) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.229")] +#[violation_metadata(stable_since = "v0.0.229", category = Category::Pedantic)] pub(crate) struct TryConsiderElse; impl Violation for TryConsiderElse { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/type_check_without_type_error.rs b/crates/ruff_linter/src/rules/tryceratops/rules/type_check_without_type_error.rs index ee8b06cd90..1dff4d4d0e 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/type_check_without_type_error.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/type_check_without_type_error.rs @@ -7,6 +7,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for type checks that do not raise `TypeError`. @@ -36,7 +37,7 @@ use crate::checkers::ast::Checker; /// ## References /// - [Python documentation: `TypeError`](https://docs.python.org/3/library/exceptions.html#TypeError) #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.230")] +#[violation_metadata(stable_since = "v0.0.230", category = Category::Style)] pub(crate) struct TypeCheckWithoutTypeError; impl Violation for TypeCheckWithoutTypeError { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/useless_try_except.rs b/crates/ruff_linter/src/rules/tryceratops/rules/useless_try_except.rs index d9023378ec..b469fd7ab9 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/useless_try_except.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/useless_try_except.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// Checks for immediate uses of `raise` within exception handlers. @@ -29,7 +30,7 @@ use crate::checkers::ast::Checker; /// bar() /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "0.7.0")] +#[violation_metadata(stable_since = "0.7.0", category = Category::Complexity)] pub(crate) struct UselessTryExcept; impl Violation for UselessTryExcept { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_log_message.rs b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_log_message.rs index 56011d3812..16caf0c904 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_log_message.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_log_message.rs @@ -8,6 +8,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::rules::tryceratops::helpers::LoggerCandidateVisitor; /// ## What it does @@ -38,7 +39,7 @@ use crate::rules::tryceratops::helpers::LoggerCandidateVisitor; /// /// - `lint.logger-objects` #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.250")] +#[violation_metadata(stable_since = "v0.0.250", category = Category::Complexity)] pub(crate) struct VerboseLogMessage; impl Violation for VerboseLogMessage { diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs index 033f9a93f9..d02218d854 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs @@ -5,6 +5,7 @@ use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::codes::Category; use crate::{AlwaysFixableViolation, Edit, Fix}; /// ## What it does @@ -36,7 +37,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// This rule's fix is marked as unsafe, as it doesn't properly handle bound /// exceptions that are shadowed between the `except` and `raise` statements. #[derive(ViolationMetadata)] -#[violation_metadata(stable_since = "v0.0.231")] +#[violation_metadata(stable_since = "v0.0.231", category = Category::Complexity)] pub(crate) struct VerboseRaise; impl AlwaysFixableViolation for VerboseRaise { diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 10e0c13b60..0b4fb9f0f1 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -783,7 +783,7 @@ impl LinterSettings { /// binder](ruff_python_ast::is_destructure_binder) is not one: it stands for /// the value a pattern takes apart, the source refers to the pattern's /// captures instead, and no rename could make it read better. - pub fn ignores_unused_binding(&self, name: &str) -> bool { + pub(crate) fn ignores_unused_binding(&self, name: &str) -> bool { self.dummy_variable_rgx.is_match(name) || is_destructure_binder(name) } @@ -950,3 +950,159 @@ impl Display for TargetVersion { } } } + +#[cfg(test)] +mod tests { + use crate::RuleSelector; + use crate::codes::Category; + use crate::registry::RuleSet; + use crate::rule_selector::PreviewOptions; + use crate::settings::types::PreviewMode; + + use super::DEFAULT_SELECTORS; + + #[test] + fn preview_default_rules() { + let stable_defaults = DEFAULT_SELECTORS + .iter() + .flat_map(|selector| selector.rules(&PreviewOptions::default())) + .collect::(); + + let preview_options = PreviewOptions { + mode: PreviewMode::Enabled, + require_explicit: false, + }; + let preview_defaults = Category::default_categories() + .map(RuleSelector::Category) + .iter() + .flat_map(|selector| selector.rules(&preview_options)) + .collect::(); + + let added = preview_defaults.clone().subtract(&stable_defaults); + let removed = stable_defaults.subtract(&preview_defaults); + + let snapshot = format!("Added in preview:\n{added}\n\nRemoved in preview:\n{removed}"); + + insta::assert_snapshot!(snapshot, @" + Added in preview: + [ + airflow-variable-name-task-id-mismatch (AIR001), + airflow-dag-no-schedule-argument (AIR002), + airflow-variable-get-outside-task (AIR003), + airflow-task-branch-as-short-circuit (AIR004), + airflow-xcom-pull-in-template-string (AIR201), + airflow-task-implicit-multiple-outputs (AIR202), + airflow3-dag-dynamic-value (AIR304), + manual-none-coalesce (BY001), + manual-optional-chain (BY002), + manual-isinstance (BY003), + manual-super-call (BY004), + manual-any-annotation (BY007), + manual-unpack-annotation (BY009), + manual-typeof-annotation (BY010), + manual-re-export (BY011), + redundant-typing-import (BY012), + unnecessary-stub-body (BY017), + manual-sentinel (BY019), + manual-cast-call (BY020), + manual-property (BY021), + manual-modifier (BY022), + redundant-none-coalesce (BY101), + fast-api-redundant-response-model (FAST001), + fast-api-non-annotated-dependency (FAST002), + fast-api-unused-path-parameter (FAST003), + blocking-http-call-httpx-in-async-function (ASYNC212), + blocking-path-method-in-async-function (ASYNC240), + blocking-input-in-async-function (ASYNC250), + abstract-base-class-without-abstract-method (B024), + empty-method-without-abstract-decorator (B027), + del-attr-with-constant (B043), + return-in-generator (B901), + loop-iterator-mutation (B909), + trailing-comma-on-bare-tuple (COM818), + unnecessary-dict-comprehension-for-iterable (C420), + django-model-without-dunder-str (DJ008), + django-unordered-body-content-in-model (DJ012), + django-non-leading-receiver-decorator (DJ013), + pytest-patch-with-lambda (PT008), + pytest-raises-with-multiple-statements (PT012), + pytest-unnecessary-asyncio-mark-on-fixture (PT024), + pytest-parameter-with-default-argument (PT028), + reimplemented-builtin (SIM110), + lazy-import-immediately-resolved (TID255), + numpy-deprecated-type-alias (NPY001), + numpy-legacy-random (NPY002), + numpy-deprecated-function (NPY003), + numpy2-deprecation (NPY201), + pandas-use-of-dot-is-null (PD003), + pandas-use-of-dot-not-null (PD004), + pandas-use-of-dot-read-table (PD012), + pandas-use-of-pd-merge (PD015), + escape-sequence-in-docstring (D301), + undefined-local-with-nested-import-star-usage (F406), + missing-maxsplit-arg (PLC0207), + unnecessary-dunder-call (PLC2801), + duplicate-bases (PLE0241), + dict-iter-missing-items (PLE1141), + modified-iterating-set (PLE4703), + no-classmethod-decorator (PLR0202), + no-staticmethod-decorator (PLR0203), + swap-with-temporary-variable (PLR1712), + unnecessary-lambda (PLW0108), + redefined-slots-in-subclass (PLW0244), + replace-str-enum (UP042), + while-one (UP048), + deprecated-abc-decorator (UP051), + if-exp-instead-of-or-operator (FURB110), + repeated-append (FURB113), + delete-full-slice (FURB131), + for-loop-set-mutations (FURB142), + slice-copy (FURB145), + unnecessary-enumerate (FURB148), + math-constant (FURB152), + hardcoded-string-charset (FURB156), + single-item-membership-test (FURB171), + meta-class-abc-meta (FURB180), + subclass-builtin (FURB189), + missing-f-string-syntax (RUF027), + none-not-at-end-of-union (RUF036), + unnecessary-empty-iterable-within-deque-call (RUF037), + redundant-bool-literal (RUF038), + pytest-raises-ambiguous-pattern (RUF043), + implicit-class-var-in-dataclass (RUF045), + needless-else (RUF047), + unnecessary-if (RUF050), + indented-form-feed (RUF054), + unnecessary-regular-expression (RUF055), + falsy-dict-get-fallback (RUF056), + in-empty-collection (RUF060), + legacy-form-pytest-raises (RUF061), + non-octal-permissions (RUF064), + logging-eager-conversion (RUF065), + property-without-return (RUF066), + float-equality-comparison (RUF069), + os-path-commonprefix (RUF071), + useless-finally (RUF072), + f-string-percent-format (RUF073), + incorrect-decorator-order (RUF074), + fallible-context-manager (RUF075), + invalid-rule-code (RUF102), + invalid-suppression-comment (RUF103), + unmatched-suppression-comment (RUF104), + rule-codes-in-suppression-comments (RUF106), + ] + + Removed in preview: + [ + exec-builtin (S102), + call-datetime-without-tzinfo (DTZ001), + call-datetime-now-without-tzinfo (DTZ005), + call-datetime-fromtimestamp (DTZ006), + call-datetime-strptime-without-zone (DTZ007), + call-date-today (DTZ011), + call-date-fromtimestamp (DTZ012), + datetime-min-max (DTZ901), + ] + "); + } +} diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index 8fd9a70d4e..c7e6d37eb7 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -4,9 +4,10 @@ use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::string::ToString; +use std::sync::{Arc, OnceLock}; use anyhow::{Context, Result, bail}; -use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; +use globset::{Candidate, Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use log::debug; use pep440_rs::{VersionSpecifier, VersionSpecifiers}; use ruff_db::diagnostic::DiagnosticFormat; @@ -318,14 +319,23 @@ impl CacheKey for FilePatternSet { /// A glob pattern and associated data for matching file paths. #[derive(Debug, Clone)] pub struct PerFile { + pattern: Arc, + /// The per-file data associated with these glob patterns. + data: T, +} + +/// A pattern shared by configurations that inherit the same per-file setting. +#[derive(Debug)] +struct PerFilePattern { /// The glob pattern used to construct the [`PerFile`]. basename: String, /// The same pattern as `basename` but normalized to the project root directory. absolute: GlobPath, /// Whether the glob pattern should be negated (e.g. `!*.ipynb`) negated: bool, - /// The per-file data associated with these glob patterns. - data: T, + // Compile only when settings are resolved, after configuration overrides have been applied. + absolute_matcher: OnceLock>, + basename_matcher: OnceLock>, } impl PerFile { @@ -342,9 +352,13 @@ impl PerFile { let project_root = project_root.unwrap_or(fs::get_cwd()); Self { - absolute: GlobPath::normalize(&pattern, project_root), - basename: pattern, - negated, + pattern: Arc::new(PerFilePattern { + absolute: GlobPath::normalize(&pattern, project_root), + basename: pattern, + negated, + absolute_matcher: OnceLock::new(), + basename_matcher: OnceLock::new(), + }), data, } } @@ -812,20 +826,30 @@ impl CompiledPerFileList { let inner: Result> = per_file_items .into_iter() .map(|per_file_ignore| { + let pattern = &per_file_ignore.pattern; + // Cloning matchers shares the compiled regex but gives each settings object + // its own scratch space for matching. // Construct absolute path matcher. - let absolute_matcher = Glob::new(&per_file_ignore.absolute.to_string_lossy()) - .with_context(|| format!("invalid glob {:?}", per_file_ignore.absolute))? - .compile_matcher(); + let absolute_matcher = pattern + .absolute_matcher + .get_or_init(|| { + Glob::new(&pattern.absolute.to_string_lossy()) + .map(|glob| glob.compile_matcher()) + }) + .clone() + .with_context(|| format!("invalid glob {:?}", pattern.absolute))?; // Construct basename matcher. - let basename_matcher = Glob::new(&per_file_ignore.basename) - .with_context(|| format!("invalid glob {:?}", per_file_ignore.basename))? - .compile_matcher(); + let basename_matcher = pattern + .basename_matcher + .get_or_init(|| Glob::new(&pattern.basename).map(|glob| glob.compile_matcher())) + .clone() + .with_context(|| format!("invalid glob {:?}", pattern.basename))?; Ok(CompiledPerFile::new( absolute_matcher, basename_matcher, - per_file_ignore.negated, + pattern.negated, per_file_ignore.data, )) }) @@ -851,8 +875,10 @@ impl CompiledPerFileList { 'a: 'p, { let file_name = path.file_name().expect("Unable to parse filename"); + let basename = Candidate::new(file_name); + let absolute = Candidate::new(path); self.inner.iter().filter_map(move |entry| { - if entry.basename_matcher.is_match(file_name) { + if entry.basename_matcher.is_match_candidate(&basename) { if entry.negated { None } else { @@ -865,7 +891,7 @@ impl CompiledPerFileList { ); Some(&entry.data) } - } else if entry.absolute_matcher.is_match(path) { + } else if entry.absolute_matcher.is_match_candidate(&absolute) { if entry.negated { None } else { @@ -924,9 +950,7 @@ impl CompiledPerFileIgnoreList { let mut resolution_error = None; let list = CompiledPerFileList::resolve(per_file_ignores.into_iter().map(|ignore| { let PerFile { - basename, - absolute, - negated, + pattern, data: selectors, } = ignore.0; // Rules in preview are included here via `all_rules` even if preview mode is disabled. @@ -948,12 +972,7 @@ impl CompiledPerFileIgnoreList { }) .flat_map(|selector| selector.all_rules()) .collect(); - PerFile { - basename, - absolute, - negated, - data, - } + PerFile { pattern, data } }))?; if let Some(error) = resolution_error { @@ -1004,6 +1023,10 @@ impl CompiledPerFileTargetVersionList { } pub fn is_match(&self, path: &Path) -> Option { + if self.0.is_empty() { + return None; + } + self.0 .iter_matches(path, "Setting Python version") .next() diff --git a/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__semantic_syntax_error_nonlocal_parameter.py_3.10.snap b/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__semantic_syntax_error_nonlocal_parameter.py_3.10.snap new file mode 100644 index 0000000000..0331d685a0 --- /dev/null +++ b/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__semantic_syntax_error_nonlocal_parameter.py_3.10.snap @@ -0,0 +1,56 @@ +--- +source: crates/ruff_linter/src/linter.rs +--- +invalid-syntax: name `a` cannot refer to a parameter and a nonlocal variable + --> resources/test/fixtures/semantic_errors/nonlocal_parameter.py:2:14 + | +1 | def f(a): +2 | nonlocal a + | ^ +3 | +4 | def g(a): + | + +invalid-syntax: name `a` cannot refer to a parameter and a nonlocal variable + --> resources/test/fixtures/semantic_errors/nonlocal_parameter.py:6:18 + | +4 | def g(a): +5 | if True: +6 | nonlocal a + | ^ +7 | +8 | def h(a): + | + +invalid-syntax: name `a` cannot refer to a parameter and a nonlocal variable + --> resources/test/fixtures/semantic_errors/nonlocal_parameter.py:14:18 + | +12 | def i(a): +13 | try: +14 | nonlocal a + | ^ +15 | except Exception: +16 | pass + | + +invalid-syntax: name `a` cannot refer to a parameter and a nonlocal variable + --> resources/test/fixtures/semantic_errors/nonlocal_parameter.py:21:14 + | +19 | a = 1 +20 | a = 2 +21 | nonlocal a + | ^ +22 | +23 | def f(a): + | + +invalid-syntax: name `a` cannot refer to a parameter and a nonlocal variable + --> resources/test/fixtures/semantic_errors/nonlocal_parameter.py:29:18 + | +27 | def f(a): +28 | def inner(a): +29 | nonlocal a + | ^ +30 | +31 | def f(a=1): + | diff --git a/crates/ruff_linter/src/suppression.rs b/crates/ruff_linter/src/suppression.rs index 6c5853d5e2..ddfb0c1a5e 100644 --- a/crates/ruff_linter/src/suppression.rs +++ b/crates/ruff_linter/src/suppression.rs @@ -241,9 +241,6 @@ struct SuppressionDiagnostic<'a> { disabled_codes: Vec<&'a str>, unused_codes: Vec<&'a str>, - /// Whether one of the invalid codes was totally unknown and may be external. - has_unknown_code: bool, - /// Whether one of the invalid codes was a rule name with preview disabled. has_stable_rule_name: bool, } @@ -256,7 +253,6 @@ impl<'a> SuppressionDiagnostic<'a> { duplicated_codes: Vec::new(), disabled_codes: Vec::new(), unused_codes: Vec::new(), - has_unknown_code: false, has_stable_rule_name: false, } } @@ -361,7 +357,12 @@ impl Suppressions { range: TextRange, parent: Option, ) -> bool { - self.check_suppression(Some(&rule.noqa_code()), rule.name().as_str(), range, parent) + self.check_suppression( + rule.noqa_code().as_ref(), + rule.name().as_str(), + range, + parent, + ) } /// Check whether the given rule code or name corresponds to a valid suppression comment at @@ -467,11 +468,6 @@ impl Suppressions { whole_comment: group.suppression.codes().len() == group.invalid_codes.len(), }, ) { - if group.has_unknown_code { - diagnostic.help( - "Add non-Ruff rule codes to the `lint.external` configuration option", - ); - } if group.has_stable_rule_name { diagnostic.help("Enable `lint.preview` to use rule names"); } @@ -524,7 +520,6 @@ impl Suppressions { let (_key, group) = grouped_diagnostic .get_or_insert_with(|| (key, SuppressionDiagnostic::new(suppression))); group.invalid_codes.push(code_str); - group.has_unknown_code |= !name_is_known; group.has_stable_rule_name |= name_is_known; } else if !suppression.used.get() { // UnusedNOQA diff --git a/crates/ruff_linter/src/test.rs b/crates/ruff_linter/src/test.rs index 21426c79ac..eb9437e70c 100644 --- a/crates/ruff_linter/src/test.rs +++ b/crates/ruff_linter/src/test.rs @@ -36,7 +36,7 @@ use crate::{Applicability, FixAvailability}; use crate::{Locator, directives}; /// Represents the difference between two diagnostic runs. -#[cfg(any(test, fuzzing))] +#[cfg(test)] #[derive(Debug)] pub(crate) struct DiagnosticsDiff { /// Diagnostics that were removed (present in 'before' but not in 'after') @@ -49,7 +49,7 @@ pub(crate) struct DiagnosticsDiff { settings_after: LinterSettings, } -#[cfg(any(test, fuzzing))] +#[cfg(test)] impl std::fmt::Display for DiagnosticsDiff { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { writeln!(f, "--- Linter settings ---")?; @@ -91,7 +91,7 @@ impl std::fmt::Display for DiagnosticsDiff { } /// Compare two sets of diagnostics and return the differences -#[cfg(any(test, fuzzing))] +#[cfg(test)] fn diff_diagnostics( before: Vec, after: Vec, @@ -379,9 +379,12 @@ Source with applied fixes: let messages = messages .into_iter() - .filter_map(|msg| Some((msg.secondary_code()?.to_string(), msg))) - .map(|(code, mut diagnostic)| { - let rule = Rule::from_code(&code).unwrap(); + .filter_map(|diagnostic| { + Rule::from_name(diagnostic.name()) + .ok() + .map(|rule| (rule, diagnostic)) + }) + .map(|(rule, mut diagnostic)| { let fixable = diagnostic.fix().is_some_and(|fix| { matches!( fix.applicability(), diff --git a/crates/ruff_linter/src/toml.rs b/crates/ruff_linter/src/toml.rs index 4a0fa817ee..8e5c5bb0cb 100644 --- a/crates/ruff_linter/src/toml.rs +++ b/crates/ruff_linter/src/toml.rs @@ -77,8 +77,8 @@ pub fn lint_fix_toml<'a>( }; } - for (rule, name, count) in fixes.iter() { - *fixed.entry(rule).or_default(name) += count; + for (id, code, count) in fixes.iter() { + *fixed.entry(id).or_default(code) += count; } transformed = Cow::Owned(code); diff --git a/crates/ruff_linter/src/violation.rs b/crates/ruff_linter/src/violation.rs index dc77c99bdd..77bed26b99 100644 --- a/crates/ruff_linter/src/violation.rs +++ b/crates/ruff_linter/src/violation.rs @@ -7,7 +7,7 @@ use ruff_source_file::SourceFile; use ruff_text_size::TextRange; use crate::{ - codes::{Rule, RuleGroup}, + codes::{Category, Rule, RuleStatus}, message::create_lint_diagnostic, }; @@ -36,8 +36,11 @@ pub trait ViolationMetadata { /// why it's bad, and what users should do instead. fn explain() -> Option<&'static str>; - /// Returns the rule group for this violation. - fn group() -> RuleGroup; + /// Returns the rule status for this violation. + fn status() -> RuleStatus; + + /// Returns the category for this violation. + fn category() -> Category; /// Returns the file where the violation is declared. fn file() -> &'static str; diff --git a/crates/ruff_macros/Cargo.toml b/crates/ruff_macros/Cargo.toml index 13d495f8bb..4b58ff23ef 100644 --- a/crates/ruff_macros/Cargo.toml +++ b/crates/ruff_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_macros" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_macros/README.md b/crates/ruff_macros/README.md index ac925be274..586a9d41ff 100644 --- a/crates/ruff_macros/README.md +++ b/crates/ruff_macros/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_macros). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_macros). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_macros/src/map_codes.rs b/crates/ruff_macros/src/map_codes.rs index c54bcad9e9..1fc23f5e27 100644 --- a/crates/ruff_macros/src/map_codes.rs +++ b/crates/ruff_macros/src/map_codes.rs @@ -14,15 +14,13 @@ use crate::{ }; /// A rule entry in the big match statement such a -/// `(Pycodestyle, "E112") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::NoIndentedBlock),` +/// `(Pycodestyle, "E112") => rules::pycodestyle::rules::logical_lines::NoIndentedBlock,` #[derive(Clone)] struct Rule { /// The actual name of the rule, e.g., `NoIndentedBlock`. name: Ident, - /// The linter associated with the rule, e.g., `Pycodestyle`. - linter: Ident, - /// The code associated with the rule, e.g., `"E112"`. - code: LitStr, + /// The linter and code associated with the rule, if any. + code: Option, /// The path to the struct implementing the rule, e.g. /// `rules::pycodestyle::rules::logical_lines::NoIndentedBlock` path: Path, @@ -30,6 +28,14 @@ struct Rule { attrs: Vec, } +#[derive(Clone)] +struct LinterCode { + /// The linter associated with the rule, e.g., `Pycodestyle`. + linter: Ident, + /// The code associated with the rule, e.g., `"E112"`. + code: LitStr, +} + pub(crate) fn map_codes(func: &ItemFn) -> syn::Result { let Some(last_stmt) = func.block.stmts.last() else { return Err(Error::new( @@ -58,26 +64,38 @@ pub(crate) fn map_codes(func: &ItemFn) -> syn::Result { )); }; - // Map from: linter (e.g., `Flake8Bugbear`) to rule code (e.g.,`"002"`) to rule data (e.g., - // `(Rule::UnaryPrefixIncrement, RuleGroup::Stable, vec![])`). - let mut linter_to_rules: BTreeMap> = BTreeMap::new(); - + let mut rules = Vec::new(); for arm in arms { if matches!(arm.pat, Pat::Wild(..)) { break; } - let rule = syn::parse::(arm.into_token_stream().into())?; - linter_to_rules - .entry(rule.linter.clone()) - .or_default() - .insert(rule.code.value(), rule); + rules.push(syn::parse::(arm.into_token_stream().into())?); + } + + rules.sort_by_cached_key(|rule| { + ( + rule.code.is_none(), + rule.code + .as_ref() + .map(|code| (code.linter.clone(), code.code.value())), + ) + }); + + // Map from: linter (e.g., `Flake8Bugbear`) to rule code (e.g.,`"002"`) to rule data. + let mut linter_to_rules: BTreeMap> = BTreeMap::new(); + for rule in &rules { + if let Some(LinterCode { linter, code }) = &rule.code { + linter_to_rules + .entry(linter.clone()) + .or_default() + .insert(code.value(), rule); + } } let linter_idents: Vec<_> = linter_to_rules.keys().collect(); - let all_rules = linter_to_rules.values().flat_map(BTreeMap::values); - let mut output = register_rules(all_rules); + let mut output = register_rules(rules.iter()); output.extend(quote! { #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -169,13 +187,13 @@ pub(crate) fn map_codes(func: &ItemFn) -> syn::Result { quote!(#(#attrs)*) }; prefix_into_iter_match_arms.extend(quote! { - #attrs #linter::#prefix_ident => vec![#(#rule_paths,)*].into_iter(), + #attrs #linter::#prefix_ident => [#(#rule_paths,)*].iter().copied(), }); } output.extend(quote! { impl #linter { - pub(crate) fn rules(&self) -> ::std::vec::IntoIter { + pub(crate) fn rules(&self) -> ::std::iter::Copied<::std::slice::Iter<'static, Rule>> { match self { #prefix_into_iter_match_arms } } } @@ -191,15 +209,15 @@ pub(crate) fn map_codes(func: &ItemFn) -> syn::Result { }) } - pub(crate) fn rules(&self) -> ::std::vec::IntoIter { + pub(crate) fn rules(&self) -> ::std::iter::Copied<::std::slice::Iter<'static, Rule>> { match self { - #(RuleCodePrefix::#linter_idents(prefix) => prefix.clone().rules(),)* + #(RuleCodePrefix::#linter_idents(prefix) => prefix.rules(),)* } } } }); - let rule_to_code = generate_rule_to_code(&linter_to_rules); + let rule_to_code = generate_rule_to_code(&rules); output.extend(rule_to_code); output.extend(generate_iter_impl(&linter_to_rules, &linter_idents)); @@ -209,7 +227,7 @@ pub(crate) fn map_codes(func: &ItemFn) -> syn::Result { /// Group the rules by their common prefixes. fn rules_by_prefix( - rules: &BTreeMap, + rules: &BTreeMap, ) -> BTreeMap)>> { // TODO(charlie): Why do we do this here _and_ in `rule_code_prefix::expand`? let mut rules_by_prefix = BTreeMap::new(); @@ -238,16 +256,20 @@ fn rules_by_prefix( /// to multiple codes (e.g., if it existed in multiple linters, like Pylint and Flake8, under /// different codes). We haven't actually activated this functionality yet, but some work was /// done to support it, so the logic exists here. -fn generate_rule_to_code(linter_to_rules: &BTreeMap>) -> TokenStream { +fn generate_rule_to_code(rules: &[Rule]) -> TokenStream { let mut rule_to_codes: HashMap<&Path, Vec<&Rule>> = HashMap::new(); let mut linter_code_for_rule_match_arms = quote!(); - for (linter, map) in linter_to_rules { - for (code, rule) in map { - let Rule { - path, attrs, name, .. - } = rule; - rule_to_codes.entry(path).or_default().push(rule); + for rule in rules { + let Rule { + path, + attrs, + name, + code, + } = rule; + rule_to_codes.entry(path).or_default().push(rule); + + if let Some(LinterCode { linter, code }) = code { linter_code_for_rule_match_arms.extend(quote! { #(#attrs)* (Self::#linter, Rule::#name) => Some(#code), }); @@ -280,25 +302,31 @@ See also https://github.com/astral-sh/ruff/issues/2186. rule_name.ident ); - let Rule { - linter, - code, - attrs, - .. - } = codes + let Rule { code, attrs, .. } = codes .iter() - .sorted_by_key(|data| data.linter == "Pylint") + .sorted_by_key(|rule| { + rule.code + .as_ref() + .is_some_and(|code| code.linter == "Pylint") + }) .next() .unwrap(); + let noqa_code = match code { + Some(LinterCode { linter, code }) => { + quote!(Some(NoqaCode(crate::registry::Linter::#linter.common_prefix(), #code))) + } + None => quote!(None), + }; + rule_noqa_code_match_arms.extend(quote! { - #(#attrs)* Rule::#rule_name => NoqaCode(crate::registry::Linter::#linter.common_prefix(), #code), + #(#attrs)* Rule::#rule_name => #noqa_code, }); } let rule_to_code = quote! { impl Rule { - pub fn noqa_code(&self) -> NoqaCode { + pub fn noqa_code(&self) -> Option { use crate::registry::RuleNamespace; match self { @@ -307,19 +335,19 @@ See also https://github.com/astral-sh/ruff/issues/2186. } pub fn is_preview(&self) -> bool { - matches!(self.group(), RuleGroup::Preview { .. }) + matches!(self.status(), RuleStatus::Preview { .. }) } pub(crate) fn is_stable(&self) -> bool { - matches!(self.group(), RuleGroup::Stable { .. }) + matches!(self.status(), RuleStatus::Stable { .. }) } pub fn is_deprecated(&self) -> bool { - matches!(self.group(), RuleGroup::Deprecated { .. }) + matches!(self.status(), RuleStatus::Deprecated { .. }) } pub fn is_removed(&self) -> bool { - matches!(self.group(), RuleGroup::Removed { .. }) + matches!(self.status(), RuleStatus::Removed { .. }) } } @@ -337,7 +365,7 @@ See also https://github.com/astral-sh/ruff/issues/2186. /// Implement `impl IntoIterator for &Linter` and `RuleCodePrefix::iter()` fn generate_iter_impl( - linter_to_rules: &BTreeMap>, + linter_to_rules: &BTreeMap>, linter_idents: &[&Ident], ) -> TokenStream { let mut linter_rules_match_arms = quote!(); @@ -348,7 +376,7 @@ fn generate_iter_impl( quote!(#(#attrs)* Rule::#rule_name) }); linter_rules_match_arms.extend(quote! { - Linter::#linter => vec![#(#rule_paths,)*].into_iter(), + Linter::#linter => [#(#rule_paths,)*].iter().copied(), }); let rule_paths = map.values().map(|Rule { attrs, path, .. }| { let rule_name = path.segments.last().unwrap(); @@ -362,7 +390,7 @@ fn generate_iter_impl( quote! { impl Linter { /// Rules not in the preview. - pub(crate) fn rules(self: &Linter) -> ::std::vec::IntoIter { + pub(crate) fn rules(self: &Linter) -> ::std::iter::Copied<::std::slice::Iter<'static, Rule>> { match self { #linter_rules_match_arms } @@ -394,7 +422,8 @@ fn register_rules<'a>(input: impl Iterator) -> TokenStream { let mut rule_message_formats_match_arms = quote!(); let mut rule_fixable_match_arms = quote!(); let mut rule_explanation_match_arms = quote!(); - let mut rule_group_match_arms = quote!(); + let mut rule_status_match_arms = quote!(); + let mut rule_category_match_arms = quote!(); let mut rule_file_match_arms = quote!(); let mut rule_line_match_arms = quote!(); let mut rule_parse_match_arms = quote!(); @@ -416,8 +445,11 @@ fn register_rules<'a>(input: impl Iterator) -> TokenStream { quote! {#(#attrs)* Self::#name => <#path as crate::Violation>::FIX_AVAILABILITY,}, ); rule_explanation_match_arms.extend(quote! {#(#attrs)* Self::#name => #path::explain(),}); - rule_group_match_arms.extend( - quote! {#(#attrs)* Self::#name => <#path as crate::ViolationMetadata>::group(),}, + rule_status_match_arms.extend( + quote! {#(#attrs)* Self::#name => <#path as crate::ViolationMetadata>::status(),}, + ); + rule_category_match_arms.extend( + quote! {#(#attrs)* Self::#name => <#path as crate::ViolationMetadata>::category(),}, ); rule_file_match_arms.extend( quote! {#(#attrs)* Self::#name => <#path as crate::ViolationMetadata>::file(),}, @@ -462,8 +494,12 @@ fn register_rules<'a>(input: impl Iterator) -> TokenStream { match self { #rule_fixable_match_arms } } - pub fn group(&self) -> crate::codes::RuleGroup { - match self { #rule_group_match_arms } + pub fn status(&self) -> crate::codes::RuleStatus { + match self { #rule_status_match_arms } + } + + pub fn category(&self) -> crate::codes::Category { + match self { #rule_category_match_arms } } pub fn file(&self) -> &'static str { @@ -491,16 +527,20 @@ impl Parse for Rule { let attrs = Attribute::parse_outer(input)?; let pat_tuple; parenthesized!(pat_tuple in input); - let linter: Ident = pat_tuple.parse()?; - let _: Token!(,) = pat_tuple.parse()?; - let code: LitStr = pat_tuple.parse()?; + let code = if pat_tuple.is_empty() { + None + } else { + let linter: Ident = pat_tuple.parse()?; + let _: Token!(,) = pat_tuple.parse()?; + let code: LitStr = pat_tuple.parse()?; + Some(LinterCode { linter, code }) + }; let _: Token!(=>) = input.parse()?; let rule_path: Path = input.parse()?; let _: Token!(,) = input.parse()?; let rule_name = rule_path.segments.last().unwrap().ident.clone(); Ok(Rule { name: rule_name, - linter, code, path: rule_path, attrs, diff --git a/crates/ruff_macros/src/violation_metadata.rs b/crates/ruff_macros/src/violation_metadata.rs index f9633b4ba7..00de9e0cc0 100644 --- a/crates/ruff_macros/src/violation_metadata.rs +++ b/crates/ruff_macros/src/violation_metadata.rs @@ -3,15 +3,24 @@ use std::sync::LazyLock; use proc_macro2::TokenStream; use quote::quote; use regex::Regex; -use syn::{Attribute, DeriveInput, Error, Lit, LitStr, Meta, meta::ParseNestedMeta}; +use syn::{Attribute, DeriveInput, Error, Lit, LitStr, Meta, Path, meta::ParseNestedMeta}; pub(crate) fn violation_metadata(input: DeriveInput) -> syn::Result { let docs = get_docs(&input.attrs)?; - let Some(group) = get_rule_status(&input.attrs)? else { + let metadata = get_metadata(&input.attrs)?; + + let Some(status) = metadata.status else { + return Err(Error::new_spanned( + &input, + "Missing required rule status metadata", + )); + }; + + let Some(category) = metadata.category else { return Err(Error::new_spanned( - input, - "Missing required rule group metadata", + &input, + "Missing required rule category metadata", )); }; @@ -31,8 +40,12 @@ pub(crate) fn violation_metadata(input: DeriveInput) -> syn::Result Some(#docs) } - fn group() -> crate::codes::RuleGroup { - crate::codes::#group + fn status() -> crate::codes::RuleStatus { + crate::codes::#status + } + + fn category() -> crate::codes::Category { + #category } fn file() -> &'static str { @@ -65,37 +78,40 @@ fn get_docs(attrs: &[Attribute]) -> syn::Result { Ok(explanation) } -/// Extract the rule status attribute. +/// Extract the rule metadata attributes. /// /// These attributes look like: /// /// ```ignore -/// #[violation_metadata(stable_since = "1.2.3")] +/// #[violation_metadata(stable_since = "1.2.3", category = Category::Correctness)] /// struct MyRule; /// ``` /// -/// The result is returned as a `TokenStream` so that the version string literal can be combined -/// with the proper `RuleGroup` variant, e.g. `RuleGroup::Stable` for `stable_since` above. -fn get_rule_status(attrs: &[Attribute]) -> syn::Result> { - let mut group = None; +/// The rule status is stored as a `TokenStream` so that the version string literal can be combined +/// with the proper `RuleStatus` variant, e.g. `RuleStatus::Stable` for `stable_since` above. +fn get_metadata(attrs: &[Attribute]) -> syn::Result { + let mut metadata = Metadata::default(); for attr in attrs { if attr.path().is_ident("violation_metadata") { attr.parse_nested_meta(|meta| { if meta.path.is_ident("stable_since") { let lit: LitStr = parse_version(&meta)?; - group = Some(quote!(RuleGroup::Stable { since: #lit })); + metadata.status = Some(quote!(RuleStatus::Stable { since: #lit })); return Ok(()); } else if meta.path.is_ident("preview_since") { let lit: LitStr = parse_version(&meta)?; - group = Some(quote!(RuleGroup::Preview { since: #lit })); + metadata.status = Some(quote!(RuleStatus::Preview { since: #lit })); return Ok(()); } else if meta.path.is_ident("deprecated_since") { let lit: LitStr = parse_version(&meta)?; - group = Some(quote!(RuleGroup::Deprecated { since: #lit })); + metadata.status = Some(quote!(RuleStatus::Deprecated { since: #lit })); return Ok(()); } else if meta.path.is_ident("removed_since") { let lit: LitStr = parse_version(&meta)?; - group = Some(quote!(RuleGroup::Removed { since: #lit })); + metadata.status = Some(quote!(RuleStatus::Removed { since: #lit })); + return Ok(()); + } else if meta.path.is_ident("category") { + metadata.category = Some(meta.value()?.parse()?); return Ok(()); } Err(Error::new_spanned( @@ -105,7 +121,13 @@ fn get_rule_status(attrs: &[Attribute]) -> syn::Result> { })?; } } - Ok(group) + Ok(metadata) +} + +#[derive(Default)] +struct Metadata { + status: Option, + category: Option, } fn parse_attr<'a, const LEN: usize>( diff --git a/crates/ruff_markdown/Cargo.toml b/crates/ruff_markdown/Cargo.toml index ec98be0bc9..f315b48c79 100644 --- a/crates/ruff_markdown/Cargo.toml +++ b/crates/ruff_markdown/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_markdown" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ruff_markdown/README.md b/crates/ruff_markdown/README.md index 9320691af7..f6e83666c2 100644 --- a/crates/ruff_markdown/README.md +++ b/crates/ruff_markdown/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_markdown). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_markdown). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_mdtest/src/lib.rs b/crates/ruff_mdtest/src/lib.rs index ecd94bfbe3..4bfeb3e800 100644 --- a/crates/ruff_mdtest/src/lib.rs +++ b/crates/ruff_mdtest/src/lib.rs @@ -1,3 +1,4 @@ +use std::assert_matches; use std::sync::Arc; use anyhow::anyhow; @@ -69,8 +70,9 @@ fn run_test( return None; } - assert!( - matches!(embedded.lang, "py" | "pyi" | "python" | "ipynb" | "toml"), + assert_matches!( + embedded.lang, + "py" | "pyi" | "python" | "ipynb" | "toml", "Supported file types are: py (or python), pyi, ipynb, toml, and ignore" ); @@ -173,6 +175,7 @@ fn run_test( test_file, &inline_diagnostics, &mut markdown_edits, + str::to_owned, ) }) { Ok(()) => None, diff --git a/crates/ruff_memory_usage/Cargo.toml b/crates/ruff_memory_usage/Cargo.toml index 852b0e46b5..8c0179ffa7 100644 --- a/crates/ruff_memory_usage/Cargo.toml +++ b/crates/ruff_memory_usage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_memory_usage" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_memory_usage/README.md b/crates/ruff_memory_usage/README.md index bdb69d27c9..8db66aeefc 100644 --- a/crates/ruff_memory_usage/README.md +++ b/crates/ruff_memory_usage/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_memory_usage). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_memory_usage). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_notebook/Cargo.toml b/crates/ruff_notebook/Cargo.toml index 477f3fa14c..bf8e566599 100644 --- a/crates/ruff_notebook/Cargo.toml +++ b/crates/ruff_notebook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_notebook" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_notebook/README.md b/crates/ruff_notebook/README.md index bcf6f56751..aded0d6dbe 100644 --- a/crates/ruff_notebook/README.md +++ b/crates/ruff_notebook/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_notebook). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_notebook). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_notebook/src/notebook.rs b/crates/ruff_notebook/src/notebook.rs index 37d32214a4..c67e2377df 100644 --- a/crates/ruff_notebook/src/notebook.rs +++ b/crates/ruff_notebook/src/notebook.rs @@ -482,6 +482,7 @@ impl Eq for Notebook {} #[cfg(test)] mod tests { + use std::assert_matches; use std::path::Path; use anyhow::Result; @@ -508,18 +509,18 @@ mod tests { #[test] fn test_invalid() { - assert!(matches!( + assert_matches!( Notebook::from_path(¬ebook_path("invalid_extension.ipynb")), Err(NotebookError::InvalidJson(_)) - )); - assert!(matches!( + ); + assert_matches!( Notebook::from_path(¬ebook_path("not_json.ipynb")), Err(NotebookError::InvalidJson(_)) - )); - assert!(matches!( + ); + assert_matches!( Notebook::from_path(¬ebook_path("wrong_schema.ipynb")), Err(NotebookError::InvalidSchema(_)) - )); + ); } #[test] diff --git a/crates/ruff_options_metadata/Cargo.toml b/crates/ruff_options_metadata/Cargo.toml index 203bf5f5ce..18da71e3b6 100644 --- a/crates/ruff_options_metadata/Cargo.toml +++ b/crates/ruff_options_metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_options_metadata" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_options_metadata/README.md b/crates/ruff_options_metadata/README.md index 6ae0120c4a..73b80b682b 100644 --- a/crates/ruff_options_metadata/README.md +++ b/crates/ruff_options_metadata/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_options_metadata). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_options_metadata). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_options_metadata/src/lib.rs b/crates/ruff_options_metadata/src/lib.rs index 01d61e99c4..1a2f48534c 100644 --- a/crates/ruff_options_metadata/src/lib.rs +++ b/crates/ruff_options_metadata/src/lib.rs @@ -207,6 +207,7 @@ impl OptionSet { /// ### Find a nested option /// /// ```rust + /// # use std::assert_matches; /// # use ruff_options_metadata::{OptionEntry, OptionField, OptionsMetadata, Visit}; /// /// static HARD_TABS: OptionField = OptionField { @@ -244,7 +245,7 @@ impl OptionSet { /// } /// /// assert_eq!(Root::metadata().find("format.hard-tabs").and_then(OptionEntry::into_field), Some(HARD_TABS.clone())); - /// assert!(matches!(Root::metadata().find("format"), Some(OptionEntry::Set(_)))); + /// assert_matches!(Root::metadata().find("format"), Some(OptionEntry::Set(_))); /// assert!(Root::metadata().find("format.spaces").is_none()); /// assert!(Root::metadata().find("lint.hard-tabs").is_none()); /// ``` diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index f3f65747e1..f0379e42f4 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_ast" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_ast/README.md b/crates/ruff_python_ast/README.md index 77078685db..5fe952a7e8 100644 --- a/crates/ruff_python_ast/README.md +++ b/crates/ruff_python_ast/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_ast). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_ast). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_ast/generate.py b/crates/ruff_python_ast/generate.py old mode 100644 new mode 100755 index 4d7e75efbc..9c99d94c54 --- a/crates/ruff_python_ast/generate.py +++ b/crates/ruff_python_ast/generate.py @@ -2,6 +2,9 @@ # /// script # requires-python = ">=3.11" # dependencies = [] +# +# [tool.uv] +# exclude-newer = "P7D" # /// from __future__ import annotations @@ -71,7 +74,7 @@ def rustfmt(code: str) -> str: def to_snake_case(node: str) -> str: """Converts CamelCase to snake_case""" - return re.sub("([A-Z])", r"_\1", node).lower().lstrip("_") + return re.sub(r"([A-Z])", r"_\1", node).lower().lstrip("_") def write_rustdoc(out: list[str], doc: str) -> None: @@ -183,7 +186,6 @@ def fields_in_source_order(self) -> list[Field]: if field.skip_source_order(): continue if field.name == field_name: - field = field break fields.append(field) return fields diff --git a/crates/ruff_python_ast/generate.py.lock b/crates/ruff_python_ast/generate.py.lock new file mode 100644 index 0000000000..35fa400167 --- /dev/null +++ b/crates/ruff_python_ast/generate.py.lock @@ -0,0 +1,7 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index a2b0c91e65..9733d556bc 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -9,8 +9,7 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::name::{Name, QualifiedName, QualifiedNameBuilder}; use crate::statement_visitor::StatementVisitor; -use crate::token::Tokens; -use crate::token::parenthesized_range; +use crate::token::{Tokens, parenthesized_range}; use crate::visitor::Visitor; use crate::{ self as ast, Arguments, AtomicNodeIndex, CmpOp, DictItem, ExceptHandler, Expr, ExprNoneLiteral, @@ -290,7 +289,16 @@ where any_over_expr(left, &mut *func) || any_over_expr(right, &mut *func) } Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => any_over_expr(operand, func), - Expr::Lambda(ast::ExprLambda { body, .. }) => any_over_expr(body, func), + Expr::Lambda(ast::ExprLambda { + body, parameters, .. + }) => { + parameters + .iter() + .flat_map(|parameters| parameters.iter_non_variadic_params()) + .filter_map(|parameter| parameter.default.as_deref()) + .any(|default| any_over_expr(default, &mut *func)) + || any_over_expr(body, func) + } Expr::If(ast::ExprIf { test, body, @@ -3005,7 +3013,7 @@ pub const INVALID_MODIFIER_MARKER: &str = "invalid_modifier"; /// Spelled the same way as [`TYPE_FN_MARKER`] — a zero-binding `Name` with /// [`crate::ExprContext::Invalid`] — so that every consumer agrees on it rather /// than matching the string itself. -pub const ENUM_DEF_MARKER: &str = "enum_def"; +const ENUM_DEF_MARKER: &str = "enum_def"; /// Whether `class` came from basedpython's `enum class`, whose `case` members /// are its variants. diff --git a/crates/ruff_python_ast/src/operator_precedence.rs b/crates/ruff_python_ast/src/operator_precedence.rs index f981499f2a..1e9ed719a6 100644 --- a/crates/ruff_python_ast/src/operator_precedence.rs +++ b/crates/ruff_python_ast/src/operator_precedence.rs @@ -54,7 +54,7 @@ pub enum OperatorPrecedence { } impl OperatorPrecedence { - pub fn from_expr_ref(expr: ExprRef) -> Self { + fn from_expr_ref(expr: ExprRef) -> Self { match expr { // Binding or parenthesized expression, list display, dictionary display, set display ExprRef::Tuple(_) diff --git a/crates/ruff_python_ast/src/script.rs b/crates/ruff_python_ast/src/script.rs index 00c0fc7c30..3337fb4422 100644 --- a/crates/ruff_python_ast/src/script.rs +++ b/crates/ruff_python_ast/src/script.rs @@ -1,6 +1,8 @@ use std::sync::LazyLock; use memchr::memmem::Finder; +use ruff_source_file::UniversalNewlineIterator; +use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")); @@ -11,12 +13,12 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) /// Vendored from: #[derive(Debug, Clone, Eq, PartialEq)] pub struct ScriptTag { - /// The content of the script before the metadata block. - prelude: String, /// The metadata block. metadata: String, - /// The content of the script after the metadata block. - postlude: String, + /// The source range of the metadata block, including its opening and closing delimiters. + range: TextRange, + /// Maps offsets in the extracted metadata to offsets in the original Python script. + source_map: ScriptSourceMap, } impl ScriptTag { @@ -25,9 +27,13 @@ impl ScriptTag { &self.metadata } + /// Returns the map from extracted TOML offsets to their original script offsets. + pub fn source_map(&self) -> &ScriptSourceMap { + &self.source_map + } + /// Given the contents of a Python file, extract the `script` metadata block with leading - /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python - /// script. + /// comment hashes removed and map its offsets to the original Python script. /// /// Given the following input string representing the contents of a Python script: /// @@ -46,11 +52,14 @@ impl ScriptTag { /// print("Hello, World!") /// ``` /// - /// This function would return: - /// - /// - Preamble: `#!/usr/bin/env python3\n` - /// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]` - /// - Postlude: `import requests\n\nprint("Hello, World!")\n` + /// This function extracts the metadata: + /// ```toml + /// requires-python = '>=3.11' + /// dependencies = [ + /// 'requests<3', + /// 'rich', + /// ] + /// ``` /// /// See: pub fn parse(contents: &[u8]) -> Option { @@ -65,14 +74,11 @@ impl ScriptTag { return None; } - // Extract the preceding content. - let prelude = std::str::from_utf8(&contents[..index]).ok()?; - - // Decode as UTF-8. - let contents = &contents[index..]; let contents = std::str::from_utf8(contents).ok()?; + let contents = &contents[index..]; - let mut lines = contents.lines(); + let start = TextSize::try_from(index).ok()?; + let mut lines = UniversalNewlineIterator::with_offset(contents, start); // Ensure that the first line is exactly `# /// script`. if lines.next().is_none_or(|line| line != "# /// script") { @@ -84,37 +90,36 @@ impl ScriptTag { // > embedded content is formed by taking away the first two characters of each line if the // > second character is a space, otherwise just the first character (which means the line // > consists of only a single #). - let mut toml = vec![]; + let mut metadata = String::new(); + let mut source_map = ScriptSourceMap::default(); + let mut closing = None; - // Extract the content that follows the metadata block. - let mut python_script = vec![]; - - while let Some(line) = lines.next() { + for line in lines { // Remove the leading `#`. - let Some(line) = line.strip_prefix('#') else { - python_script.push(line); - python_script.extend(lines); + let Some(comment) = line.strip_prefix('#') else { break; }; - // If the line is empty, continue. - if line.is_empty() { - toml.push(""); - continue; - } - - // Otherwise, the line _must_ start with ` `. - let Some(line) = line.strip_prefix(' ') else { - python_script.push(line); - python_script.extend(lines); + let (content, indent_len) = if comment.is_empty() { + ("", TextSize::ZERO) + } else if let Some(content) = comment.strip_prefix(' ') { + (content, ' '.text_len()) + } else { break; }; - toml.push(line); + if content == "///" { + closing = Some((metadata.len(), source_map.markers.len(), line.range())); + } + + let prefix_length = '#'.text_len() + indent_len; + + source_map.push_marker(metadata.text_len(), line.start() + prefix_length); + metadata.push_str(content); + metadata.push('\n'); } - // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such - // line. + // The last closing `# ///` wins, so discard that delimiter and everything after it. // // For example, given: // ```python @@ -126,32 +131,163 @@ impl ScriptTag { // ``` // // The latter `///` is the closing pragma - let index = toml.iter().rev().position(|line| *line == "///")?; - let index = toml.len() - index; + let (metadata_end, marker_count, closing_range) = closing?; + metadata.truncate(metadata_end); + source_map.truncate(marker_count); - // Discard any lines after the closing `# ///`. - // - // For example, given: - // ```python - // # /// script - // # - // # /// - // # - // # - // ``` - // - // We need to discard the last two lines. - toml.truncate(index - 1); - - // Join the lines into a single string. - let prelude = prelude.to_string(); - let metadata = toml.join("\n") + "\n"; - let postlude = python_script.join("\n") + "\n"; + if metadata.is_empty() { + metadata.push('\n'); + } else { + source_map.push_marker(metadata.text_len(), closing_range.start()); + } Some(Self { - prelude, metadata, - postlude, + range: TextRange::new(start, closing_range.end()), + source_map, }) } } + +impl Ranged for ScriptTag { + fn range(&self) -> TextRange { + self.range + } +} + +/// Maps offsets in extracted script metadata to offsets in the original Python source. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ScriptSourceMap { + markers: Vec, +} + +impl ScriptSourceMap { + /// Maps a metadata offset to the corresponding offset in the Python source. + fn map_offset(&self, offset: TextSize) -> TextSize { + let Some(index) = self + .markers + .partition_point(|marker| marker.metadata_offset <= offset) + .checked_sub(1) + else { + return offset; + }; + let marker = &self.markers[index]; + + marker.source_offset + (offset - marker.metadata_offset) + } + + /// Maps a metadata range to its corresponding range in the Python source. + pub fn map_range(&self, range: TextRange) -> TextRange { + TextRange::new(self.map_offset(range.start()), self.map_offset(range.end())) + } + + fn push_marker(&mut self, metadata_offset: TextSize, source_offset: TextSize) { + self.markers.push(ScriptSourceMarker { + metadata_offset, + source_offset, + }); + } + + fn truncate(&mut self, len: usize) { + self.markers.truncate(len); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ScriptSourceMarker { + metadata_offset: TextSize, + source_offset: TextSize, +} + +#[cfg(test)] +mod tests { + use ruff_text_size::{Ranged, TextLen, TextRange}; + + use super::ScriptTag; + + #[test] + fn carriage_return_line_endings() -> Result<(), &'static str> { + let tag = ScriptTag::parse(b"# /// script\r# value = true\r# ///\r") + .ok_or("Expected script metadata with carriage-return line endings")?; + + assert_eq!(tag.metadata(), "value = true\n"); + + Ok(()) + } + + #[test] + fn metadata_block_range_includes_both_delimiters() -> Result<(), &'static str> { + let prefix = "#!/usr/bin/env python3\n\n"; + let metadata = "# /// script\n# dependencies = []\n# ///"; + let source = format!("{prefix}{metadata}\n\nprint('hello')\n"); + let tag = ScriptTag::parse(source.as_bytes()).ok_or("Expected valid script metadata")?; + + assert_eq!( + tag.range(), + TextRange::at(prefix.text_len(), metadata.text_len()) + ); + + Ok(()) + } + + #[test] + fn metadata_range_accounts_for_unicode_crlf_and_multiline_values() -> Result<(), &'static str> { + let metadata_value = r#"""" +first + +last +""""#; + let source_value = r#"""" +# first +# +# last +# """"# + .replace('\n', "\r\n"); + let source = format!("π\r\n# /// script\r\n# value = {source_value}\r\n# ///\r\n"); + let tag = ScriptTag::parse(source.as_bytes()).ok_or("Expected valid script metadata")?; + + assert_eq!(tag.metadata(), format!("value = {metadata_value}\n")); + + let metadata_range = TextRange::at("value = ".text_len(), metadata_value.text_len()); + let source_range = TextRange::at( + "π\r\n# /// script\r\n# value = ".text_len(), + source_value.text_len(), + ); + + assert_eq!(tag.source_map().map_range(metadata_range), source_range); + + Ok(()) + } + + #[test] + fn last_closing_delimiter_discards_following_comments() -> Result<(), &'static str> { + let source = r"# /// script +# first = true +# /// +# last = true +# /// +# ignored = true +"; + let tag = ScriptTag::parse(source.as_bytes()).ok_or("Expected valid script metadata")?; + + assert_eq!( + tag.metadata(), + r"first = true +/// +last = true +" + ); + + let closing_start = source + .rfind("# ///") + .map(|offset| source[..offset].text_len()) + .ok_or("Expected the final closing delimiter")?; + assert_eq!( + tag.source_map().map_offset(tag.metadata().text_len()), + closing_start, + ); + assert_eq!(tag.end(), closing_start + "# ///".text_len()); + + Ok(()) + } +} diff --git a/crates/ruff_python_ast/src/token.rs b/crates/ruff_python_ast/src/token.rs index 34cbfc782e..f2f32afe0a 100644 --- a/crates/ruff_python_ast/src/token.rs +++ b/crates/ruff_python_ast/src/token.rs @@ -340,6 +340,18 @@ impl TokenKind { matches!(self, TokenKind::EndOfFile) } + /// Returns `true` if this is a dot token (`.`). + #[inline] + pub const fn is_dot(self) -> bool { + matches!(self, TokenKind::Dot) + } + + /// Returns `true` if this is a left brace token (`{`). + #[inline] + pub const fn is_lbrace(self) -> bool { + matches!(self, TokenKind::Lbrace) + } + /// Returns `true` if this is either a newline or non-logical newline token. #[inline] pub const fn is_any_newline(self) -> bool { diff --git a/crates/ruff_python_ast/src/token/parentheses.rs b/crates/ruff_python_ast/src/token/parentheses.rs index c1d6f40650..5520037ed7 100644 --- a/crates/ruff_python_ast/src/token/parentheses.rs +++ b/crates/ruff_python_ast/src/token/parentheses.rs @@ -1,4 +1,4 @@ -use ruff_text_size::{Ranged, TextLen, TextRange}; +use ruff_text_size::{Ranged, TextRange}; use super::{TokenKind, Tokens}; use crate::{AnyNodeRef, ExprRef}; @@ -16,16 +16,20 @@ pub fn parentheses_iterator<'a>( tokens: &'a Tokens, ) -> impl Iterator + 'a { let after_tokens = if let Some(parent) = parent { + let after_tokens = tokens.in_range(TextRange::new(expr.end(), parent.end())); + // If the parent is a node that brings its own parentheses, exclude the closing parenthesis // from our search range. Otherwise, we risk matching on calls, like `func(x)`, for which // the open and close parentheses are part of the `Arguments` node. - let exclusive_parent_end = if parent.is_arguments() { - parent.end() - ")".text_len() + // The closing parenthesis may be missing in an incomplete call. + if parent.is_arguments() + && let Some((last, remaining)) = after_tokens.split_last() + && last.kind() == TokenKind::Rpar + { + remaining } else { - parent.end() - }; - - tokens.in_range(TextRange::new(expr.end(), exclusive_parent_end)) + after_tokens + } } else { tokens.after(expr.end()) }; diff --git a/crates/ruff_python_ast/src/token/tokens.rs b/crates/ruff_python_ast/src/token/tokens.rs index 7037ae1e34..e230a0f843 100644 --- a/crates/ruff_python_ast/src/token/tokens.rs +++ b/crates/ruff_python_ast/src/token/tokens.rs @@ -347,10 +347,8 @@ impl From<&Tokens> for TriviaRanges { /// An iterator over the [`Token`]s with context. /// -/// This struct is created by the [`iter_with_context`] method on [`Tokens`]. Refer to its -/// documentation for more details. -/// -/// [`iter_with_context`]: Tokens::iter_with_context +/// Use [`Tokens::iter_with_context`] to iterate over all tokens, or [`Self::new`] to iterate over a +/// token slice. #[derive(Debug, Clone)] pub struct TokenIterWithContext<'a> { inner: std::slice::Iter<'a, Token>, @@ -358,7 +356,8 @@ pub struct TokenIterWithContext<'a> { } impl<'a> TokenIterWithContext<'a> { - fn new(tokens: &'a [Token]) -> TokenIterWithContext<'a> { + /// Creates an iterator with a nesting level of zero at the start of the token slice. + pub fn new(tokens: &'a [Token]) -> TokenIterWithContext<'a> { TokenIterWithContext { inner: tokens.iter(), nesting: 0, diff --git a/crates/ruff_python_ast_integration_tests/tests/parentheses.rs b/crates/ruff_python_ast_integration_tests/tests/parentheses.rs index 479c456c39..90de025f09 100644 --- a/crates/ruff_python_ast_integration_tests/tests/parentheses.rs +++ b/crates/ruff_python_ast_integration_tests/tests/parentheses.rs @@ -1,5 +1,5 @@ -//! Tests for [`ruff_python_ast::tokens::parentheses_iterator`] and -//! [`ruff_python_ast::tokens::parenthesized_range`]. +//! Tests for [`ruff_python_ast::token::parentheses_iterator`] and +//! [`ruff_python_ast::token::parenthesized_range`]. use ruff_python_ast::{ self as ast, Expr, diff --git a/crates/ruff_python_codegen/Cargo.toml b/crates/ruff_python_codegen/Cargo.toml index 15eb55c7fe..6603f7d60e 100644 --- a/crates/ruff_python_codegen/Cargo.toml +++ b/crates/ruff_python_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_codegen" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_codegen/README.md b/crates/ruff_python_codegen/README.md index eb260851fa..115b3818c0 100644 --- a/crates/ruff_python_codegen/README.md +++ b/crates/ruff_python_codegen/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_codegen). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_codegen). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_edits/Cargo.toml b/crates/ruff_python_edits/Cargo.toml new file mode 100644 index 0000000000..046045452f --- /dev/null +++ b/crates/ruff_python_edits/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ruff_python_edits" +version = "0.0.12" +description = "This is an internal component crate of Ruff" +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[dependencies] +ruff_python_ast = { workspace = true } +ruff_source_file = { workspace = true } +ruff_text_size = { workspace = true } + +[dev-dependencies] +ruff_python_parser = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/crates/ruff_python_edits/README.md b/crates/ruff_python_edits/README.md new file mode 100644 index 0000000000..493049c858 --- /dev/null +++ b/crates/ruff_python_edits/README.md @@ -0,0 +1,12 @@ + + +# ruff_python_edits + +This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed +here is unstable and will have frequent breaking changes. + +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_edits). + +See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for +details on versioning. diff --git a/crates/ruff_python_edits/src/call.rs b/crates/ruff_python_edits/src/call.rs new file mode 100644 index 0000000000..ed5f341c13 --- /dev/null +++ b/crates/ruff_python_edits/src/call.rs @@ -0,0 +1,155 @@ +use ruff_python_ast::token::{TokenIterWithContext, Tokens, parenthesized_range}; +use ruff_python_ast::{self as ast, AnyNodeRef, Expr, ExprCall, OperatorPrecedence}; +use ruff_source_file::LineRanges; +use ruff_text_size::Ranged; + +/// Returns source code that replaces a call with one of its arguments. +/// +/// Preserve the argument's optional parentheses, including any comments inside them. Otherwise, add +/// parentheses when needed for grouping, line continuation, or separation from adjacent tokens. +/// Compound expressions can remain unparenthesized in expression statements, assignment or return +/// values, and call arguments. In other contexts, including when `parent` is unknown, group them +/// conservatively. +/// +/// The parent can be the enclosing expression or statement, or an `Arguments` or `Keyword` node. +/// +/// Callers are responsible for determining whether removing the call is valid and whether discarding +/// its other arguments or comments affects the applicability of a fix. +pub fn unwrapped_call_argument( + call: &ExprCall, + argument: &Expr, + parent: Option, + tokens: &Tokens, + source: &str, +) -> String { + if let Some(range) = parenthesized_range(argument.into(), (&call.arguments).into(), tokens) { + return source[range].to_string(); + } + + let argument_source = &source[argument.range()]; + + // The call can separate tokens that would otherwise join after its removal: `return(int)(1)`, + // `int(1)and other`, or `int(1).real`. In an f-string, adjacent opening braces would instead + // escape the interpolation: `f"{cast(dict[str, int], {'a': value})}"`. + let adjacent_before = tokens + .before(call.start()) + .last() + .filter(|token| token.end() == call.start()); + let adjacent_after = tokens + .after(call.end()) + .first() + .filter(|token| token.start() == call.end()); + let needs_boundary_parens = adjacent_before.is_some_and(|token| { + token.kind().is_keyword() || (token.kind().is_lbrace() && argument_source.starts_with('{')) + }) || adjacent_after.is_some_and(|token| { + token.kind().is_keyword() + || (token.kind().is_dot() + && matches!( + argument, + Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(_), + .. + }) + )) + }); + + // These positions accept a complete expression. A callee still needs grouping: + // `consume(f if flag else g)` is valid, but calling the result needs `(f if flag else g)()`. + let parent_allows_unparenthesized = parent.is_some_and(|parent| match parent { + AnyNodeRef::Arguments(_) | AnyNodeRef::Keyword(_) => true, + AnyNodeRef::ExprCall(parent) => parent.arguments.range().contains_range(call.range()), + AnyNodeRef::StmtExpr(ast::StmtExpr { value, .. }) + | AnyNodeRef::StmtAssign(ast::StmtAssign { value, .. }) => value.range() == call.range(), + AnyNodeRef::StmtReturn(ast::StmtReturn { value, .. }) + | AnyNodeRef::StmtAnnAssign(ast::StmtAnnAssign { value, .. }) => value + .as_ref() + .is_some_and(|value| value.range() == call.range()), + _ => false, + }); + + // Newlines inside the argument's own delimiters do not need the outer call's parentheses. + // For example, `f(\n1)` can stand alone, but `math\n.floor(1)` cannot. + let mut needs_line_continuation = false; + if source.contains_line_break(argument.range()) { + let mut argument_tokens = TokenIterWithContext::new(tokens.in_range(argument.range())); + while let Some(token) = argument_tokens.next() { + if token.kind().is_any_newline() && !argument_tokens.in_parenthesized_context() { + needs_line_continuation = true; + break; + } + } + } + + let needs_parens = needs_boundary_parens + || needs_line_continuation + || (OperatorPrecedence::from(argument) < OperatorPrecedence::CallAttribute + && !parent_allows_unparenthesized) + || matches!( + argument, + Expr::Named(_) + | Expr::Yield(_) + | Expr::YieldFrom(_) + | Expr::Tuple(ast::ExprTuple { + parenthesized: false, + .. + }) + | Expr::Generator(ast::ExprGenerator { + parenthesized: false, + .. + }) + ); + + if needs_parens { + format!("({argument_source})") + } else { + argument_source.to_string() + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use ruff_python_ast::AnyNodeRef; + use ruff_python_ast::find_node::covering_node; + use ruff_python_parser::parse_module; + use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; + + use super::unwrapped_call_argument; + + #[test] + fn call_unwrapping_preserves_context() -> Result<(), Box> { + for (source, expected) in [ + ("wrap(f if flag else g)()", "(f if flag else g)()"), + ( + "[x for x in wrap(a if flag else b)]", + "[x for x in (a if flag else b)]", + ), + ("f\"{wrap({})}\"", "f\"{({})}\""), + ] { + let parsed = parse_module(source)?; + let start = TextSize::try_from(source.find("wrap").ok_or("missing wrap call")?)?; + let covering = covering_node( + parsed.syntax().into(), + TextRange::at(start, "wrap".text_len()), + ) + .find_first(|node| matches!(node, AnyNodeRef::ExprCall(_))) + .map_err(|_| "missing enclosing call")?; + let AnyNodeRef::ExprCall(call) = covering.node() else { + return Err("expected a call expression".into()); + }; + let argument = call.arguments.args.first().ok_or("missing argument")?; + let replacement = + unwrapped_call_argument(call, argument, covering.parent(), parsed.tokens(), source); + + let mut fixed = source.to_string(); + fixed.replace_range( + usize::from(call.start())..usize::from(call.end()), + &replacement, + ); + assert_eq!(fixed, expected, "{source}"); + parse_module(&fixed)?; + } + Ok(()) + } +} diff --git a/crates/ruff_python_edits/src/lib.rs b/crates/ruff_python_edits/src/lib.rs new file mode 100644 index 0000000000..26b50a9e5a --- /dev/null +++ b/crates/ruff_python_edits/src/lib.rs @@ -0,0 +1,7 @@ +/*! +Utilities for constructing edits to Python source code. +*/ + +pub use self::call::unwrapped_call_argument; + +mod call; diff --git a/crates/ruff_python_formatter/CONTRIBUTING.md b/crates/ruff_python_formatter/CONTRIBUTING.md index b30b628c03..111ec6c078 100644 --- a/crates/ruff_python_formatter/CONTRIBUTING.md +++ b/crates/ruff_python_formatter/CONTRIBUTING.md @@ -121,18 +121,6 @@ Available options: - `--stats-file`: Use together with `--multi-project`, this writes the similarity index as unicode table to the given file. -**Large ecosystem checks** It is also possible to check a large number of repositories. This dataset -is large (~60GB), so we only do this occasionally: - -```shell -# Get the list of projects -curl https://raw.githubusercontent.com/akx/ruff-usage-aggregate/master/data/known-github-tomls-clean.jsonl > github_search.jsonl -# Repurpose this script to download the repositories for us -python scripts/check_ecosystem.py --checkouts target/checkouts --projects github_search.jsonl -v $(which true) $(which true) -# Check each project for formatter stability -cargo run --bin ruff_dev -- format-dev --stability-check --error-file target/formatter-ecosystem-errors.txt --multi-project target/checkouts -``` - ## Helper structs To abstract formatting something into a helper, create a new struct with the data you want to diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index cfa6732104..cd45103fa4 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_formatter" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_formatter/README.md b/crates/ruff_python_formatter/README.md index c9a942f1bc..3d68e07b86 100644 --- a/crates/ruff_python_formatter/README.md +++ b/crates/ruff_python_formatter/README.md @@ -32,8 +32,8 @@ Head to [The Ruff Formatter](https://docs.astral.sh/ruff/formatter/) for usage i This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_formatter). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_formatter/generate.py b/crates/ruff_python_formatter/generate.py index ded6ae5b00..9ce8acddb7 100755 --- a/crates/ruff_python_formatter/generate.py +++ b/crates/ruff_python_formatter/generate.py @@ -74,13 +74,12 @@ def group_for_node(node: str) -> str: for group in groups: if node.startswith(group.title().replace("_", "")): return group - else: - return "other" + return "other" def to_camel_case(node: str) -> str: """Converts PascalCase to camel_case""" - return re.sub("([A-Z])", r"_\1", node).lower().lstrip("_") + return re.sub(r"([A-Z])", r"_\1", node).lower().lstrip("_") for node in nodes: diff --git a/crates/ruff_python_formatter/src/comments/placement.rs b/crates/ruff_python_formatter/src/comments/placement.rs index 0d1ac46075..590a5d66af 100644 --- a/crates/ruff_python_formatter/src/comments/placement.rs +++ b/crates/ruff_python_formatter/src/comments/placement.rs @@ -10,6 +10,7 @@ use ruff_python_trivia::{ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use std::cmp::Ordering; +use std::debug_assert_matches; use crate::comments::visitor::{CommentPlacement, DecoratedComment}; use crate::expression::expr_slice::{ExprSliceCommentSection, assign_comment_in_slice}; @@ -1164,11 +1165,7 @@ fn handle_slice_comments<'a>( // 1: // ] // ``` - debug_assert!( - matches!(comment.enclosing_node(), AnyNodeRef::ExprSubscript(_)), - "{:?}", - comment.enclosing_node() - ); + debug_assert_matches!(comment.enclosing_node(), AnyNodeRef::ExprSubscript(_)); return CommentPlacement::dangling(comment.enclosing_node(), comment); } @@ -1325,10 +1322,10 @@ fn handle_dict_unpacking_comment<'a>( comment: DecoratedComment<'a>, source: &str, ) -> CommentPlacement<'a> { - debug_assert!(matches!( + debug_assert_matches!( comment.enclosing_node(), AnyNodeRef::ExprDict(_) | AnyNodeRef::ExprDictComp(_) - )); + ); // no node after our comment so we can't be between `**` and the name (node) let Some(following) = comment.following_node() else { @@ -1368,10 +1365,10 @@ fn handle_key_value_comment<'a>( comment: DecoratedComment<'a>, source: &str, ) -> CommentPlacement<'a> { - debug_assert!(matches!( + debug_assert_matches!( comment.enclosing_node(), AnyNodeRef::ExprDict(_) | AnyNodeRef::ExprDictComp(_) - )); + ); let (Some(following), Some(preceding)) = (comment.following_node(), comment.preceding_node()) else { @@ -2014,10 +2011,10 @@ fn handle_bracketed_end_of_line_comment<'a>( let Some(paren) = lexer.next() else { return CommentPlacement::Default(comment); }; - debug_assert!(matches!( + debug_assert_matches!( paren.kind(), SimpleTokenKind::LParen | SimpleTokenKind::LBrace | SimpleTokenKind::LBracket - )); + ); // If there are no additional tokens between the open parenthesis and the comment, then // it should be attached as a dangling comment on the brackets, rather than a leading diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index c19b9c2c9a..45febc21c6 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -423,7 +423,7 @@ pub enum AssignmentAlignment { } impl AssignmentAlignment { - pub const fn is_enabled(self) -> bool { + pub(crate) const fn is_enabled(self) -> bool { matches!(self, AssignmentAlignment::Enabled) } } diff --git a/crates/ruff_python_formatter/src/other/parameters.rs b/crates/ruff_python_formatter/src/other/parameters.rs index e1dd1153a3..89338226eb 100644 --- a/crates/ruff_python_formatter/src/other/parameters.rs +++ b/crates/ruff_python_formatter/src/other/parameters.rs @@ -1,3 +1,5 @@ +use std::assert_matches; + use ruff_formatter::{FormatRuleWithOptions, format_args, write}; use ruff_python_ast::{AnyNodeRef, Parameters}; use ruff_python_trivia::{CommentLinePosition, SimpleToken, SimpleTokenKind, SimpleTokenizer}; @@ -671,27 +673,23 @@ fn has_trailing_comma( // The slash lacks its own node if ends_with_pos_only_argument_separator { let comma = tokens.next(); - assert!( - matches!( - comma, - Some(SimpleToken { - kind: SimpleTokenKind::Comma, - .. - }) - ), - "The last positional only argument must be separated by a `,` from the positional only parameters separator `/` but found '{comma:?}'." + assert_matches!( + comma, + Some(SimpleToken { + kind: SimpleTokenKind::Comma, + .. + }), + "The last positional only argument must be separated by a `,` from the positional only parameters separator `/`." ); let slash = tokens.next(); - assert!( - matches!( - slash, - Some(SimpleToken { - kind: SimpleTokenKind::Slash, - .. - }) - ), - "The positional argument separator must be present for a function that has positional only parameters but found '{slash:?}'." + assert_matches!( + slash, + Some(SimpleToken { + kind: SimpleTokenKind::Slash, + .. + }), + "The positional argument separator must be present for a function that has positional only parameters." ); } diff --git a/crates/ruff_python_importer/Cargo.toml b/crates/ruff_python_importer/Cargo.toml index 98b44197b0..556bbed95d 100644 --- a/crates/ruff_python_importer/Cargo.toml +++ b/crates/ruff_python_importer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_importer" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_importer/README.md b/crates/ruff_python_importer/README.md index 31a810a657..b06ab0d64d 100644 --- a/crates/ruff_python_importer/README.md +++ b/crates/ruff_python_importer/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_importer). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_importer). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_index/Cargo.toml b/crates/ruff_python_index/Cargo.toml index a177107337..5287210de4 100644 --- a/crates/ruff_python_index/Cargo.toml +++ b/crates/ruff_python_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_index" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_index/README.md b/crates/ruff_python_index/README.md index dc83a2c02e..fd3953c79d 100644 --- a/crates/ruff_python_index/README.md +++ b/crates/ruff_python_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_index). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_literal/Cargo.toml b/crates/ruff_python_literal/Cargo.toml index 7aa7cdd0a4..f4d5a4aff8 100644 --- a/crates/ruff_python_literal/Cargo.toml +++ b/crates/ruff_python_literal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_literal" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } diff --git a/crates/ruff_python_literal/README.md b/crates/ruff_python_literal/README.md index 94ee9e81bc..dfda323dc8 100644 --- a/crates/ruff_python_literal/README.md +++ b/crates/ruff_python_literal/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_literal). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_literal). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index b042afe18d..5f67ccebcb 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -227,27 +227,27 @@ pub enum FormatSpec { #[derive(Debug, PartialEq)] pub struct StaticFormatSpec { // Ex) `!s` in `'{!s}'` - pub conversion: Option, + pub(crate) conversion: Option, // Ex) `*` in `'{:*^30}'` - pub fill: Option, + pub(crate) fill: Option, // Ex) `<` in `'{:<30}'` - pub align: Option, + pub(crate) align: Option, // Ex) `+` in `'{:+f}'` - pub sign: Option, + pub(crate) sign: Option, // Ex) `#` in `'{:#x}'` - pub alternate_form: bool, + pub(crate) alternate_form: bool, // Ex) the leading `0` in `'{:08.3f}'`. the zero is also folded into `fill` // and `align`, which is what formatting itself uses; this records whether // it was written, which the sign-aware-zero-padding rules need - pub zero: bool, + pub(crate) zero: bool, // Ex) `30` in `'{:<30}'` - pub width: Option, + pub(crate) width: Option, // Ex) `,` in `'{:,}'` - pub grouping_option: Option, + pub(crate) grouping_option: Option, // Ex) `2` in `'{:.2}'` - pub precision: Option, + pub(crate) precision: Option, // Ex) `f` in `'{:+f}'` - pub format_type: Option, + pub(crate) format_type: Option, } /// byte spans of the individual components of a [`StaticFormatSpec`], relative @@ -258,16 +258,16 @@ pub struct StaticFormatSpec { /// part of the spec a given offset falls in #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FormatSpecSpans { - pub conversion: Option>, - pub fill: Option>, - pub align: Option>, - pub sign: Option>, - pub alternate_form: Option>, - pub zero: Option>, - pub width: Option>, - pub grouping_option: Option>, - pub precision: Option>, - pub format_type: Option>, + pub(crate) conversion: Option>, + pub(crate) fill: Option>, + pub(crate) align: Option>, + pub(crate) sign: Option>, + pub(crate) alternate_form: Option>, + pub(crate) zero: Option>, + pub(crate) width: Option>, + pub(crate) grouping_option: Option>, + pub(crate) precision: Option>, + pub(crate) format_type: Option>, } #[derive(Debug, PartialEq)] diff --git a/crates/ruff_python_literal/src/mini_language.rs b/crates/ruff_python_literal/src/mini_language.rs index 1d3f5fc5e7..a2d3333e13 100644 --- a/crates/ruff_python_literal/src/mini_language.rs +++ b/crates/ruff_python_literal/src/mini_language.rs @@ -150,7 +150,7 @@ impl FormatSpecComponent { FormatSpecComponent::Type, ]; - pub fn label(self) -> &'static str { + fn label(self) -> &'static str { match self { FormatSpecComponent::Conversion => "conversion", FormatSpecComponent::Fill => "fill", @@ -165,7 +165,7 @@ impl FormatSpecComponent { } } - pub fn documentation(self) -> &'static str { + fn documentation(self) -> &'static str { match self { FormatSpecComponent::Conversion => { "applied before formatting: `!s` calls `str`, `!r` calls `repr`, `!a` calls `ascii`" diff --git a/crates/ruff_python_literal/src/strftime.rs b/crates/ruff_python_literal/src/strftime.rs index 546ff0574c..90cf8fa037 100644 --- a/crates/ruff_python_literal/src/strftime.rs +++ b/crates/ruff_python_literal/src/strftime.rs @@ -51,7 +51,7 @@ impl Directive { /// what this directive writes for [`SAMPLE`], or `None` when the answer /// depends on the machine rather than the value - pub fn sample(&self) -> Option<&'static str> { + fn sample(&self) -> Option<&'static str> { let code = self.code?; // a flag changes the padding, so the unflagged sample would be a lie if self.flagged { diff --git a/crates/ruff_python_parser/Cargo.toml b/crates/ruff_python_parser/Cargo.toml index 9766dbb59c..940f535d71 100644 --- a/crates/ruff_python_parser/Cargo.toml +++ b/crates/ruff_python_parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_parser" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } @@ -24,6 +24,7 @@ get-size2 = { workspace = true } memchr = { workspace = true } rustc-hash = { workspace = true } static_assertions = { workspace = true } +stacker = { workspace = true } thin-vec = { workspace = true } unicode-ident = { workspace = true } unicode-normalization = { workspace = true } diff --git a/crates/ruff_python_parser/README.md b/crates/ruff_python_parser/README.md index 20d8066dc4..9f841b8a2b 100644 --- a/crates/ruff_python_parser/README.md +++ b/crates/ruff_python_parser/README.md @@ -19,8 +19,8 @@ Refer to the [contributing guidelines](./CONTRIBUTING.md) to get started and Git This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_parser). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_parser). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_parser/resources/inline/err/duplicate_keyword_args.py b/crates/ruff_python_parser/resources/inline/err/duplicate_keyword_args.py new file mode 100644 index 0000000000..f599841eb4 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/duplicate_keyword_args.py @@ -0,0 +1,4 @@ +def foo(x): ... +foo(x=1, x=2) +def baz(x, y, z): ... +baz(x, y=1, z=3, y=4) diff --git a/crates/ruff_python_parser/resources/inline/err/mixed_tstring_and_bytes_literals.py b/crates/ruff_python_parser/resources/inline/err/mixed_tstring_and_bytes_literals.py new file mode 100644 index 0000000000..b81a3f44ab --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/mixed_tstring_and_bytes_literals.py @@ -0,0 +1,7 @@ +t'first' b'second' +b'first' t'second' +t'first' br'second' +'first' b'second' t'third' +b'first' 'second' t'third' +'first' t'second' 'third' b'fourth' +b'first' t'second' f'third' diff --git a/crates/ruff_python_parser/resources/inline/err/signed_pattern_non_literal_operand.py b/crates/ruff_python_parser/resources/inline/err/signed_pattern_non_literal_operand.py new file mode 100644 index 0000000000..98e45798e7 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/signed_pattern_non_literal_operand.py @@ -0,0 +1,7 @@ +# parse_options: {"target-version": "3.15"} +match value: + case -1**2: ... + case -1 .real: ... + case -1[0]: ... + case -1(): ... + case {+1**2: _}: ... diff --git a/crates/ruff_python_parser/resources/inline/err/unary_plus_py314.py b/crates/ruff_python_parser/resources/inline/err/unary_plus_py314.py new file mode 100644 index 0000000000..293dc07822 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/unary_plus_py314.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.14"} +match foo: + case +1: ... + case {+1: 2}: ... diff --git a/crates/ruff_python_parser/resources/inline/ok/non_duplicate_keyword_args.py b/crates/ruff_python_parser/resources/inline/ok/non_duplicate_keyword_args.py new file mode 100644 index 0000000000..d42953792a --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/non_duplicate_keyword_args.py @@ -0,0 +1,4 @@ +def foo(x): ... +foo(x=1) +def bar(x, y, z): ... +foo(x="a", y=1, z=True) diff --git a/crates/ruff_python_parser/resources/inline/ok/unary_plus_py315.py b/crates/ruff_python_parser/resources/inline/ok/unary_plus_py315.py new file mode 100644 index 0000000000..fd2ce24bba --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/unary_plus_py315.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +match foo: + case +1: ... + # this is also now valid inside more complicated patterns + case {+1: 2}: ... diff --git a/crates/ruff_python_parser/resources/invalid/statements/match/unary_add_usage.py b/crates/ruff_python_parser/resources/invalid/statements/match/unary_add_usage.py index 293e4b4b81..11b274b1fa 100644 --- a/crates/ruff_python_parser/resources/invalid/statements/match/unary_add_usage.py +++ b/crates/ruff_python_parser/resources/invalid/statements/match/unary_add_usage.py @@ -1,4 +1,4 @@ -# Unary addition isn't allowed but we parse it for better error recovery. +# Unary addition isn't allowed before Python 3.15. match subject: case +1: pass diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 23d8008664..30f86d7a28 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -165,9 +165,6 @@ pub enum ParseErrorType { /// A default value was found for a `*` or `**` parameter. VarParameterWithDefault, - /// A keyword argument was repeated. - DuplicateKeywordArgumentError(String), - /// An invalid expression was found in the assignment target. InvalidAssignmentTarget, /// An invalid expression was found in the named assignment target. @@ -222,9 +219,6 @@ pub enum ParseErrorType { TStringError(InterpolatedStringErrorType), /// Parser encountered an error during lexing. Lexical(LexicalErrorType), - - /// Parser aborted because [`crate::ParseOptions::max_recursion_depth`] was exceeded. - RecursionLimitExceeded, } impl ParseErrorType { @@ -352,9 +346,6 @@ impl std::fmt::Display for ParseErrorType { f.write_str("Invalid augmented assignment target") } ParseErrorType::InvalidDeleteTarget => f.write_str("Invalid delete target"), - ParseErrorType::DuplicateKeywordArgumentError(arg_name) => { - write!(f, "Duplicate keyword argument {arg_name:?}") - } ParseErrorType::UnexpectedIpythonEscapeCommand => { f.write_str("IPython escape commands are only allowed in `Mode::Ipython`") } @@ -367,7 +358,6 @@ impl std::fmt::Display for ParseErrorType { ParseErrorType::UnexpectedExpressionToken => { write!(f, "Unexpected token at the end of an expression") } - ParseErrorType::RecursionLimitExceeded => f.write_str("Source is too deeply nested"), } } } @@ -986,6 +976,17 @@ pub enum UnsupportedSyntaxErrorKind { /// /// [PEP 750]: https://peps.python.org/pep-0750/ TemplateStrings, + + /// Represents the use of a unary plus in a `match` literal pattern before Python 3.15. + /// + /// Before 3.15, unary minus was allowed but not plus: + /// + /// ```python + /// match foo: + /// case -1: ... # okay + /// case +1: ... # error before 3.15 + /// ``` + UnaryPlusMatchPattern, } impl Display for UnsupportedSyntaxError { @@ -1083,6 +1084,9 @@ impl Display for UnsupportedSyntaxError { "Multiple exception types must be parenthesized" } UnsupportedSyntaxErrorKind::TemplateStrings => "Cannot use t-strings", + UnsupportedSyntaxErrorKind::UnaryPlusMatchPattern => { + "Unary '+' is not allowed in a literal pattern" + } }; write!( @@ -1158,6 +1162,9 @@ impl UnsupportedSyntaxErrorKind { Change::Added(PythonVersion::PY314) } UnsupportedSyntaxErrorKind::TemplateStrings => Change::Added(PythonVersion::PY314), + UnsupportedSyntaxErrorKind::UnaryPlusMatchPattern => { + Change::Added(PythonVersion::PY315) + } } } diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index 5305d635b6..6687968d14 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -123,12 +123,6 @@ impl<'src> Lexer<'src> { self.current_range } - /// Returns the current parenthesis, bracket, and brace nesting level. - #[inline] - pub(crate) const fn nesting(&self) -> u32 { - self.nesting - } - /// Returns the flags for the current token. pub(crate) const fn current_flags(&self) -> TokenFlags { self.current_flags @@ -1102,10 +1096,10 @@ impl<'src> Lexer<'src> { /// Lex a hex/octal/decimal/binary number without a decimal point. fn lex_number_radix(&mut self, radix: Radix) -> TokenKind { #[cfg(debug_assertions)] - debug_assert!(matches!( - self.cursor.previous().to_ascii_lowercase(), - 'x' | 'o' | 'b' - )); + { + use std::debug_assert_matches; + debug_assert_matches!(self.cursor.previous().to_ascii_lowercase(), 'x' | 'o' | 'b'); + } let number = self.radix_run(radix); if !number.has_digit { diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 494806bf78..0a1aff0197 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -1,7 +1,6 @@ use std::ops::Deref; use bitflags::bitflags; -use rustc_hash::{FxBuildHasher, FxHashSet}; use thin_vec::ThinVec; use ruff_python_ast::name::Name; @@ -174,7 +173,7 @@ impl<'src> Parser<'src> { /// whereas `out[...]`, `out(...)`, `out.attr`, `out + 1` etc. are an /// ordinary subscript / call / attribute / arithmetic on a variable named /// `out` and must be left alone (real Python uses them, e.g. `xs[out[0]]`). - pub(super) fn eat_basedpython_variance_prefix( + fn eat_basedpython_variance_prefix( &mut self, ) -> Option { use ruff_python_ast::helpers::UseSiteVariance; @@ -215,7 +214,7 @@ impl<'src> Parser<'src> { /// `marker_range` should cover the variance keyword tokens themselves /// (no trailing whitespace) so the formatter can emit the exact source /// text on round-trip. - pub(super) fn wrap_variance_marker( + fn wrap_variance_marker( inner: Expr, variance: ruff_python_ast::helpers::UseSiteVariance, marker_range: TextRange, @@ -250,7 +249,7 @@ impl<'src> Parser<'src> { /// that a type which does not start with a name — a parenthesized callable /// type, a string forward reference, a starred type — cannot carry a bare /// modifier; see the docs for the recommended spelling. - pub(super) fn eat_basedpython_type_modifier_prefix( + fn eat_basedpython_type_modifier_prefix( &mut self, ) -> Option<(ruff_python_ast::helpers::TypeModifier, TextRange)> { use ruff_python_ast::helpers::TypeModifier; @@ -279,7 +278,7 @@ impl<'src> Parser<'src> { /// /// `marker_range` covers the keyword token only (no trailing whitespace) so /// the formatter can emit the exact source text on round-trip. - pub(super) fn wrap_type_modifier_marker( + fn wrap_type_modifier_marker( inner: Expr, modifier: ruff_python_ast::helpers::TypeModifier, marker_range: TextRange, @@ -340,12 +339,9 @@ impl<'src> Parser<'src> { self.parse_type_decorator(PostfixCalls::Forbidden) }; - let Some(inner) = self.with_recursion(|parser| { + let inner = self.with_recursion(|parser| { parser.parse_binary_expression_or_higher(OperatorPrecedence::None, context) - }) else { - self.report_recursion_limit_exceeded(self.current_token_range()); - return self.recursion_recovery_expr(); - }; + }); ParsedExpr { expr: Expr::Subscript(ast::ExprSubscript { @@ -484,9 +480,11 @@ impl<'src> Parser<'src> { left_precedence: OperatorPrecedence, context: ExpressionContext, ) -> ParsedExpr { - let start = self.node_start(); - let lhs = self.parse_lhs_expression(left_precedence, context); - self.parse_binary_expression_or_higher_recursive(lhs, left_precedence, context, start) + self.with_recursion(|parser| { + let start = parser.node_start(); + let lhs = parser.parse_lhs_expression(left_precedence, context); + parser.parse_binary_expression_or_higher_recursive(lhs, left_precedence, context, start) + }) } fn parse_binary_expression_or_higher_recursive( @@ -714,22 +712,7 @@ impl<'src> Parser<'src> { } self.bump(TokenKind::from(bin_op)); - let right = if new_precedence.is_right_associative() { - // For right-associative operators (`**`), the right - // operand recursion is unbounded in `a**a**a**...`, - // and it bypasses the guard in `parse_lhs_expression` - // (that scope is exited once the atom is parsed). - if let Some(right) = self.with_recursion(|parser| { - parser.parse_binary_expression_or_higher(new_precedence, context) - }) { - right - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - self.recursion_recovery_expr() - } - } else { - self.parse_binary_expression_or_higher(new_precedence, context) - }; + let right = self.parse_binary_expression_or_higher(new_precedence, context); Expr::BinOp(ast::ExprBinOp { left: Box::new(left.expr), @@ -773,12 +756,8 @@ impl<'src> Parser<'src> { if context.is_in_type_expression() && let Some((modifier, marker_range)) = self.eat_basedpython_type_modifier_prefix() { - let Some(inner) = - self.with_recursion(|parser| parser.parse_lhs_expression(left_precedence, context)) - else { - self.report_recursion_limit_exceeded(self.current_token_range()); - return self.recursion_recovery_expr(); - }; + let inner = + self.with_recursion(|parser| parser.parse_lhs_expression(left_precedence, context)); return ParsedExpr { expr: Self::wrap_type_modifier_marker(inner.expr, modifier, marker_range), is_parenthesized: false, @@ -787,60 +766,6 @@ impl<'src> Parser<'src> { } let token = self.current_token_kind(); - if !Self::token_starts_recursive_lhs(token) { - return self.parse_lhs_expression_inner(left_precedence, context, token); - } - - if let Some(result) = self.with_recursion(|parser| { - parser.parse_lhs_expression_inner(left_precedence, context, token) - }) { - result - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - self.recursion_recovery_expr() - } - } - - /// Returns whether parsing an expression that starts with `token` can - /// immediately recurse through another expression parse. - #[inline] - fn token_starts_recursive_lhs(token: TokenKind) -> bool { - token.as_unary_operator().is_some() - || matches!( - token, - TokenKind::Star - | TokenKind::Await - | TokenKind::Lambda - | TokenKind::Yield - | TokenKind::FStringStart - | TokenKind::TStringStart - | TokenKind::Lpar - | TokenKind::Lsqb - | TokenKind::Lbrace - ) - } - - /// The standard expression-recovery node returned when the recursion - /// limit is exceeded: an empty `Name` with the `Invalid` context. - fn recursion_recovery_expr(&mut self) -> ParsedExpr { - ParsedExpr { - expr: Expr::Name(ast::ExprName { - range: self.missing_node_range(), - id: Name::empty(), - ctx: ExprContext::Invalid, - node_index: AtomicNodeIndex::NONE, - }), - is_parenthesized: false, - parameter_borrow: ParameterBorrow::None, - } - } - - fn parse_lhs_expression_inner( - &mut self, - left_precedence: OperatorPrecedence, - context: ExpressionContext, - token: TokenKind, - ) -> ParsedExpr { let start = self.node_start(); if let Some(unary_op) = token.as_unary_operator() { @@ -1350,20 +1275,8 @@ impl<'src> Parser<'src> { loop { lhs = match self.current_token_kind() { TokenKind::Lpar if calls == PostfixCalls::Forbidden => break lhs, - TokenKind::Lpar => { - if self.tokens.nesting() > self.max_nesting_depth { - self.report_recursion_limit_exceeded(self.current_token_range()); - break lhs; - } - Expr::Call(self.parse_call_expression(lhs, start)) - } - TokenKind::Lsqb => { - if self.tokens.nesting() > self.max_nesting_depth { - self.report_recursion_limit_exceeded(self.current_token_range()); - break lhs; - } - Expr::Subscript(self.parse_subscript_expression(lhs, start)) - } + TokenKind::Lpar => Expr::Call(self.parse_call_expression(lhs, start)), + TokenKind::Lsqb => Expr::Subscript(self.parse_subscript_expression(lhs, start)), // basedpython: in a bound range `T: Lower..Upper` the `..` separates the two // ends, so the lower end stops rather than reading the dots as attribute access TokenKind::Dot @@ -1390,13 +1303,6 @@ impl<'src> Parser<'src> { // form is unambiguous; the `->` is consumed here rather than by // the binary loop, which only recognises a parenthesized left TokenKind::Dot if self.peek() == TokenKind::Lpar => { - // the parameter list opens a bracket and the return type - // recurses, so this arm needs the same depth guard as the - // call / subscript arms above - if self.tokens.nesting() > self.max_nesting_depth { - self.report_recursion_limit_exceeded(self.current_token_range()); - break lhs; - } self.error_if_not_basedpython( "receiver callable type `T.(...) -> ...` is not valid in .py files" .to_string(), @@ -2214,7 +2120,7 @@ impl<'src> Parser<'src> { } /// Parse `a?.b` — basedpython None-chaining attribute access. - pub(super) fn parse_optional_attribute_expression( + fn parse_optional_attribute_expression( &mut self, value: Expr, start: TextSize, @@ -2481,16 +2387,20 @@ impl<'src> Parser<'src> { // We could convert the node into a string and mark it as invalid // and would be clever to mark the type which is fewer in quantity. + // test_err mixed_tstring_and_bytes_literals + // t'first' b'second' + // b'first' t'second' + // t'first' br'second' + // 'first' b'second' t'third' + // b'first' 'second' t'third' + // 'first' t'second' 'third' b'fourth' + // b'first' t'second' f'third' + // test_err mixed_bytes_and_non_bytes_literals // 'first' b'second' // f'first' b'second' // 'first' f'second' b'third' - self.add_error( - ParseErrorType::OtherError( - "Bytes literal cannot be mixed with non-bytes literals".to_string(), - ), - range, - ); + self.report_mixed_string_literal_error(&strings, range); } // Only construct a byte expression if all the literals are bytes // otherwise, we'll try either string, t-string, or f-string. This is to retain @@ -2509,16 +2419,9 @@ impl<'src> Parser<'src> { node_index: AtomicNodeIndex::NONE, }); } - } - - if has_tstring { + } else if has_tstring { if tstring_count < strings.len() { - self.add_error( - ParseErrorType::OtherError( - "Cannot mix t-string literals with string or bytes literals".to_string(), - ), - range, - ); + self.report_mixed_string_literal_error(&strings, range); } // Only construct a t-string expression if all the literals are t-strings // otherwise, we'll try either string or f-string. This is to retain @@ -2600,6 +2503,26 @@ impl<'src> Parser<'src> { }) } + fn report_mixed_string_literal_error(&mut self, strings: &[StringType], range: TextRange) { + // CPython reports the first incompatible pair. A t-string mismatch takes + // precedence over a bytes mismatch within that pair. + for pair in strings.windows(2) { + let message = match pair { + [StringType::TString(_), StringType::TString(_)] + | [StringType::Bytes(_), StringType::Bytes(_)] => continue, + [StringType::TString(_), _] | [_, StringType::TString(_)] => { + "Cannot mix t-string literals with string or bytes literals" + } + [StringType::Bytes(_), _] | [_, StringType::Bytes(_)] => { + "Bytes literal cannot be mixed with non-bytes literals" + } + _ => continue, + }; + self.add_error(ParseErrorType::OtherError(message.to_string()), range); + break; + } + } + /// Parses a single string or byte literal. /// /// This does not handle implicitly concatenated strings. @@ -2608,7 +2531,7 @@ impl<'src> Parser<'src> { /// /// If the parser isn't positioned at a `String` token. /// - /// See: + /// See: fn parse_string_or_byte_literal(&mut self) -> StringType { let range = self.current_token_range(); let flags = self.tokens.current_flags().as_any_string_flags(); @@ -2916,18 +2839,13 @@ impl<'src> Parser<'src> { let format_spec = if self.eat(TokenKind::Colon) { let spec_start = self.node_start(); - let elements = if let Some(elements) = self.with_recursion(|parser| { + let elements = self.with_recursion(|parser| { parser.parse_interpolated_string_elements( flags, InterpolatedStringElementsKind::FormatSpec(string_kind), string_kind, ) - }) { - elements - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - ast::InterpolatedStringElements::from(vec![]) - }; + }); Some(Box::new(ast::InterpolatedStringFormatSpec { range: self.node_range(spec_start), elements, @@ -5201,15 +5119,8 @@ impl<'src> Parser<'src> { // lambda x: yield y // lambda x: yield from y - // `lambda: lambda: lambda: ...` recurses through the lambda body at - // the conditional layer, bypassing the `parse_lhs_expression` guard. - let body = - if let Some(body) = self.with_recursion(Self::parse_conditional_expression_or_higher) { - body - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - self.recursion_recovery_expr() - }; + // Lambda bodies recurse through the conditional layer without entering the binary parser. + let body = self.with_recursion(Self::parse_conditional_expression_or_higher); ast::ExprLambda { body: Box::new(body.expr), @@ -5227,7 +5138,7 @@ impl<'src> Parser<'src> { /// If the parser isn't positioned at an `if` token. /// /// See: - pub(super) fn parse_if_expression( + fn parse_if_expression( &mut self, body: Expr, start: TextSize, @@ -5250,17 +5161,12 @@ impl<'src> Parser<'src> { } else { ExpressionContext::default() }; - // `a if b else a if b else ...` recurses through `orelse` at the - // conditional layer, which is not covered by the `parse_lhs_expression` - // guard (that scope is released once each atom is parsed). Guard here. - let orelse = if let Some(orelse) = - self.with_recursion(|p| p.parse_conditional_expression_or_higher_impl(orelse_context)) - { - orelse - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - self.recursion_recovery_expr() - }; + // `a if b else a if b else ...` recurses through `orelse` at the conditional + // layer, which the `parse_lhs_expression` guard does not cover — that scope is + // released once each atom is parsed — so the guard belongs here. the + // binary-expression guard has likewise already returned by this point + let orelse = + self.with_recursion(|p| p.parse_conditional_expression_or_higher_impl(orelse_context)); ast::ExprIf { body: Box::new(body), @@ -5302,31 +5208,13 @@ impl<'src> Parser<'src> { } /// Performs the following validations on the arguments: - /// 1. There aren't any duplicate keyword argument - /// 2. Generator expressions are parenthesized when required by the argument context. + /// - Generator expressions are parenthesized when required by the argument context. fn validate_arguments( &mut self, arguments: &ast::Arguments, has_trailing_comma: bool, context: ArgumentsContext, ) { - let mut all_arg_names = - FxHashSet::with_capacity_and_hasher(arguments.keywords.len(), FxBuildHasher); - - for (name, range) in arguments - .keywords - .iter() - .filter_map(|argument| argument.arg.as_ref().map(|arg| (arg, argument.range))) - { - let arg_name = name.as_str(); - if !all_arg_names.insert(arg_name) { - self.add_error( - ParseErrorType::DuplicateKeywordArgumentError(arg_name.to_string()), - range, - ); - } - } - let generator_must_be_parenthesized = match context { ArgumentsContext::Call => has_trailing_comma || arguments.len() > 1, // CPython rejects an unparenthesized generator expression as a class base even though @@ -5661,12 +5549,12 @@ impl ExpressionContext { /// basedpython: returns a new context that marks parsing as being inside a /// subscript slice element, enabling bare `*` top-star markers nested /// inside type-position binops like `int | *` - pub(super) fn with_subscript_slice(self) -> Self { + fn with_subscript_slice(self) -> Self { ExpressionContext(self.0 | ExpressionContextFlags::SUBSCRIPT_SLICE) } /// basedpython: returns `true` if currently parsing a subscript slice element - pub(super) const fn is_subscript_slice(self) -> bool { + const fn is_subscript_slice(self) -> bool { self.0.contains(ExpressionContextFlags::SUBSCRIPT_SLICE) } @@ -5677,7 +5565,7 @@ impl ExpressionContext { } /// basedpython: returns `true` if currently parsing a type expression - pub(super) const fn is_in_type_expression(self) -> bool { + const fn is_in_type_expression(self) -> bool { self.0.contains(ExpressionContextFlags::IN_TYPE_EXPRESSION) } @@ -5687,7 +5575,7 @@ impl ExpressionContext { /// yield / comprehension rules inside them are their own — but being in a /// type expression is a property of the *position*, and a parenthesis does /// not leave it. Without this, `(literal str)` reads `literal` as a name - pub(super) fn inheriting_type_expression(self, outer: Self) -> Self { + fn inheriting_type_expression(self, outer: Self) -> Self { if outer.is_in_type_expression() { self.with_in_type_expression() } else { @@ -5697,14 +5585,14 @@ impl ExpressionContext { /// basedpython: returns a new context that marks parsing as being inside the /// value of an interpolated-string replacement field - pub(super) fn with_in_interpolation(self) -> Self { + fn with_in_interpolation(self) -> Self { ExpressionContext(self.0 | ExpressionContextFlags::IN_INTERPOLATION) } /// basedpython: returns `true` if parsing the value of an interpolated-string /// replacement field, where a trailing `!` is the conversion flag rather /// than the postfix force-unwrap operator - pub(super) const fn is_in_interpolation(self) -> bool { + const fn is_in_interpolation(self) -> bool { self.0.contains(ExpressionContextFlags::IN_INTERPOLATION) } @@ -5717,7 +5605,7 @@ impl ExpressionContext { /// basedpython: returns `true` if parsing the lower end of a type-parameter bound range, /// where a following `..` separates the two ends rather than being a malformed attribute /// access - pub(super) const fn is_in_type_param_bound(self) -> bool { + const fn is_in_type_param_bound(self) -> bool { self.0.contains(ExpressionContextFlags::IN_TYPE_PARAM_BOUND) } diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 6a9afb4d3c..885a704bba 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -60,6 +60,12 @@ impl NameInterner { } } +// Stack probes access thread-local state, so avoid them while recursive parser calls remain +// shallow. `STACK_RED_ZONE` must cover the stack used before the first deferred probe. +const STACK_RED_ZONE: usize = 100 * 1024; +const STACK_SIZE: usize = 1024 * 1024; +const MAX_UNCHECKED_RECURSION_DEPTH: usize = 20; + #[derive(Debug)] pub(crate) struct Parser<'src> { source: &'src str, @@ -97,7 +103,7 @@ pub(crate) struct Parser<'src> { /// basedpython: depth of nested class bodies currently being parsed. /// Used to recognise `init(...)` as a method shorthand only inside a class. - pub(super) class_body_depth: u32, + class_body_depth: u32, /// basedpython: extra class-body members a single `parse_statement` produced /// but could not return directly. A property accessor block lowers one @@ -105,31 +111,28 @@ pub(crate) struct Parser<'src> { /// declaration statement is returned and these follow-on members are drained /// here by [`Parser::parse_block`]. Filled only by the property path (guarded /// by `class_body_depth > 0`), empty everywhere else. - pub(super) pending_members: Vec, + pending_members: Vec, /// basedpython: `(property, backing field)` pairs declared in the class body /// currently being parsed whose getter is a pure field read. In-class *reads* /// of those properties are retargeted at the backing field once the body is /// complete, so the class sees storage at its own type. Scoped per class body /// by [`Parser::parse_body`]. - pub(super) pending_narrow_props: Vec, + pending_narrow_props: Vec, /// basedpython: set when a [statement expression](ruff_python_ast::ExprStatement) /// just parsed as part of a simple statement swallowed that statement's /// terminating newline along with its suite. The simple-statement parsers /// take this flag instead of demanding a newline of their own. - pub(super) expr_consumed_suite: bool, + expr_consumed_suite: bool, /// basedpython: how many destructuring binders have been named so far. /// Counting them in source order keeps their names stable across a /// reformat, and rewinding restores the count with everything else destructure_binders: u32, - /// Current parser recursion depth remaining before the depth limit is exceeded. - depth_remaining: u16, - - /// Maximum lexer nesting depth before postfix calls and subscripts should stop recursing. - max_nesting_depth: u32, + /// Number of active recursive statement, expression, and pattern parsing operations. + recursion_depth: usize, /// Reusable, nesting-safe scratch storage for expression lists. expr_scratch: ScratchBuffer, @@ -163,8 +166,6 @@ impl<'src> Parser<'src> { options: ParseOptions, ) -> Self { let tokens = TokenSource::from_source(source, options.mode, start_offset); - let depth_remaining = options.max_recursion_depth; - let max_nesting_depth = u32::from(options.max_recursion_depth.saturating_sub(2)); Parser { options, @@ -177,14 +178,13 @@ impl<'src> Parser<'src> { recovery_context: RecoveryContext::empty(), prev_token_end: TextSize::new(0), start_offset, + recursion_depth: 0, current_token_id: TokenId::default(), class_body_depth: 0, pending_members: Vec::new(), pending_narrow_props: Vec::new(), expr_consumed_suite: false, destructure_binders: 0, - depth_remaining, - max_nesting_depth, expr_scratch: ScratchBuffer::with_capacity(16), keyword_scratch: ScratchBuffer::new(), parameter_scratch: ScratchBuffer::new(), @@ -194,44 +194,34 @@ impl<'src> Parser<'src> { } } - /// Runs `f` if the recursive parser depth limit has not been hit. - /// - /// # Note - /// - /// This recursion guard is a temporary fix for #22930. - #[must_use] + /// Grows the stack for recursive parser calls only after shallow nesting is exceeded. #[inline] - fn with_recursion(&mut self, f: impl FnOnce(&mut Self) -> T) -> Option { - if self.depth_remaining == 0 { - return None; - } + fn with_recursion(&mut self, f: impl FnOnce(&mut Self) -> T) -> T { + self.recursion_depth += 1; + + let result = if self.recursion_depth > MAX_UNCHECKED_RECURSION_DEPTH { + self.grow_stack(f) + } else { + f(self) + }; - self.depth_remaining -= 1; - let result = f(self); - self.depth_remaining += 1; - Some(result) + self.recursion_depth -= 1; + result } #[cold] - #[inline(never)] - fn report_recursion_limit_exceeded(&mut self, ranged: R) { - self.add_error(ParseErrorType::RecursionLimitExceeded, ranged); - // Skip to end-of-file so outer parser frames unwind quickly and our - // `ParserProgress` infinite-loop guards don't fire when they see the - // same `(` / `[` etc. that this frame failed to consume. - while self.current_token_kind() != TokenKind::EndOfFile { - self.bump_any(); - } + fn grow_stack(&mut self, f: impl FnOnce(&mut Self) -> T) -> T { + stacker::maybe_grow(STACK_RED_ZONE, STACK_SIZE, || f(self)) } /// Consumes the [`Parser`] and returns the parsed [`Parsed`]. pub(crate) fn parse(mut self) -> Parsed { - let syntax = match self.options.mode { + let syntax = stacker::maybe_grow(STACK_RED_ZONE, STACK_SIZE, || match self.options.mode { Mode::Expression | Mode::ParenthesizedExpression => { Mod::Expression(self.parse_single_expression()) } Mode::Module | Mode::Ipython => Mod::Module(self.parse_module()), - }; + }); self.finish(syntax) } @@ -1572,10 +1562,7 @@ impl RecoveryContextKind { | RecoveryContextKind::SetElements | RecoveryContextKind::TupleElements(_) => p.at_expr(), RecoveryContextKind::DictElements => p.at(TokenKind::DoubleStar) || p.at_expr(), - RecoveryContextKind::SequenceMatchPattern(_) => { - // `+` doesn't start any pattern but is here for better error recovery. - p.at(TokenKind::Plus) || p.at_pattern_start() - } + RecoveryContextKind::SequenceMatchPattern(_) => p.at_pattern_start(), RecoveryContextKind::MatchPatternMapping => { // A star pattern is invalid as a mapping key and is here only for // better error recovery. diff --git a/crates/ruff_python_parser/src/parser/options.rs b/crates/ruff_python_parser/src/parser/options.rs index e1feb71491..4e807323e7 100644 --- a/crates/ruff_python_parser/src/parser/options.rs +++ b/crates/ruff_python_parser/src/parser/options.rs @@ -2,20 +2,6 @@ use ruff_python_ast::{PySourceType, PythonVersion}; use crate::{AsMode, Mode}; -/// The default maximum recursion depth used by the parser. -/// -/// Real-world Python rarely nests more than a handful of levels deep; this cap -/// exists to keep the parser from overflowing the stack on adversarial or -/// machine-generated input. -/// -/// The default value mirrors CPython's `MAXSTACK` of 200 nested parentheses -/// (`Parser/parser.c`): a one-statement module of the form `((((1))))` at -/// depth 200 must parse, and one at depth 201 must fail. Each nesting level -/// costs one `with_recursion` call, plus two framing calls (one for the -/// surrounding statement and one for the innermost atom), so the cap is set -/// to `200 + 2`. -const DEFAULT_MAX_RECURSION_DEPTH: u16 = 202; - /// Options for controlling how a source file is parsed. /// /// You can construct a [`ParseOptions`] directly from a [`Mode`]: @@ -43,24 +29,9 @@ pub struct ParseOptions { /// When true, basedpython-specific syntax is accepted without errors. /// When false (default), basedpython syntax is a parse error. pub(crate) is_basedpython: bool, - /// Maximum recursion depth for the parser. The parser aborts with a - /// [`crate::ParseErrorType::RecursionLimitExceeded`] error once this many - /// nested expression / statement / pattern nodes are on the parser's call - /// stack. Defaults to [`DEFAULT_MAX_RECURSION_DEPTH`]. - pub(crate) max_recursion_depth: u16, } impl ParseOptions { - #[must_use] - pub fn with_target_version(mut self, target_version: PythonVersion) -> Self { - self.target_version = target_version; - self - } - - pub fn target_version(&self) -> PythonVersion { - self.target_version - } - /// Marks the source as a basedpython (`.by`) file, enabling basedpython-specific syntax. #[must_use] pub fn with_basedpython(mut self, is_basedpython: bool) -> Self { @@ -68,15 +39,14 @@ impl ParseOptions { self } - /// Set the maximum recursion depth for the parser. #[must_use] - pub fn with_max_recursion_depth(mut self, depth: u16) -> Self { - self.max_recursion_depth = depth; + pub fn with_target_version(mut self, target_version: PythonVersion) -> Self { + self.target_version = target_version; self } - pub fn max_recursion_depth(&self) -> u16 { - self.max_recursion_depth + pub fn target_version(&self) -> PythonVersion { + self.target_version } } @@ -86,7 +56,6 @@ impl From for ParseOptions { mode, target_version: PythonVersion::default(), is_basedpython: false, - max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH, } } } @@ -97,7 +66,6 @@ impl From for ParseOptions { mode: source_type.as_mode(), target_version: PythonVersion::default(), is_basedpython: source_type.is_basedpython(), - max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH, } } } diff --git a/crates/ruff_python_parser/src/parser/pattern.rs b/crates/ruff_python_parser/src/parser/pattern.rs index 7be5eaac6a..647c0c3956 100644 --- a/crates/ruff_python_parser/src/parser/pattern.rs +++ b/crates/ruff_python_parser/src/parser/pattern.rs @@ -6,10 +6,10 @@ use ruff_python_ast::{ }; use ruff_text_size::{Ranged, TextSize}; -use crate::ParseErrorType; use crate::parser::progress::ParserProgress; use crate::parser::{Parser, RecoveryContextKind, SequenceMatchPatternParentheses, recovery}; use crate::token_set::TokenSet; +use crate::{ParseErrorType, UnsupportedSyntaxErrorKind}; use super::expression::ExpressionContext; @@ -23,6 +23,7 @@ const LITERAL_PATTERN_START_SET: TokenSet = TokenSet::new([ TokenKind::Float, TokenKind::Complex, TokenKind::Minus, // Unary minus + TokenKind::Plus, // Unary plus ]); /// The set of tokens that can start a pattern. @@ -125,28 +126,6 @@ impl Parser<'_> { /// /// See: fn parse_match_pattern(&mut self, allow_star_pattern: AllowStarPattern) -> Pattern { - if let Some(result) = - self.with_recursion(|parser| parser.parse_match_pattern_inner(allow_star_pattern)) - { - result - } else { - let range = self.missing_node_range(); - self.report_recursion_limit_exceeded(self.current_token_range()); - let invalid_node = Expr::Name(ast::ExprName { - range, - id: Name::empty(), - ctx: ExprContext::Invalid, - node_index: AtomicNodeIndex::NONE, - }); - Pattern::MatchValue(ast::PatternMatchValue { - range: invalid_node.range(), - value: Box::new(invalid_node), - node_index: AtomicNodeIndex::NONE, - }) - } - } - - fn parse_match_pattern_inner(&mut self, allow_star_pattern: AllowStarPattern) -> Pattern { let start = self.node_start(); // We don't yet know if it's an or pattern or an as pattern, so use whatever @@ -246,33 +225,37 @@ impl Parser<'_> { /// /// See: fn parse_match_pattern_lhs(&mut self, allow_star_pattern: AllowStarPattern) -> Pattern { - let start = self.node_start(); - - let mut lhs = match self.current_token_kind() { - TokenKind::Lbrace => Pattern::MatchMapping(self.parse_match_pattern_mapping()), - TokenKind::Star => { - let star_pattern = self.parse_match_pattern_star(); - if allow_star_pattern.is_no() { - self.add_error(ParseErrorType::InvalidStarPatternUsage, &star_pattern); + self.with_recursion(|parser| { + let start = parser.node_start(); + + let mut lhs = match parser.current_token_kind() { + TokenKind::Lbrace => Pattern::MatchMapping(parser.parse_match_pattern_mapping()), + TokenKind::Star => { + let star_pattern = parser.parse_match_pattern_star(); + if allow_star_pattern.is_no() { + parser.add_error(ParseErrorType::InvalidStarPatternUsage, &star_pattern); + } + Pattern::MatchStar(star_pattern) } - Pattern::MatchStar(star_pattern) - } - TokenKind::Lpar | TokenKind::Lsqb => self.parse_parenthesized_or_sequence_pattern(), - _ => self.parse_match_pattern_literal(), - }; + TokenKind::Lpar | TokenKind::Lsqb => { + parser.parse_parenthesized_or_sequence_pattern() + } + _ => parser.parse_match_pattern_literal(), + }; - if self.at(TokenKind::Lpar) { - lhs = Pattern::MatchClass(self.parse_match_pattern_class(lhs, start)); - } + if parser.at(TokenKind::Lpar) { + lhs = Pattern::MatchClass(parser.parse_match_pattern_class(lhs, start)); + } - if matches!( - self.current_token_kind(), - TokenKind::Plus | TokenKind::Minus - ) { - lhs = Pattern::MatchValue(self.parse_complex_literal_pattern(lhs, start)); - } + if matches!( + parser.current_token_kind(), + TokenKind::Plus | TokenKind::Minus + ) { + lhs = Pattern::MatchValue(parser.parse_complex_literal_pattern(lhs, start)); + } - lhs + lhs + }) } /// Parses a mapping pattern. @@ -581,7 +564,6 @@ impl Parser<'_> { }) } kind => { - // The `+` is only for better error recovery. if let Some(unary_arithmetic_op) = kind.as_unary_arithmetic_operator() { if matches!( self.peek(), @@ -592,12 +574,42 @@ impl Parser<'_> { ExpressionContext::default(), ); - if unary_expr.op.is_u_add() { + // test_err signed_pattern_non_literal_operand + // # parse_options: {"target-version": "3.15"} + // match value: + // case -1**2: ... + // case -1 .real: ... + // case -1[0]: ... + // case -1(): ... + // case {+1**2: _}: ... + + // Parse the full operand for error recovery, but only numeric literals + // are valid after a sign in a literal pattern. + if !unary_expr.operand.is_number_literal_expr() { self.add_error( ParseErrorType::OtherError( - "Unary '+' is not allowed as a literal pattern".to_string(), + "Expected a numeric literal after unary operator".to_string(), ), - &unary_expr, + unary_expr.operand.range(), + ); + } + + // test_ok unary_plus_py315 + // # parse_options: {"target-version": "3.15"} + // match foo: + // case +1: ... + // # this is also now valid inside more complicated patterns + // case {+1: 2}: ... + + // test_err unary_plus_py314 + // # parse_options: {"target-version": "3.14"} + // match foo: + // case +1: ... + // case {+1: 2}: ... + if unary_expr.op.is_u_add() { + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::UnaryPlusMatchPattern, + unary_expr.range, ); } diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index ce52181025..8bfcf891c6 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -291,12 +291,12 @@ fn is_pure_field_read(body: &[Stmt], backing: &Name) -> bool { #[derive(Debug)] pub(crate) struct PropertyRetarget { /// the name the author writes - pub(crate) public: Name, + public: Name, /// a read resolves here: the backing field when the getter only reads it (so /// the class sees storage at its own type), otherwise the property itself - pub(crate) read: Name, + read: Name, /// a write always resolves to the property, so a validating setter still runs - pub(crate) write: Name, + write: Name, } /// Retargets in-class accesses written under a property's public name. @@ -2298,16 +2298,11 @@ impl<'src> Parser<'src> { if self.at(TokenKind::Indent) { self.bump(TokenKind::Indent); let mut statements = Suite::new(); - if self - .with_recursion(|parser| { - parser.parse_list(RecoveryContextKind::BlockStatements, |p| { - p.parse_enum_item_into(&mut statements); - }); - }) - .is_none() - { - self.report_recursion_limit_exceeded(self.current_token_range()); - } + self.with_recursion(|parser| { + parser.parse_list(RecoveryContextKind::BlockStatements, |p| { + p.parse_enum_item_into(&mut statements); + }); + }); statements.shrink_to_fit(); self.expect(TokenKind::Dedent); return statements; @@ -6173,7 +6168,7 @@ impl<'src> Parser<'src> { /// annotated target are disjoint. Gated on basedpython mode rather than /// merely reported: the shape also shows up in `.py` error recovery, where /// consuming the suite would replace upstream's diagnostics. - pub(super) fn at_trailing_lambda_block(&mut self) -> bool { + fn at_trailing_lambda_block(&mut self) -> bool { self.options.is_basedpython && self.at(TokenKind::Colon) && self.peek2() == (TokenKind::Newline, TokenKind::Indent) @@ -7148,22 +7143,7 @@ impl<'src> Parser<'src> { // Although this statement is not a valid `async` statement, // we still parse it. Guard the recursive recovery path so // `async async async ...` cannot overflow the parser stack. - if let Some(stmt) = self.with_recursion(Self::parse_statement) { - stmt - } else { - let range = self.node_range(async_start); - self.add_error(ParseErrorType::RecursionLimitExceeded, range); - Stmt::Expr(ast::StmtExpr { - range, - value: Box::new(Expr::Name(ast::ExprName { - range, - id: Name::new_static("async"), - ctx: ExprContext::Invalid, - node_index: AtomicNodeIndex::NONE, - })), - node_index: AtomicNodeIndex::NONE, - }) - } + self.with_recursion(Self::parse_statement) } } } @@ -7579,7 +7559,7 @@ impl<'src> Parser<'src> { fn parse_block(&mut self) -> Suite { self.bump(TokenKind::Indent); - let statements = if let Some(statements) = self.with_recursion(|parser| { + let statements = self.with_recursion(|parser| { let snapshot = parser.stmt_scratch.snapshot(); parser.parse_list(RecoveryContextKind::BlockStatements, |parser| { let statement = parser.parse_statement(); @@ -7595,12 +7575,7 @@ impl<'src> Parser<'src> { }); parser.stmt_scratch.take_thin_vec(snapshot) - }) { - statements - } else { - self.report_recursion_limit_exceeded(self.current_token_range()); - Suite::new() - }; + }); self.expect(TokenKind::Dedent); diff --git a/crates/ruff_python_parser/src/parser/tests.rs b/crates/ruff_python_parser/src/parser/tests.rs index 9174ade0a9..063a8b4114 100644 --- a/crates/ruff_python_parser/src/parser/tests.rs +++ b/crates/ruff_python_parser/src/parser/tests.rs @@ -1,3 +1,5 @@ +use std::assert_matches; + use ruff_python_ast::helpers::{UseSiteVariance, use_site_variance_marker}; use ruff_python_ast::{ Expr, InterpolatedStringElement, IpyEscapeKind, ModModule, Number, Operator, Pattern, Stmt, @@ -9,6 +11,9 @@ use crate::{ Mode, ParseError, ParseErrorType, ParseOptions, Parsed, parse, parse_expression, parse_module, }; +// Keep recursive ASTs shallow enough for Windows's 1 MiB test-thread stacks. +const RECURSIVE_AST_TEST_DEPTH: usize = 1_000; + /// Parse a module in basedpython mode so tests for `.by`-only syntax don't /// trigger the `error_if_not_basedpython` parse-error gates. fn parse_basedpython_module(source: &str) -> Parsed { @@ -1234,6 +1239,7 @@ fn test_tstring_fstring_middle_fuzzer() { insta::assert_debug_snapshot!(error); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] fn test_anon_named_tuple_alias() { let source = "a = (name: str, age: int)\n"; @@ -1816,61 +1822,54 @@ fn fstring_conversion_after_ternary_is_not_force_unwrap() { } #[test] -fn recursion_limit_nested_parens() { +fn nested_parens_grow_stack() { let src = format!("{}1{}", "(".repeat(1_000), ")".repeat(1_000)); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + let parsed = stacker::grow(32 * 1024, || parse_module(&src)); + assert!(parsed.is_ok()); } #[test] -fn recursion_limit_nested_receiver_callables() { +fn deeply_nested_receiver_callables_grow_stack() { // basedpython `T.(...) -> R` opens a bracket and recurses through its - // return type, so it needs the same depth guard as a call or subscript - let src = format!("f: {}int{} -> str", "a.(".repeat(1_000), ")".repeat(1_000)); - let opts = ParseOptions::from(ruff_python_ast::PySourceType::BasedPython) - .with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + // return type, so a deep chain has to grow the stack rather than overflow it + let depth = RECURSIVE_AST_TEST_DEPTH; + let src = format!("f: {}int{} -> str", "a.(".repeat(depth), ")".repeat(depth)); + let parsed = stacker::grow(32 * 1024, || { + crate::parse_unchecked( + &src, + ParseOptions::from(ruff_python_ast::PySourceType::BasedPython), + ) + }); + // reaching here at all is the point: the parse completes instead of + // overflowing the stack + assert_matches!(parsed.syntax(), crate::Mod::Module(_)); } #[test] -fn recursion_limit_normal_python_unaffected() { - // 50 levels is well above what real-world Python ever produces and well - // below the default cap — the point is to confirm the default doesn't - // reject ordinary input. +fn normal_python_unaffected() { let src = format!("x = {}1{}", "(".repeat(50), ")".repeat(50)); parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_preserves_prior_statements() { - // Recursion-limit recovery is limited for now: we drain the rest of the file but keep the - // statements parsed before the overflowing statement. - // TODO: Recover at the next newline so the trailing statement is preserved too. +fn deep_nesting_preserves_surrounding_statements() { let src = format!( "before = 1\n{}1{}\nafter = 2\n", "(".repeat(1_000), ")".repeat(1_000), ); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let parsed = crate::parse_unchecked(&src, opts) - .try_into_module() - .unwrap(); + let parsed = parse_module(&src).unwrap(); - assert!(matches!( - parsed.errors().first().map(|error| &error.error), - Some(ParseErrorType::RecursionLimitExceeded) - )); - assert!(matches!(parsed.suite().first(), Some(Stmt::Assign(_)))); + assert_matches!(parsed.suite().first(), Some(Stmt::Assign(_))); + assert_matches!(parsed.suite().last(), Some(Stmt::Assign(_))); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_def_blocks() { - // Nested function definitions exercise instrumentation on - // `parse_statement` rather than `parse_lhs_expression`. Each level - // needs one more leading tab to make indentation valid. - let depth = 400; +fn nested_def_blocks_grow_stack() { + // Each nested function crosses the suite boundary where the parser rechecks the stack. + let depth = RECURSIVE_AST_TEST_DEPTH; let mut src = String::new(); for i in 0..depth { src.push_str(&"\t".repeat(i)); @@ -1878,37 +1877,33 @@ fn recursion_limit_nested_def_blocks() { } src.push_str(&"\t".repeat(depth)); src.push_str("pass\n"); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_lists() { +fn nested_lists_grow_stack() { let src = format!("{}1{}", "[".repeat(1_000), "]".repeat(1_000)); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_calls() { +fn nested_calls_grow_stack() { let src = format!("x = {}1{}", "f(".repeat(1_000), ")".repeat(1_000)); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_subscripts() { +fn nested_subscripts_grow_stack() { let src = format!("x = {}1{}", "a[".repeat(1_000), "]".repeat(1_000)); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_match_patterns() { +fn nested_match_patterns_grow_stack() { // Deeply parenthesised match patterns — exercises pattern-parsing // instrumentation in addition to statement / expression paths. let mut src = String::from("match x:\n case "); @@ -1920,17 +1915,29 @@ fn recursion_limit_nested_match_patterns() { src.push(')'); } src.push_str(": pass\n"); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_binary_paren_interplay() { +fn nested_invalid_mapping_pattern_keys_grow_stack() { + let depth = 512; + let src = format!( + "match value:\n case {}0{}:\n pass\n", + "{".repeat(depth), + ": 0}".repeat(depth) + ); + let parsed = crate::parse_unchecked(&src, ParseOptions::from(Mode::Module)); + assert!(!parsed.errors().is_empty()); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[test] +fn binary_paren_interplay_grows_stack() { // `1+(1+(1+(1+...)))` — each level alternates a binary operator and a // parenthesised sub-expression, exactly like the pattern described in // the tracking issue. - let depth = 2_000; + let depth = RECURSIVE_AST_TEST_DEPTH; let mut src = String::new(); for _ in 0..depth { src.push_str("1+("); @@ -1939,114 +1946,74 @@ fn recursion_limit_binary_paren_interplay() { for _ in 0..depth { src.push(')'); } - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); -} - -#[test] -fn recursion_limit_first_error_is_recursion_not_noise() { - // When the limit is hit the outer parser frames will emit secondary - // errors as they unwind. Callers read the first error via `into_result` - // / `Parsed::errors()`, so `RecursionLimitExceeded` must come first, and - // the drain-to-EOF after reporting the recursion limit should keep the total count - // small rather than producing one noisy error per unwound frame. - let src = format!("{}1{}", "(".repeat(2_000), ")".repeat(2_000)); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(50); - let parsed = crate::parse_unchecked(&src, opts); - let errors = parsed.errors(); - let first = errors.first().expect("expected at least one error"); - assert!(matches!( - first.error, - ParseErrorType::RecursionLimitExceeded - )); - // Exactly one `RecursionLimitExceeded` — guards against a regression - // where the unwind loops and re-triggers the limit check. - let recursion_errors = errors - .iter() - .filter(|e| matches!(e.error, ParseErrorType::RecursionLimitExceeded)) - .count(); - assert_eq!(recursion_errors, 1); - // Small, bounded tail of follow-up errors from the unwinding frames. - // Today this is 0; the generous cap is a regression gate, not a spec. - assert!( - errors.len() <= 8, - "expected a small number of errors, got {}: {errors:?}", - errors.len(), - ); -} - -#[test] -fn recursion_limit_default_set() { - let opts = ParseOptions::from(Mode::Module); - // Guards against someone accidentally unsetting the default. Real-world - // Python never approaches this depth, and the value must stay within the - // threading stack's capacity — see the const's docs in `options.rs`. - assert!(opts.max_recursion_depth() >= 200); - assert!(opts.max_recursion_depth() <= 2000); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_right_assoc_pow_chain() { +fn right_assoc_pow_chain_grows_stack() { // `1**1**1**...**1` — `**` is right-associative, so the right operand // is parsed by a recursive `parse_binary_expression_or_higher` call // *without* any intervening parentheses or atom nesting. This exercises // the binary-expression recursion path directly, unlike the // `1+(1+(...))` interplay test which recurses through parenthesised // atoms. - let depth = 2_000; + let depth = RECURSIVE_AST_TEST_DEPTH; let mut src = String::with_capacity(depth * 3 + 1); for _ in 0..depth { src.push_str("1**"); } src.push('1'); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!( - matches!(err.error, ParseErrorType::RecursionLimitExceeded), - "expected RecursionLimitExceeded, got {:?}", - err.error - ); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_ternary_else_chain() { +fn ternary_else_chain_grows_stack() { // `1 if 1 else 1 if 1 else ...` — the `else` operand recurses at the // conditional layer (`parse_if_expression` -> `orelse`), which is not - // covered by the `parse_lhs_expression` guard. - let depth = 2_000; + // covered by the binary-expression guard. + let depth = RECURSIVE_AST_TEST_DEPTH; let mut src = String::with_capacity(depth * 12 + 1); for _ in 0..depth { src.push_str("1 if 1 else "); } src.push('1'); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!( - matches!(err.error, ParseErrorType::RecursionLimitExceeded), - "expected RecursionLimitExceeded, got {:?}", - err.error - ); + parse_module(&src).unwrap(); } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] -fn recursion_limit_nested_lambda_chain() { +fn nested_lambda_chain_grows_stack() { // `lambda: lambda: lambda: ...` — the lambda body recurses at the // conditional layer (`parse_lambda_expr` -> body), bypassing the - // `parse_lhs_expression` guard entirely. - let depth = 2_000; + // binary-expression guard entirely. + let depth = RECURSIVE_AST_TEST_DEPTH; let mut src = String::from("x = "); for _ in 0..depth { src.push_str("lambda: "); } src.push('1'); - let opts = ParseOptions::from(Mode::Module).with_max_recursion_depth(100); - let err = parse(&src, opts).unwrap_err(); - assert!( - matches!(err.error, ParseErrorType::RecursionLimitExceeded), - "expected RecursionLimitExceeded, got {:?}", - err.error - ); + parse_module(&src).unwrap(); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[test] +fn invalid_async_chain_grows_stack() { + let source = format!("{}x = 1\n", "async ".repeat(5_000)); + let parsed = crate::parse_unchecked(&source, ParseOptions::from(Mode::Module)); + assert!(!parsed.errors().is_empty()); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[test] +fn nested_unary_chains_grow_stack() { + let depth = 300; + let source = format!("{}1\n", "-~+".repeat(depth)); + parse_module(&source).unwrap(); + + let source = format!("{}True\n", "not ".repeat(depth)); + parse_module(&source).unwrap(); } #[test] diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index 744e72de33..49fd6f1de5 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -242,10 +242,19 @@ impl SemanticSyntaxChecker { } } Stmt::ClassDef(ast::StmtClassDef { - type_params: Some(type_params), + type_params, + arguments, .. - }) - | Stmt::TypeAlias(ast::StmtTypeAlias { + }) => { + if let Some(type_params) = type_params { + Self::duplicate_type_parameter_name(type_params, ctx); + Self::type_parameter_default_order(type_params, ctx); + } + if let Some(arguments) = arguments { + Self::duplicate_keyword_args(arguments, ctx); + } + } + Stmt::TypeAlias(ast::StmtTypeAlias { type_params: Some(type_params), .. }) => { @@ -351,7 +360,13 @@ impl SemanticSyntaxChecker { if !ctx.in_module_scope() { for name in names { - if !ctx.has_nonlocal_binding(name) { + if ctx.is_bound_parameter(name) { + Self::add_error( + ctx, + SemanticSyntaxErrorKind::NonlocalParameter(name.to_string()), + name.range, + ); + } else if !ctx.has_nonlocal_binding(name) { Self::add_error( ctx, SemanticSyntaxErrorKind::NonlocalWithoutBinding(name.to_string()), @@ -882,6 +897,40 @@ impl SemanticSyntaxChecker { visitor.visit_pattern_against_subject(pattern, ctx.is_basedpython()); } + fn duplicate_keyword_args(args: &ast::Arguments, ctx: &Ctx) { + if args.keywords.len() < 2 { + return; + } + + let mut all_arg_names = + FxHashSet::with_capacity_and_hasher(args.keywords.len(), FxBuildHasher); + + for (ident, range) in args + .keywords + .iter() + .filter_map(|keyword| keyword.arg.as_ref().map(|arg| (arg, keyword.range))) + { + if !all_arg_names.insert(ident.as_str()) { + // test_err duplicate_keyword_args + // def foo(x): ... + // foo(x=1, x=2) + // def baz(x, y, z): ... + // baz(x, y=1, z=3, y=4) + + // test_ok non_duplicate_keyword_args + // def foo(x): ... + // foo(x=1) + // def bar(x, y, z): ... + // foo(x="a", y=1, z=True) + Self::add_error( + ctx, + SemanticSyntaxErrorKind::DuplicateKeywordArgument(ident.to_string()), + range, + ); + } + } + } + fn irrefutable_match_case(stmt: &ast::StmtMatch, ctx: &Ctx) { // basedpython: a bare `case A:` is only a capture when the name is not an // enum member of the subject, which is a question for the type checker — @@ -1139,6 +1188,9 @@ impl SemanticSyntaxChecker { } Self::duplicate_parameter_name(parameters, ctx); } + Expr::Call(ast::ExprCall { arguments, .. }) => { + Self::duplicate_keyword_args(arguments, ctx); + } _ => {} } } @@ -1509,6 +1561,9 @@ impl Display for SemanticSyntaxError { SemanticSyntaxErrorKind::NonlocalDeclarationAtModuleLevel => { write!(f, "nonlocal declaration not allowed at module level") } + SemanticSyntaxErrorKind::DuplicateKeywordArgument(name) => { + write!(f, "Duplicate keyword argument `{name}`") + } SemanticSyntaxErrorKind::NonlocalAndGlobal(name) => { write!(f, "name `{name}` is nonlocal and global") } @@ -1562,6 +1617,12 @@ impl Display for SemanticSyntaxError { "name `{name}` cannot refer to a parameter and a global variable" ) } + SemanticSyntaxErrorKind::NonlocalParameter(name) => { + write!( + f, + "name `{name}` cannot refer to a parameter and a nonlocal variable" + ) + } SemanticSyntaxErrorKind::DifferentMatchPatternBindings => { write!(f, "alternative patterns bind different names") } @@ -1998,6 +2059,17 @@ pub enum SemanticSyntaxErrorKind { /// ``` DuplicateParameter(String), + /// Represents duplicated keyword arguments in a function call or class definition. + /// + /// ## Examples + /// + /// ```python + /// def f(x): ... + /// f(x=1, x=2) + /// class C(metaclass=type, metaclass=type): ... + /// ``` + DuplicateKeywordArgument(String), + /// Represents a nonlocal declaration at module level NonlocalDeclarationAtModuleLevel, @@ -2039,6 +2111,13 @@ pub enum SemanticSyntaxErrorKind { /// ambiguity and will result in a `SyntaxError`. GlobalParameter(String), + /// Represents a function parameter that is also declared as `nonlocal`. + /// + /// Declaring a parameter as `nonlocal` is invalid, since parameters are already + /// bound in a local scope of the function. using `nonlocal` on them introduces + /// ambiguity and will result in a `SyntaxError`. + NonlocalParameter(String), + /// Represents the use of alternative patterns in a `match` statement that bind different names. /// /// Python requires all alternatives in an OR pattern (`|`) to bind the same set of names. diff --git a/crates/ruff_python_parser/src/string.rs b/crates/ruff_python_parser/src/string.rs index 8855c0e9a0..bd7e9961ab 100644 --- a/crates/ruff_python_parser/src/string.rs +++ b/crates/ruff_python_parser/src/string.rs @@ -532,10 +532,7 @@ mod tests { use ruff_python_ast::Suite; use crate::error::LexicalErrorType; - use crate::{ - InterpolatedStringErrorType, Mode, ParseError, ParseErrorType, ParseOptions, Parsed, parse, - parse_module, - }; + use crate::{InterpolatedStringErrorType, ParseError, ParseErrorType, Parsed, parse_module}; const WINDOWS_EOL: &str = "\r\n"; const MAC_EOL: &str = "\r"; @@ -545,17 +542,6 @@ mod tests { parse_module(source).map(Parsed::into_suite) } - fn parse_suite_with_recursion_limit( - source: &str, - max_recursion_depth: u16, - ) -> Result { - parse( - source, - ParseOptions::from(Mode::Module).with_max_recursion_depth(max_recursion_depth), - ) - .map(|parsed| parsed.try_into_module().unwrap().into_suite()) - } - fn nested_format_spec(prefix: char, depth: usize) -> String { let mut replacement_field = String::from("{spec}"); for _ in 0..depth { @@ -602,11 +588,8 @@ mod tests { } #[test] - fn test_parse_fstring_nested_spec_recursion_limit() { - assert!(parse_suite_with_recursion_limit(r#"f"{foo:{spec}}""#, 8).is_ok()); - - let err = parse_suite_with_recursion_limit(&nested_format_spec('f', 200), 8).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + fn parse_fstring_nested_spec_grows_stack() { + assert!(parse_suite(&nested_format_spec('f', 200)).is_ok()); } #[test] @@ -722,11 +705,8 @@ mod tests { } #[test] - fn test_parse_tstring_nested_spec_recursion_limit() { - assert!(parse_suite_with_recursion_limit(r#"t"{foo:{spec}}""#, 8).is_ok()); - - let err = parse_suite_with_recursion_limit(&nested_format_spec('t', 200), 8).unwrap_err(); - assert!(matches!(err.error, ParseErrorType::RecursionLimitExceeded)); + fn parse_tstring_nested_spec_grows_stack() { + assert!(parse_suite(&nested_format_spec('t', 200)).is_ok()); } #[test] diff --git a/crates/ruff_python_parser/src/token_source.rs b/crates/ruff_python_parser/src/token_source.rs index 4557b283e4..b1b9125670 100644 --- a/crates/ruff_python_parser/src/token_source.rs +++ b/crates/ruff_python_parser/src/token_source.rs @@ -47,12 +47,6 @@ impl<'src> TokenSource<'src> { self.lexer.current_range() } - /// Returns the current parenthesis, bracket, and brace nesting level. - #[inline] - pub(crate) const fn nesting(&self) -> u32 { - self.lexer.nesting() - } - /// Returns the flags for the current token. pub(crate) const fn current_flags(&self) -> TokenFlags { self.lexer.current_flags() diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_keyword_args.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_keyword_args.py.snap new file mode 100644 index 0000000000..56886d71c6 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_keyword_args.py.snap @@ -0,0 +1,360 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/duplicate_keyword_args.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..74, + body: [ + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 0..15, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("foo"), + range: 4..7, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 7..10, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 8..9, + node_index: NodeIndex(None), + parameter: Parameter { + range: 8..9, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 8..9, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + }, + returns: None, + raises: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 12..15, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 12..15, + }, + ), + }, + ), + ], + is_trailing_lambda: false, + is_asserts_return: false, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 16..29, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 16..29, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 16..19, + id: Name("foo"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 19..29, + node_index: NodeIndex(None), + args: [], + keywords: [ + Keyword { + range: 20..23, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 20..21, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 22..23, + value: Int( + 1, + ), + }, + ), + }, + Keyword { + range: 25..28, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 25..26, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 27..28, + value: Int( + 2, + ), + }, + ), + }, + ], + }, + cast_kind: None, + is_string_tag: false, + }, + ), + }, + ), + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 30..51, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("baz"), + range: 34..37, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 37..46, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 38..39, + node_index: NodeIndex(None), + parameter: Parameter { + range: 38..39, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 38..39, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ParameterWithDefault { + range: 41..42, + node_index: NodeIndex(None), + parameter: Parameter { + range: 41..42, + node_index: NodeIndex(None), + name: Identifier { + id: Name("y"), + range: 41..42, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ParameterWithDefault { + range: 44..45, + node_index: NodeIndex(None), + parameter: Parameter { + range: 44..45, + node_index: NodeIndex(None), + name: Identifier { + id: Name("z"), + range: 44..45, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + }, + returns: None, + raises: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 48..51, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 48..51, + }, + ), + }, + ), + ], + is_trailing_lambda: false, + is_asserts_return: false, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 52..73, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 52..73, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..55, + id: Name("baz"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 55..73, + node_index: NodeIndex(None), + args: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 56..57, + id: Name("x"), + ctx: Load, + }, + ), + ], + keywords: [ + Keyword { + range: 59..62, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("y"), + range: 59..60, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 61..62, + value: Int( + 1, + ), + }, + ), + }, + Keyword { + range: 64..67, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("z"), + range: 64..65, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 66..67, + value: Int( + 3, + ), + }, + ), + }, + Keyword { + range: 69..72, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("y"), + range: 69..70, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 71..72, + value: Int( + 4, + ), + }, + ), + }, + ], + }, + cast_kind: None, + is_string_tag: false, + }, + ), + }, + ), + ], + }, +) +``` +## Semantic Syntax Errors + + | +1 | def foo(x): ... +2 | foo(x=1, x=2) + | ^^^ Syntax Error: Duplicate keyword argument `x` +3 | def baz(x, y, z): ... +4 | baz(x, y=1, z=3, y=4) + | + + + | +2 | foo(x=1, x=2) +3 | def baz(x, y, z): ... +4 | baz(x, y=1, z=3, y=4) + | ^^^ Syntax Error: Duplicate keyword argument `y` diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__duplicate_keyword_arguments.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__duplicate_keyword_arguments.py.snap index 6ff281e558..e08149c25f 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__duplicate_keyword_arguments.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__duplicate_keyword_arguments.py.snap @@ -148,13 +148,13 @@ Module( }, ) ``` -## Errors +## Semantic Syntax Errors | 1 | foo(a=1, b=2, c=3, b=4, a=5) - | ^^^ Syntax Error: Duplicate keyword argument "b" + | ^^^ Syntax Error: Duplicate keyword argument `b` | 1 | foo(a=1, b=2, c=3, b=4, a=5) - | ^^^ Syntax Error: Duplicate keyword argument "a" + | ^^^ Syntax Error: Duplicate keyword argument `a` diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_tstring_and_bytes_literals.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_tstring_and_bytes_literals.py.snap new file mode 100644 index 0000000000..517a797452 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_tstring_and_bytes_literals.py.snap @@ -0,0 +1,460 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/mixed_tstring_and_bytes_literals.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..176, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 0..18, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 0..18, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 0..8, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 9..18, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 19..37, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 19..37, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 19..27, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 28..37, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 38..57, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 38..57, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 38..46, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 47..57, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 58..84, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 58..84, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 58..65, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 66..75, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 76..84, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 85..111, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 85..111, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 85..93, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 94..102, + node_index: NodeIndex(None), + value: "second", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 103..111, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 112..147, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 112..147, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 112..119, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 120..129, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 130..137, + node_index: NodeIndex(None), + value: "third", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 138..147, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 148..175, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 148..175, + value: FStringValue { + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 148..156, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + Literal( + StringLiteral { + range: 157..166, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + FString( + FString { + range: 167..175, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 169..174, + node_index: NodeIndex(None), + value: "third", + }, + ), + ], + flags: FStringFlags { + quote_style: Single, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + ], + }, +) +``` +## Errors + + | +1 | t'first' b'second' + | ^^^^^^^^^^^^^^^^^^ Syntax Error: Cannot mix t-string literals with string or bytes literals +2 | b'first' t'second' +3 | t'first' br'second' + | + + + | +1 | t'first' b'second' +2 | b'first' t'second' + | ^^^^^^^^^^^^^^^^^^ Syntax Error: Cannot mix t-string literals with string or bytes literals +3 | t'first' br'second' +4 | 'first' b'second' t'third' + | + + + | +1 | t'first' b'second' +2 | b'first' t'second' +3 | t'first' br'second' + | ^^^^^^^^^^^^^^^^^^^ Syntax Error: Cannot mix t-string literals with string or bytes literals +4 | 'first' b'second' t'third' +5 | b'first' 'second' t'third' + | + + + | +2 | b'first' t'second' +3 | t'first' br'second' +4 | 'first' b'second' t'third' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Bytes literal cannot be mixed with non-bytes literals +5 | b'first' 'second' t'third' +6 | 'first' t'second' 'third' b'fourth' + | + + + | +3 | t'first' br'second' +4 | 'first' b'second' t'third' +5 | b'first' 'second' t'third' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Bytes literal cannot be mixed with non-bytes literals +6 | 'first' t'second' 'third' b'fourth' +7 | b'first' t'second' f'third' + | + + + | +4 | 'first' b'second' t'third' +5 | b'first' 'second' t'third' +6 | 'first' t'second' 'third' b'fourth' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Cannot mix t-string literals with string or bytes literals +7 | b'first' t'second' f'third' + | + + + | +5 | b'first' 'second' t'third' +6 | 'first' t'second' 'third' b'fourth' +7 | b'first' t'second' f'third' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Syntax Error: Cannot mix t-string literals with string or bytes literals diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@signed_pattern_non_literal_operand.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@signed_pattern_non_literal_operand.py.snap new file mode 100644 index 0000000000..02c27bf555 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@signed_pattern_non_literal_operand.py.snap @@ -0,0 +1,373 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/signed_pattern_non_literal_operand.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..164, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 44..163, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..55, + id: Name("value"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 61..76, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 66..71, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 66..71, + op: USub, + operand: BinOp( + ExprBinOp { + node_index: NodeIndex(None), + range: 67..71, + left: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 67..68, + value: Int( + 1, + ), + }, + ), + op: Pow, + right: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 70..71, + value: Int( + 2, + ), + }, + ), + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 73..76, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 73..76, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 81..99, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 86..94, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 86..94, + op: USub, + operand: Attribute( + ExprAttribute { + node_index: NodeIndex(None), + range: 87..94, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 87..88, + value: Int( + 1, + ), + }, + ), + attr: Identifier { + id: Name("real"), + range: 90..94, + node_index: NodeIndex(None), + }, + ctx: Load, + optional: false, + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 96..99, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 96..99, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 104..119, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 109..114, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 109..114, + op: USub, + operand: Subscript( + ExprSubscript { + node_index: NodeIndex(None), + range: 110..114, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 110..111, + value: Int( + 1, + ), + }, + ), + slice: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 112..113, + value: Int( + 0, + ), + }, + ), + ctx: Load, + is_typeof: false, + is_type_decoration: false, + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 116..119, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 116..119, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 124..138, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 129..133, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 129..133, + op: USub, + operand: Call( + ExprCall { + node_index: NodeIndex(None), + range: 130..133, + func: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 130..131, + value: Int( + 1, + ), + }, + ), + arguments: Arguments { + range: 131..133, + node_index: NodeIndex(None), + args: [], + keywords: [], + }, + cast_kind: None, + is_string_tag: false, + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 135..138, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 135..138, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 143..163, + node_index: NodeIndex(None), + pattern: MatchMapping( + PatternMatchMapping { + node_index: NodeIndex(None), + range: 148..158, + keys: [ + UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 149..154, + op: UAdd, + operand: BinOp( + ExprBinOp { + node_index: NodeIndex(None), + range: 150..154, + left: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 150..151, + value: Int( + 1, + ), + }, + ), + op: Pow, + right: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 153..154, + value: Int( + 2, + ), + }, + ), + }, + ), + }, + ), + ], + patterns: [ + MatchAs( + PatternMatchAs { + node_index: NodeIndex(None), + range: 156..157, + pattern: None, + name: None, + }, + ), + ], + rest: None, + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 160..163, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 160..163, + }, + ), + }, + ), + ], + }, + ], + }, + ), + ], + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | match value: +3 | case -1**2: ... + | ^^^^ Syntax Error: Expected a numeric literal after unary operator +4 | case -1 .real: ... +5 | case -1[0]: ... + | + + + | +2 | match value: +3 | case -1**2: ... +4 | case -1 .real: ... + | ^^^^^^^ Syntax Error: Expected a numeric literal after unary operator +5 | case -1[0]: ... +6 | case -1(): ... + | + + + | +3 | case -1**2: ... +4 | case -1 .real: ... +5 | case -1[0]: ... + | ^^^^ Syntax Error: Expected a numeric literal after unary operator +6 | case -1(): ... +7 | case {+1**2: _}: ... + | + + + | +4 | case -1 .real: ... +5 | case -1[0]: ... +6 | case -1(): ... + | ^^^ Syntax Error: Expected a numeric literal after unary operator +7 | case {+1**2: _}: ... + | + + + | +5 | case -1[0]: ... +6 | case -1(): ... +7 | case {+1**2: _}: ... + | ^^^^ Syntax Error: Expected a numeric literal after unary operator diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__unary_add_usage.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__unary_add_usage.py.snap index 2fcc8cad93..e79911e767 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__unary_add_usage.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__unary_add_usage.py.snap @@ -8,37 +8,37 @@ input_file: crates/ruff_python_parser/resources/invalid/statements/match/unary_a Module( ModModule { node_index: NodeIndex(None), - range: 0..269, + range: 0..246, body: [ Match( StmtMatch { node_index: NodeIndex(None), - range: 74..268, + range: 51..245, subject: Name( ExprName { node_index: NodeIndex(None), - range: 80..87, + range: 57..64, id: Name("subject"), ctx: Load, }, ), cases: [ MatchCase { - range: 93..114, + range: 70..91, node_index: NodeIndex(None), pattern: MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 98..100, + range: 75..77, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 98..100, + range: 75..77, op: UAdd, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 99..100, + range: 76..77, value: Int( 1, ), @@ -53,27 +53,27 @@ Module( Pass( StmtPass { node_index: NodeIndex(None), - range: 110..114, + range: 87..91, }, ), ], }, MatchCase { - range: 119..149, + range: 96..126, node_index: NodeIndex(None), pattern: MatchOr( PatternMatchOr { node_index: NodeIndex(None), - range: 124..135, + range: 101..112, patterns: [ MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 124..125, + range: 101..102, value: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 124..125, + range: 101..102, value: Int( 1, ), @@ -84,16 +84,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 128..130, + range: 105..107, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 128..130, + range: 105..107, op: UAdd, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 129..130, + range: 106..107, value: Int( 2, ), @@ -106,16 +106,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 133..135, + range: 110..112, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 133..135, + range: 110..112, op: USub, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 134..135, + range: 111..112, value: Int( 3, ), @@ -133,27 +133,27 @@ Module( Pass( StmtPass { node_index: NodeIndex(None), - range: 145..149, + range: 122..126, }, ), ], }, MatchCase { - range: 154..184, + range: 131..161, node_index: NodeIndex(None), pattern: MatchSequence( PatternMatchSequence { node_index: NodeIndex(None), - range: 159..170, + range: 136..147, patterns: [ MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 160..161, + range: 137..138, value: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 160..161, + range: 137..138, value: Int( 1, ), @@ -164,16 +164,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 163..165, + range: 140..142, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 163..165, + range: 140..142, op: UAdd, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 164..165, + range: 141..142, value: Int( 2, ), @@ -186,16 +186,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 167..169, + range: 144..146, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 167..169, + range: 144..146, op: USub, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 168..169, + range: 145..146, value: Int( 3, ), @@ -213,52 +213,52 @@ Module( Pass( StmtPass { node_index: NodeIndex(None), - range: 180..184, + range: 157..161, }, ), ], }, MatchCase { - range: 189..223, + range: 166..200, node_index: NodeIndex(None), pattern: MatchClass( PatternMatchClass { node_index: NodeIndex(None), - range: 194..209, + range: 171..186, cls: Name( ExprName { node_index: NodeIndex(None), - range: 194..197, + range: 171..174, id: Name("Foo"), ctx: Load, }, ), arguments: PatternArguments { - range: 197..209, + range: 174..186, node_index: NodeIndex(None), patterns: [], keywords: [ PatternKeyword { - range: 198..202, + range: 175..179, node_index: NodeIndex(None), attr: Identifier { id: Name("x"), - range: 198..199, + range: 175..176, node_index: NodeIndex(None), }, pattern: MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 200..202, + range: 177..179, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 200..202, + range: 177..179, op: UAdd, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 201..202, + range: 178..179, value: Int( 1, ), @@ -270,26 +270,26 @@ Module( ), }, PatternKeyword { - range: 204..208, + range: 181..185, node_index: NodeIndex(None), attr: Identifier { id: Name("y"), - range: 204..205, + range: 181..182, node_index: NodeIndex(None), }, pattern: MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 206..208, + range: 183..185, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 206..208, + range: 183..185, op: USub, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 207..208, + range: 184..185, value: Int( 2, ), @@ -309,30 +309,30 @@ Module( Pass( StmtPass { node_index: NodeIndex(None), - range: 219..223, + range: 196..200, }, ), ], }, MatchCase { - range: 228..268, + range: 205..245, node_index: NodeIndex(None), pattern: MatchMapping( PatternMatchMapping { node_index: NodeIndex(None), - range: 233..254, + range: 210..231, keys: [ BooleanLiteral( ExprBooleanLiteral { node_index: NodeIndex(None), - range: 234..238, + range: 211..215, value: true, }, ), BooleanLiteral( ExprBooleanLiteral { node_index: NodeIndex(None), - range: 244..249, + range: 221..226, value: false, }, ), @@ -341,16 +341,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 240..242, + range: 217..219, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 240..242, + range: 217..219, op: UAdd, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 241..242, + range: 218..219, value: Int( 1, ), @@ -363,16 +363,16 @@ Module( MatchValue( PatternMatchValue { node_index: NodeIndex(None), - range: 251..253, + range: 228..230, value: UnaryOp( ExprUnaryOp { node_index: NodeIndex(None), - range: 251..253, + range: 228..230, op: USub, operand: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 252..253, + range: 229..230, value: Int( 2, ), @@ -391,7 +391,7 @@ Module( Pass( StmtPass { node_index: NodeIndex(None), - range: 264..268, + range: 241..245, }, ), ], @@ -403,13 +403,13 @@ Module( }, ) ``` -## Errors +## Unsupported Syntax Errors | -1 | # Unary addition isn't allowed but we parse it for better error recovery. +1 | # Unary addition isn't allowed before Python 3.15. 2 | match subject: 3 | case +1: - | ^^ Syntax Error: Unary '+' is not allowed as a literal pattern + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) 4 | pass 5 | case 1 | +2 | -3: | @@ -419,7 +419,7 @@ Module( 3 | case +1: 4 | pass 5 | case 1 | +2 | -3: - | ^^ Syntax Error: Unary '+' is not allowed as a literal pattern + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) 6 | pass 7 | case [1, +2, -3]: | @@ -429,7 +429,7 @@ Module( 5 | case 1 | +2 | -3: 6 | pass 7 | case [1, +2, -3]: - | ^^ Syntax Error: Unary '+' is not allowed as a literal pattern + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) 8 | pass 9 | case Foo(x=+1, y=-2): | @@ -439,7 +439,7 @@ Module( 7 | case [1, +2, -3]: 8 | pass 9 | case Foo(x=+1, y=-2): - | ^^ Syntax Error: Unary '+' is not allowed as a literal pattern + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) 10 | pass 11 | case {True: +1, False: -2}: | @@ -449,6 +449,6 @@ Module( 9 | case Foo(x=+1, y=-2): 10 | pass 11 | case {True: +1, False: -2}: - | ^^ Syntax Error: Unary '+' is not allowed as a literal pattern + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) 12 | pass | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unary_plus_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unary_plus_py314.py.snap new file mode 100644 index 0000000000..ebb1a72030 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unary_plus_py314.py.snap @@ -0,0 +1,150 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/unary_plus_py314.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..94, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 44..93, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..53, + id: Name("foo"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 59..71, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 64..66, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 64..66, + op: UAdd, + operand: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 65..66, + value: Int( + 1, + ), + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 68..71, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 68..71, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 76..93, + node_index: NodeIndex(None), + pattern: MatchMapping( + PatternMatchMapping { + node_index: NodeIndex(None), + range: 81..88, + keys: [ + UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 82..84, + op: UAdd, + operand: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 83..84, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + patterns: [ + MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 86..87, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 86..87, + value: Int( + 2, + ), + }, + ), + }, + ), + ], + rest: None, + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 90..93, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 90..93, + }, + ), + }, + ), + ], + }, + ], + }, + ), + ], + }, +) +``` +## Unsupported Syntax Errors + + | +1 | # parse_options: {"target-version": "3.14"} +2 | match foo: +3 | case +1: ... + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) +4 | case {+1: 2}: ... + | + + + | +2 | match foo: +3 | case +1: ... +4 | case {+1: 2}: ... + | ^^ Syntax Error: Unary '+' is not allowed in a literal pattern on Python 3.14 (syntax was added in Python 3.15) diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_duplicate_keyword_args.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_duplicate_keyword_args.py.snap new file mode 100644 index 0000000000..1dec00c1b4 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_duplicate_keyword_args.py.snap @@ -0,0 +1,324 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/non_duplicate_keyword_args.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..71, + body: [ + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 0..15, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("foo"), + range: 4..7, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 7..10, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 8..9, + node_index: NodeIndex(None), + parameter: Parameter { + range: 8..9, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 8..9, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + }, + returns: None, + raises: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 12..15, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 12..15, + }, + ), + }, + ), + ], + is_trailing_lambda: false, + is_asserts_return: false, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 16..24, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 16..24, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 16..19, + id: Name("foo"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 19..24, + node_index: NodeIndex(None), + args: [], + keywords: [ + Keyword { + range: 20..23, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 20..21, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 22..23, + value: Int( + 1, + ), + }, + ), + }, + ], + }, + cast_kind: None, + is_string_tag: false, + }, + ), + }, + ), + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 25..46, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("bar"), + range: 29..32, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 32..41, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 33..34, + node_index: NodeIndex(None), + parameter: Parameter { + range: 33..34, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 33..34, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ParameterWithDefault { + range: 36..37, + node_index: NodeIndex(None), + parameter: Parameter { + range: 36..37, + node_index: NodeIndex(None), + name: Identifier { + id: Name("y"), + range: 36..37, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ParameterWithDefault { + range: 39..40, + node_index: NodeIndex(None), + parameter: Parameter { + range: 39..40, + node_index: NodeIndex(None), + name: Identifier { + id: Name("z"), + range: 39..40, + node_index: NodeIndex(None), + }, + pattern: None, + annotation: None, + is_context: false, + is_some: false, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + }, + returns: None, + raises: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 43..46, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 43..46, + }, + ), + }, + ), + ], + is_trailing_lambda: false, + is_asserts_return: false, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 47..70, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 47..70, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 47..50, + id: Name("foo"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 50..70, + node_index: NodeIndex(None), + args: [], + keywords: [ + Keyword { + range: 51..56, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 51..52, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 53..56, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 53..56, + node_index: NodeIndex(None), + value: "a", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + }, + }, + ), + }, + Keyword { + range: 58..61, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("y"), + range: 58..59, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 60..61, + value: Int( + 1, + ), + }, + ), + }, + Keyword { + range: 63..69, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("z"), + range: 63..64, + node_index: NodeIndex(None), + }, + ), + key: Bare, + value: BooleanLiteral( + ExprBooleanLiteral { + node_index: NodeIndex(None), + range: 65..69, + value: true, + }, + ), + }, + ], + }, + cast_kind: None, + is_string_tag: false, + }, + ), + }, + ), + ], + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@unary_plus_py315.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unary_plus_py315.py.snap new file mode 100644 index 0000000000..3454b5e0ad --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unary_plus_py315.py.snap @@ -0,0 +1,134 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/unary_plus_py315.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..156, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 44..155, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..53, + id: Name("foo"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 59..71, + node_index: NodeIndex(None), + pattern: MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 64..66, + value: UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 64..66, + op: UAdd, + operand: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 65..66, + value: Int( + 1, + ), + }, + ), + }, + ), + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 68..71, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 68..71, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 138..155, + node_index: NodeIndex(None), + pattern: MatchMapping( + PatternMatchMapping { + node_index: NodeIndex(None), + range: 143..150, + keys: [ + UnaryOp( + ExprUnaryOp { + node_index: NodeIndex(None), + range: 144..146, + op: UAdd, + operand: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 145..146, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + patterns: [ + MatchValue( + PatternMatchValue { + node_index: NodeIndex(None), + range: 148..149, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 148..149, + value: Int( + 2, + ), + }, + ), + }, + ), + ], + rest: None, + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 152..155, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 152..155, + }, + ), + }, + ), + ], + }, + ], + }, + ), + ], + }, +) +``` diff --git a/crates/ruff_python_semantic/Cargo.toml b/crates/ruff_python_semantic/Cargo.toml index c73d7ea449..d589af0a67 100644 --- a/crates/ruff_python_semantic/Cargo.toml +++ b/crates/ruff_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_semantic" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_semantic/README.md b/crates/ruff_python_semantic/README.md index b0275afaa4..460d1ce581 100644 --- a/crates/ruff_python_semantic/README.md +++ b/crates/ruff_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_semantic). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index d53e7703a5..818b0afcfb 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -2557,7 +2557,7 @@ impl<'a> SemanticModel<'a> { } /// Return `true` if the model is in a basedpython file (i.e., a `.by` or `.byi` file). - pub const fn in_basedpython_file(&self) -> bool { + const fn in_basedpython_file(&self) -> bool { self.flags.intersects(SemanticModelFlags::BASEDPYTHON_FILE) } diff --git a/crates/ruff_python_stdlib/Cargo.toml b/crates/ruff_python_stdlib/Cargo.toml index c3e59fa976..a6d0646643 100644 --- a/crates/ruff_python_stdlib/Cargo.toml +++ b/crates/ruff_python_stdlib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_stdlib" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_stdlib/README.md b/crates/ruff_python_stdlib/README.md index d38692821e..b3883f2352 100644 --- a/crates/ruff_python_stdlib/README.md +++ b/crates/ruff_python_stdlib/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_stdlib). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_stdlib). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_stdlib/src/identifiers.rs b/crates/ruff_python_stdlib/src/identifiers.rs index fc0ac0e715..b20b768caa 100644 --- a/crates/ruff_python_stdlib/src/identifiers.rs +++ b/crates/ruff_python_stdlib/src/identifiers.rs @@ -46,7 +46,7 @@ pub fn is_identifier_continuation(c: char) -> bool { /// identifier is defined in a class definition, it will be mangled prior to /// code generation. /// -/// See: . +/// See: . pub fn is_mangled_private(id: &str) -> bool { id.starts_with("__") && !id.ends_with("__") } diff --git a/crates/ruff_python_trivia/Cargo.toml b/crates/ruff_python_trivia/Cargo.toml index 465877e15c..5484a1e335 100644 --- a/crates/ruff_python_trivia/Cargo.toml +++ b/crates/ruff_python_trivia/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_trivia" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_trivia/README.md b/crates/ruff_python_trivia/README.md index b467967292..a78b5e7472 100644 --- a/crates/ruff_python_trivia/README.md +++ b/crates/ruff_python_trivia/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_python_trivia). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_python_trivia). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_trivia/src/whitespace.rs b/crates/ruff_python_trivia/src/whitespace.rs index e36f570caa..535b03905d 100644 --- a/crates/ruff_python_trivia/src/whitespace.rs +++ b/crates/ruff_python_trivia/src/whitespace.rs @@ -125,20 +125,21 @@ impl PythonWhitespace for str { #[cfg(test)] mod tests { + use std::assert_matches; use std::borrow::Cow; use super::{expand_tabs, tab_offset, tab_offset_u32}; #[test] fn tab_expansion_borrows_unchanged_text() { - assert!(matches!(expand_tabs("unchanged"), Cow::Borrowed(_))); + assert_matches!(expand_tabs("unchanged"), Cow::Borrowed(_)); } #[test] fn tab_expansion_allocates_changed_text() { let expanded = expand_tabs(" \tvalue"); - assert!(matches!(&expanded, Cow::Owned(_))); + assert_matches!(&expanded, Cow::Owned(_)); assert_eq!(expanded, " value"); } diff --git a/crates/ruff_ranged_value/Cargo.toml b/crates/ruff_ranged_value/Cargo.toml index 8f699f3a38..6041ddcfd7 100644 --- a/crates/ruff_ranged_value/Cargo.toml +++ b/crates/ruff_ranged_value/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_ranged_value" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } @@ -16,6 +16,7 @@ doctest = false [dependencies] ruff_db = { workspace = true } +ruff_python_ast = { workspace = true } ruff_text_size = { workspace = true } get-size2 = { workspace = true, optional = true } diff --git a/crates/ruff_ranged_value/README.md b/crates/ruff_ranged_value/README.md index 068105df68..78f5d16076 100644 --- a/crates/ruff_ranged_value/README.md +++ b/crates/ruff_ranged_value/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_ranged_value). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_ranged_value). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_ranged_value/src/lib.rs b/crates/ruff_ranged_value/src/lib.rs index f1305e61fd..9b6a4247a8 100644 --- a/crates/ruff_ranged_value/src/lib.rs +++ b/crates/ruff_ranged_value/src/lib.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use serde::{Deserialize, Deserializer}; use toml::Spanned; -use ruff_db::system::{SystemPath, SystemPathBuf}; +use ruff_db::Db; +use ruff_db::files::{File, system_path_to_file}; +use ruff_db::system::SystemPathBuf; +use ruff_python_ast::script::ScriptSourceMap; use ruff_text_size::{TextRange, TextSize}; #[derive(Clone, Debug, PartialEq)] @@ -20,6 +23,12 @@ pub enum ValueSource { /// created when loading the configuration. File(Arc), + /// Value loaded from inline metadata in a standalone script. + /// + /// Unlike project configuration, scripts are parsed after the database exists, so their + /// existing Salsa file can be retained directly, including for virtual files. + ScriptMetadata(File), + /// The value comes from a CLI argument, while it's left open if specified using a short argument, /// long argument (`--extra-paths`) or `--config key=value`. Cli, @@ -30,17 +39,19 @@ pub enum ValueSource { /// (e.g., the Python environment) Editor, - /// The value was provided by `uv workspace metadata`. - UvWorkspace, + /// The value was provided by uv metadata for a project or standalone script. + UvMetadata, } impl ValueSource { - pub fn file(&self) -> Option<&SystemPath> { + /// Resolves the file containing this setting, if its source is file-backed. + pub fn file(&self, db: &dyn Db) -> Option { match self { - ValueSource::File(path) => Some(&**path), + ValueSource::File(path) => system_path_to_file(db, &**path).ok(), + ValueSource::ScriptMetadata(file) => Some(*file), ValueSource::Cli => None, ValueSource::Editor => None, - ValueSource::UvWorkspace => None, + ValueSource::UvMetadata => None, } } } @@ -53,19 +64,31 @@ thread_local! { /// Use the [`ValueSourceGuard`] to initialize the thread local before calling into any /// deserialization code. It ensures that the thread local variable gets cleaned up /// once deserialization is done (once the guard gets dropped). - static VALUE_SOURCE: RefCell> = const { RefCell::new(None) }; + static VALUE_SOURCE: RefCell> = const { RefCell::new(None) }; } /// Guard to safely change the [`ValueSource`] for the current thread. #[must_use] pub struct ValueSourceGuard { - prev_value: Option<(ValueSource, bool)>, + prev_value: Option, } impl ValueSourceGuard { pub fn new(source: ValueSource, is_toml: bool) -> Self { - let prev = VALUE_SOURCE.replace(Some((source, is_toml))); - Self { prev_value: prev } + Self::replace(ValueSourceContext { + source, + has_span: is_toml, + source_map: None, + }) + } + + /// Sets the source and maps deserialized TOML ranges into that source. + pub fn with_source_map(source: ValueSource, source_map: ScriptSourceMap) -> Self { + Self::replace(ValueSourceContext { + source, + has_span: true, + source_map: Some(source_map), + }) } pub fn without_spans() -> Self { @@ -73,11 +96,16 @@ impl ValueSourceGuard { current .as_ref() .expect("value source to be set before disabling spans") - .0 + .source .clone() }); Self::new(source, false) } + + fn replace(context: ValueSourceContext) -> Self { + let prev = VALUE_SOURCE.replace(Some(context)); + Self { prev_value: prev } + } } impl Drop for ValueSourceGuard { @@ -86,6 +114,12 @@ impl Drop for ValueSourceGuard { } } +struct ValueSourceContext { + source: ValueSource, + has_span: bool, + source_map: Option, +} + /// A value that "remembers" where it comes from (source) and its range in source. /// /// ## Equality, Hash, and Ordering @@ -140,15 +174,19 @@ where impl RangedValue { pub fn new(value: T, source: ValueSource) -> Self { - Self::with_range(value, source, TextRange::default()) + Self { + value, + source, + range: None, + } } pub fn cli(value: T) -> Self { - Self::with_range(value, ValueSource::Cli, TextRange::default()) + Self::new(value, ValueSource::Cli) } pub fn python_extension(value: T) -> Self { - Self::with_range(value, ValueSource::Editor, TextRange::default()) + Self::new(value, ValueSource::Editor) } fn with_range(value: T, source: ValueSource, range: TextRange) -> Self { @@ -305,9 +343,12 @@ where D: Deserializer<'de>, { VALUE_SOURCE.with_borrow(|source| { - let (source, has_span) = source.clone().unwrap(); + let context = source + .as_ref() + .expect("value source to be set before deserializing a ranged value"); + let source = context.source.clone(); - if has_span { + if context.has_span { let spanned: Spanned = Spanned::deserialize(deserializer)?; let span = spanned.span(); let range = TextRange::new( @@ -316,6 +357,10 @@ where TextSize::try_from(span.end) .expect("Configuration file to be smaller than 4GB"), ); + let range = context + .source_map + .as_ref() + .map_or(range, |source_map| source_map.map_range(range)); Ok(Self::with_range(spanned.into_inner(), source, range)) } else { diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index 465d5b7d10..9e72bbeef4 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_server" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_server/README.md b/crates/ruff_server/README.md index fbb2f6b627..fc09780ee0 100644 --- a/crates/ruff_server/README.md +++ b/crates/ruff_server/README.md @@ -24,8 +24,8 @@ You can also join us on [**Discord**](https://discord.com/invite/astral-sh). This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_server). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_server). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_server/src/fix.rs b/crates/ruff_server/src/fix.rs index 668c24cabc..536ad7d48d 100644 --- a/crates/ruff_server/src/fix.rs +++ b/crates/ruff_server/src/fix.rs @@ -25,7 +25,7 @@ use ruff_source_file::LineIndex; pub(crate) type Fixes = FxHashMap>; /// The source a document started from, and the source the linter's fixer produced from it. -pub(crate) struct FixedSource { +struct FixedSource { source: SourceKind, fixed: SourceKind, } diff --git a/crates/ruff_server/src/lint.rs b/crates/ruff_server/src/lint.rs index 834119a9d3..337b7a49b5 100644 --- a/crates/ruff_server/src/lint.rs +++ b/crates/ruff_server/src/lint.rs @@ -47,6 +47,8 @@ pub(crate) struct AssociatedDiagnosticData { code: String, /// Possible edit to add a suppression comment which will disable this diagnostic. noqa_edit: Option, + /// Whether this fix corresponds to a preferred action that can be used by auto fix commands. + is_preferred: Option, } /// Describes a fix for `fixed_diagnostic` that may have quick fix @@ -64,6 +66,8 @@ pub(crate) struct DiagnosticFix { pub(crate) edits: Vec, /// Possible edit to add a suppression comment which will disable this diagnostic. pub(crate) noqa_edit: Option, + /// Whether this fix corresponds to a preferred action that can be used by auto fix commands. + pub(crate) is_preferred: Option, } /// A series of diagnostics across a single text document or an arbitrary number of notebook cells. @@ -306,6 +310,7 @@ pub(crate) fn fixes_for_diagnostics( title: associated_data.title, noqa_edit: associated_data.noqa_edit, edits: associated_data.edits, + is_preferred: associated_data.is_preferred, })) }) .filter_map(crate::Result::transpose) @@ -372,7 +377,6 @@ fn to_lsp_diagnostic( let name = diagnostic.name(); let fix = diagnostic.fix(); let suggestion = diagnostic.first_help_text(); - let fix = fix.and_then(|fix| fix.applies(Applicability::Unsafe).then_some(fix)); let (severity, code) = if let Some(code) = diagnostic.secondary_code() { let severity = severity(code); @@ -387,6 +391,8 @@ fn to_lsp_diagnostic( } else { ( match diagnostic.severity() { + // Map lint diagnostics without codes to warning, like in `severity`. + _ if diagnostic.id().is_lint() => lsp_types::DiagnosticSeverity::Warning, ruff_db::diagnostic::Severity::Info => lsp_types::DiagnosticSeverity::Information, ruff_db::diagnostic::Severity::Warning => lsp_types::DiagnosticSeverity::Warning, ruff_db::diagnostic::Severity::Error => lsp_types::DiagnosticSeverity::Error, @@ -398,6 +404,10 @@ fn to_lsp_diagnostic( let data = (fix.is_some() || noqa_edit.is_some()) .then(|| { + let mut title = suggestion.unwrap_or(name).to_string(); + if fix.is_some_and(|fix| fix.applicability() == Applicability::DisplayOnly) { + title.push_str(" (suggestion)"); + } let edits = fix .into_iter() .flat_map(Fix::edits) @@ -411,10 +421,11 @@ fn to_lsp_diagnostic( new_text: noqa_edit.into_content().unwrap_or_default().into_string(), }); serde_json::to_value(AssociatedDiagnosticData { - title: suggestion.unwrap_or(name).to_string(), + title, noqa_edit, edits, code: code.clone(), + is_preferred: fix.map(|fix| fix.applicability().is_safe()), }) .ok() }) diff --git a/crates/ruff_server/src/server.rs b/crates/ruff_server/src/server.rs index ad437bf147..87e29c596e 100644 --- a/crates/ruff_server/src/server.rs +++ b/crates/ruff_server/src/server.rs @@ -268,8 +268,7 @@ impl Server { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum SupportedCodeAction { /// Maps to the `quickfix` code action kind. Quick fix code actions are shown under - /// their respective diagnostics. Quick fixes are only created where the fix applicability is - /// at least [`ruff_diagnostics::Applicability::Unsafe`]. + /// their respective diagnostics, including display-only fixes that require manual review. QuickFix, /// Maps to the `source.fixAll` and `source.fixAll.ruff` code action kinds. /// This is a source action that applies all safe fixes to the currently open document. diff --git a/crates/ruff_server/src/server/api/requests/code_action.rs b/crates/ruff_server/src/server/api/requests/code_action.rs index f1254407d8..8ff0aceab8 100644 --- a/crates/ruff_server/src/server/api/requests/code_action.rs +++ b/crates/ruff_server/src/server/api/requests/code_action.rs @@ -191,6 +191,7 @@ fn quick_fix( data: Some( serde_json::to_value(document_uri).expect("document uri should serialize"), ), + is_preferred: fix.is_preferred, ..Default::default() })) }) diff --git a/crates/ruff_server/src/server/api/requests/code_action_resolve.rs b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs index 014af119fb..b2899a58f3 100644 --- a/crates/ruff_server/src/server/api/requests/code_action_resolve.rs +++ b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs @@ -259,9 +259,7 @@ pub(super) fn format_and_optimize_imports_edit( /// The same as [`format_and_optimize_imports_edit`] but for F401: laying a file out is not licence /// to delete anything from it, so *Reformat Code* sorts imports without pruning them. Dropping the /// unused ones is what *Optimize Imports* is for, and the user asks for that separately. -pub(super) fn format_and_organize_imports_edit( - snapshot: &DocumentSnapshot, -) -> crate::Result { +fn format_and_organize_imports_edit(snapshot: &DocumentSnapshot) -> crate::Result { let settings = settings_for_rules(snapshot.query(), import_sorting_rules()); format_and_imports_edit(snapshot, &settings) } diff --git a/crates/ruff_server/src/server/api/requests/diagnostic.rs b/crates/ruff_server/src/server/api/requests/diagnostic.rs index d4a4d41567..33c369c549 100644 --- a/crates/ruff_server/src/server/api/requests/diagnostic.rs +++ b/crates/ruff_server/src/server/api/requests/diagnostic.rs @@ -15,13 +15,11 @@ impl super::BackgroundDocumentRequestHandler for DocumentDiagnostic { fn run_with_snapshot( snapshot: Self::Snapshot, _client: &Client, - _params: types::DocumentDiagnosticParams, + params: types::DocumentDiagnosticParams, ) -> Result { let diagnostics = match snapshot { Ok(snapshot) => generate_diagnostics(&snapshot) - .into_iter() - .next() - .map(|(_, diagnostics)| diagnostics) + .remove(¶ms.text_document.uri) .unwrap_or_default(), Err(uri) => { tracing::warn!("Returning no diagnostics because document `{uri}` isn't open."); @@ -34,8 +32,6 @@ impl super::BackgroundDocumentRequestHandler for DocumentDiagnostic { full_document_diagnostic_report: FullDocumentDiagnosticReport { // TODO(jane): eventually this will be important for caching diagnostic information. result_id: None, - // Pull diagnostic requests are only called for text documents. - // Since diagnostic requests generate items: diagnostics, }, } diff --git a/crates/ruff_server/src/server/api/requests/explain_rule.rs b/crates/ruff_server/src/server/api/requests/explain_rule.rs index ec98192d2f..e9abbb0a3d 100644 --- a/crates/ruff_server/src/server/api/requests/explain_rule.rs +++ b/crates/ruff_server/src/server/api/requests/explain_rule.rs @@ -32,7 +32,7 @@ impl Request for ExplainRuleRequest { pub(crate) struct ExplainRuleParams { /// A noqa code (`F401`) or a rule name (`unused-import`). Both are what a reader has in hand: /// the code is what a diagnostic shows, the name is what the documentation calls it. - pub(crate) code: String, + code: String, } /// What the rule is, ready to show. @@ -40,11 +40,11 @@ pub(crate) struct ExplainRuleParams { #[serde(rename_all = "camelCase")] pub(crate) struct RuleExplanation { /// The rule's own name, e.g. `unused-import`. - pub(crate) name: String, + name: String, /// The noqa code, e.g. `F401`. - pub(crate) code: String, + code: String, /// The full explanation, in markdown. - pub(crate) documentation: String, + documentation: String, } pub(crate) struct ExplainRule; @@ -72,7 +72,10 @@ impl BackgroundRequestHandler for ExplainRule { fn explanation_of(code: &str) -> Option { resolve(code).map(|rule| RuleExplanation { name: rule.name().as_str().to_string(), - code: rule.noqa_code().to_string(), + code: rule + .noqa_code() + .map(|code| code.to_string()) + .unwrap_or_default(), documentation: rule_documentation(rule), }) } @@ -106,7 +109,10 @@ mod tests { #[test] fn a_code_resolves_to_its_rule() { let rule = resolve("F401").expect("F401 is this linter's"); - assert_eq!(rule.noqa_code().to_string(), "F401"); + assert_eq!( + rule.noqa_code().map(|code| code.to_string()).as_deref(), + Some("F401") + ); } /// A reader who has the name rather than the code is asking the same question. diff --git a/crates/ruff_server/src/server/api/requests/hover.rs b/crates/ruff_server/src/server/api/requests/hover.rs index 5085a02c13..cefc9b78d0 100644 --- a/crates/ruff_server/src/server/api/requests/hover.rs +++ b/crates/ruff_server/src/server/api/requests/hover.rs @@ -112,18 +112,22 @@ fn hover( fn format_rule_text(rule: Rule) -> String { let mut output = String::new(); - let _ = write!(&mut output, "# {} ({})", rule.name(), rule.noqa_code()); + let _ = write!(&mut output, "# {}", rule.name_and_code()); output.push('\n'); output.push('\n'); - let (linter, _) = Linter::parse_code(&rule.noqa_code().to_string()).unwrap(); - let _ = write!( - &mut output, - "Derived from the **{}** linter.", - linter.name() - ); - output.push('\n'); - output.push('\n'); + if let Some(linter) = rule + .noqa_code() + .and_then(|code| Linter::parse_code(&code.to_string()).map(|(linter, _)| linter)) + { + let _ = write!( + &mut output, + "Derived from the **{}** linter.", + linter.name() + ); + output.push('\n'); + output.push('\n'); + } let fix_availability = rule.fixable(); if matches!( @@ -144,7 +148,7 @@ fn format_rule_text(rule: Rule) -> String { if let Some(explanation) = rule.explanation() { output.push_str(explanation.trim()); } else { - tracing::warn!("Rule {} does not have an explanation", rule.noqa_code()); + tracing::warn!("Rule {} does not have an explanation", rule.name_and_code()); output.push_str("An issue occurred: an explanation for this rule was not found."); } output diff --git a/crates/ruff_server/src/session/options.rs b/crates/ruff_server/src/session/options.rs index b1d2d8f2ed..98807c3ec8 100644 --- a/crates/ruff_server/src/session/options.rs +++ b/crates/ruff_server/src/session/options.rs @@ -33,7 +33,7 @@ pub(crate) enum ConfigurationPreference { EditorOnly, } -/// A direct representation of of `configuration` schema within the client settings. +/// A direct representation of the `configuration` schema within the client settings. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(test, derive(PartialEq, Eq))] #[serde(untagged)] @@ -491,8 +491,6 @@ impl_noop_combine!(LineLength); #[cfg(test)] mod tests { - use std::str::FromStr; - use insta::assert_debug_snapshot; use ruff_linter::settings::types::PreviewMode; use ruff_python_formatter::QuoteStyle; @@ -746,6 +744,8 @@ mod tests { #[cfg(not(windows))] #[test] fn test_vs_code_workspace_settings_resolve() { + use std::str::FromStr; + let options = deserialize_fixture(VS_CODE_INIT_OPTIONS_FIXTURE); let AllOptions { global, diff --git a/crates/ruff_server/src/session/settings.rs b/crates/ruff_server/src/session/settings.rs index 70151a4114..9f8b5c9b23 100644 --- a/crates/ruff_server/src/session/settings.rs +++ b/crates/ruff_server/src/session/settings.rs @@ -114,7 +114,7 @@ impl TryFrom for ResolvedConfiguration { )), ClientConfiguration::Object(map) => { let _guard = ValueSourceGuard::new(ValueSource::Editor, false); - let options = toml::Table::try_from(map)?.try_into::()?; + let options = Options::from_toml_table(toml::Table::try_from(map)?)?; if options.extend.is_some() { Err(ResolvedConfigurationError::ExtendNotSupported) } else { diff --git a/crates/ruff_server/tests/e2e/code_action.rs b/crates/ruff_server/tests/e2e/code_action.rs index c64dac7348..7a0d4333f6 100644 --- a/crates/ruff_server/tests/e2e/code_action.rs +++ b/crates/ruff_server/tests/e2e/code_action.rs @@ -135,6 +135,7 @@ extend-select = ["F401"] "tags": [] } ], + "isPreferred": true, "edit": { "changes": { "file:///ruff.toml": [ diff --git a/crates/ruff_server/tests/e2e/diagnostics.rs b/crates/ruff_server/tests/e2e/diagnostics.rs index a3f95fb4eb..c76bae8d99 100644 --- a/crates/ruff_server/tests/e2e/diagnostics.rs +++ b/crates/ruff_server/tests/e2e/diagnostics.rs @@ -57,6 +57,7 @@ fn uses_human_readable_names_in_preview() -> Result<()> { } } ], + "is_preferred": true, "noqa_edit": { "newText": " # ruff: ignore[unused-import]\n", "range": { @@ -138,6 +139,7 @@ extend-select = ["F401"] } } ], + "is_preferred": true, "noqa_edit": null, "title": "Replace rule code with `unused-import`" } diff --git a/crates/ruff_server/tests/e2e/notebook.rs b/crates/ruff_server/tests/e2e/notebook.rs index 9c39245ff3..835d873b03 100644 --- a/crates/ruff_server/tests/e2e/notebook.rs +++ b/crates/ruff_server/tests/e2e/notebook.rs @@ -24,6 +24,86 @@ struct NotebookChange { updated_cells: NotebookDocumentCellChanges, } +#[test] +fn pull_diagnostics_for_notebook_cells() -> Result<()> { + let mut server = TestServerBuilder::new()? + .with_workspace(".")? + .with_file( + "pyproject.toml", + "[tool.ruff.lint]\nselect = [\"F401\", \"F811\"]\n", + )? + .build(); + + let notebook_path = server.file_path("test.ipynb"); + let cell_uris = [ + make_cell_uri(¬ebook_path, 0), + make_cell_uri(¬ebook_path, 1), + make_cell_uri(¬ebook_path, 2), + ]; + let cells = cell_uris + .iter() + .cloned() + .map(|uri| lsp_types::NotebookCell { + kind: lsp_types::NotebookCellKind::Code, + document: uri, + metadata: None, + execution_summary: None, + }) + .collect(); + let cell_text_documents = cell_uris + .iter() + .cloned() + .zip(["import sys\n", "import os\nimport os\n", "os.getcwd()\n"]) + .map(|(uri, source)| { + TextDocumentItem::new(uri, lsp_types::LanguageKind::Python, 0, source.to_string()) + }) + .collect(); + + server.send_notification::( + DidOpenNotebookDocumentParams { + notebook_document: NotebookDocument { + uri: server.file_uri("test.ipynb"), + notebook_type: "jupyter-notebook".to_string(), + version: 0, + metadata: None, + cells, + }, + cell_text_documents, + }, + ); + + let expected_diagnostics = server.collect_publish_diagnostic_notifications(cell_uris.len()); + assert_eq!(expected_diagnostics[&cell_uris[0]].len(), 1); + assert_eq!(expected_diagnostics[&cell_uris[1]].len(), 1); + assert!(expected_diagnostics[&cell_uris[2]].is_empty()); + + for uri in cell_uris { + let request_id = server.send_request::( + lsp_types::DocumentDiagnosticParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + identifier: Some("ruff".to_string()), + previous_result_id: None, + work_done_progress_params: lsp_types::WorkDoneProgressParams::default(), + partial_result_params: lsp_types::PartialResultParams::default(), + }, + ); + let report = server.await_response::(&request_id); + + let lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) = + report + else { + panic!("Expected a full diagnostic report for {uri}"); + }; + + assert_eq!( + report.full_document_diagnostic_report.items, expected_diagnostics[&uri], + "Pull diagnostics do not match published diagnostics for {uri}" + ); + } + + Ok(()) +} + #[test] fn related_information() -> Result<()> { let mut server = TestServerBuilder::new()? @@ -121,15 +201,14 @@ select = ["F811"] #[test] fn super_resolution_overview() -> Result<()> { - let fixture_path = fixture_path(NOTEBOOK_FIXTURE_PATH)?; - let workspace_dir = fixture_path - .parent() - .expect("notebook fixture should have a parent"); + let fixture = std::fs::read_to_string(fixture_path(NOTEBOOK_FIXTURE_PATH)?)?; let mut server = TestServerBuilder::new()? - .with_workspace(workspace_dir)? + .with_workspace(".")? + .with_file(NOTEBOOK_FIXTURE_PATH, fixture)? .build(); + let fixture_path = server.file_path(NOTEBOOK_FIXTURE_PATH); let (notebook_document, cell_text_documents) = create_lsp_notebook(&fixture_path, fixture_path.clone())?; let notebook_uri = notebook_document.uri.clone(); @@ -294,17 +373,17 @@ fn super_resolution_overview() -> Result<()> { #[test] fn notebook_without_ipynb_extension() -> Result<()> { - let fixture_path = fixture_path(NOTEBOOK_FIXTURE_PATH)?; - let workspace_dir = fixture_path - .parent() - .expect("notebook fixture should have a parent"); + let fixture = std::fs::read_to_string(fixture_path(NOTEBOOK_FIXTURE_PATH)?)?; let mut server = TestServerBuilder::new()? - .with_workspace(workspace_dir)? + .with_workspace(".")? + .with_file(NOTEBOOK_FIXTURE_PATH, fixture)? .build(); - let (notebook_document, cell_text_documents) = - create_lsp_notebook(&fixture_path, workspace_dir.join("notebook.py"))?; + let (notebook_document, cell_text_documents) = create_lsp_notebook( + &server.file_path(NOTEBOOK_FIXTURE_PATH), + server.file_path("notebook.py"), + )?; let cell_count = cell_text_documents.len(); server.send_notification::( diff --git a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__notebook_without_ipynb_extension_open.snap b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__notebook_without_ipynb_extension_open.snap index 6d6df887d2..3140d36d60 100644 --- a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__notebook_without_ipynb_extension_open.snap +++ b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__notebook_without_ipynb_extension_open.snap @@ -3,278 +3,7 @@ source: crates/ruff_server/tests/e2e/notebook.rs expression: diagnostics --- { - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [ - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "I002", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/missing-required-import" - }, - "source": "Ruff", - "message": "Missing required import: `from __future__ import annotations`\n\nhelp: Insert required import: `from __future__ import annotations`", - "tags": [], - "data": { - "code": "I002", - "edits": [ - { - "newText": "from __future__ import annotations\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: I002\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "Insert required import: `from __future__ import annotations`" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF900", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule.", - "tags": [], - "data": { - "code": "RUF900", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF900\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF901", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-safe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a safe fix.", - "tags": [], - "data": { - "code": "RUF901", - "edits": [ - { - "newText": "# fix from stable-test-rule-safe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF901\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-safe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF902", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-unsafe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with an unsafe fix.", - "tags": [], - "data": { - "code": "RUF902", - "edits": [ - { - "newText": "# fix from stable-test-rule-unsafe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF902\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-unsafe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF903", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-display-only-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a display only fix.", - "tags": [], - "data": { - "code": "RUF903", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF903\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-display-only-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF950", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/redirected-to-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a test rule that was redirected from another.", - "tags": [], - "data": { - "code": "RUF950", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF950\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "redirected-to-test-rule" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=1": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=2": [ { @@ -313,6 +42,7 @@ expression: diagnostics } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: I001\n", "range": { @@ -330,167 +60,10 @@ expression: diagnostics } } ], - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [ - { - "range": { - "start": { - "line": 5, - "character": 29 - }, - "end": { - "line": 5, - "character": 30 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 30, - "line": 5 - }, - "start": { - "character": 29, - "line": 5 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 6 - }, - "start": { - "character": 30, - "line": 5 - } - } - }, - "title": "Remove unnecessary semicolon" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=4": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=5": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=6": [ - { - "range": { - "start": { - "line": 3, - "character": 22 - }, - "end": { - "line": 3, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 3 - }, - "start": { - "character": 22, - "line": 3 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 4 - }, - "start": { - "character": 23, - "line": 3 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, - { - "range": { - "start": { - "line": 8, - "character": 22 - }, - "end": { - "line": 8, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 8 - }, - "start": { - "character": 22, - "line": 8 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 9 - }, - "start": { - "character": 23, - "line": 8 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, { "range": { "start": { @@ -527,6 +100,7 @@ expression: diagnostics } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: F541\n", "range": { diff --git a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_final.snap b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_final.snap index 6c553b0a59..1d845984c3 100644 --- a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_final.snap +++ b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_final.snap @@ -3,278 +3,7 @@ source: crates/ruff_server/tests/e2e/notebook.rs expression: "final_diagnostics.expect(\"at least one notebook change\")" --- { - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [ - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "I002", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/missing-required-import" - }, - "source": "Ruff", - "message": "Missing required import: `from __future__ import annotations`\n\nhelp: Insert required import: `from __future__ import annotations`", - "tags": [], - "data": { - "code": "I002", - "edits": [ - { - "newText": "from __future__ import annotations\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: I002\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "Insert required import: `from __future__ import annotations`" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF900", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule.", - "tags": [], - "data": { - "code": "RUF900", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF900\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF901", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-safe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a safe fix.", - "tags": [], - "data": { - "code": "RUF901", - "edits": [ - { - "newText": "# fix from stable-test-rule-safe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF901\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-safe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF902", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-unsafe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with an unsafe fix.", - "tags": [], - "data": { - "code": "RUF902", - "edits": [ - { - "newText": "# fix from stable-test-rule-unsafe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF902\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-unsafe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF903", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-display-only-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a display only fix.", - "tags": [], - "data": { - "code": "RUF903", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF903\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-display-only-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF950", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/redirected-to-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a test rule that was redirected from another.", - "tags": [], - "data": { - "code": "RUF950", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF950\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "redirected-to-test-rule" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=1": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=2": [ { @@ -313,6 +42,7 @@ expression: "final_diagnostics.expect(\"at least one notebook change\")" } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: I001\n", "range": { @@ -330,167 +60,10 @@ expression: "final_diagnostics.expect(\"at least one notebook change\")" } } ], - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [ - { - "range": { - "start": { - "line": 5, - "character": 29 - }, - "end": { - "line": 5, - "character": 30 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 30, - "line": 5 - }, - "start": { - "character": 29, - "line": 5 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 6 - }, - "start": { - "character": 30, - "line": 5 - } - } - }, - "title": "Remove unnecessary semicolon" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=4": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=5": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=6": [ - { - "range": { - "start": { - "line": 3, - "character": 22 - }, - "end": { - "line": 3, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 3 - }, - "start": { - "character": 22, - "line": 3 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 4 - }, - "start": { - "character": 23, - "line": 3 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, - { - "range": { - "start": { - "line": 8, - "character": 22 - }, - "end": { - "line": 8, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 8 - }, - "start": { - "character": 22, - "line": 8 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 9 - }, - "start": { - "character": 23, - "line": 8 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, { "range": { "start": { @@ -527,6 +100,7 @@ expression: "final_diagnostics.expect(\"at least one notebook change\")" } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: F541\n", "range": { diff --git a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_open.snap b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_open.snap index 6d6df887d2..3140d36d60 100644 --- a/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_open.snap +++ b/crates/ruff_server/tests/e2e/snapshots/e2e__notebook__super_resolution_overview_open.snap @@ -3,278 +3,7 @@ source: crates/ruff_server/tests/e2e/notebook.rs expression: diagnostics --- { - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [ - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "I002", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/missing-required-import" - }, - "source": "Ruff", - "message": "Missing required import: `from __future__ import annotations`\n\nhelp: Insert required import: `from __future__ import annotations`", - "tags": [], - "data": { - "code": "I002", - "edits": [ - { - "newText": "from __future__ import annotations\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: I002\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "Insert required import: `from __future__ import annotations`" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF900", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule.", - "tags": [], - "data": { - "code": "RUF900", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF900\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF901", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-safe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a safe fix.", - "tags": [], - "data": { - "code": "RUF901", - "edits": [ - { - "newText": "# fix from stable-test-rule-safe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF901\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-safe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF902", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-unsafe-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with an unsafe fix.", - "tags": [], - "data": { - "code": "RUF902", - "edits": [ - { - "newText": "# fix from stable-test-rule-unsafe-fix\n", - "range": { - "end": { - "character": 0, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: RUF902\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-unsafe-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF903", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/stable-test-rule-display-only-fix" - }, - "source": "Ruff", - "message": "Hey this is a stable test rule with a display only fix.", - "tags": [], - "data": { - "code": "RUF903", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF903\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "stable-test-rule-display-only-fix" - } - }, - { - "range": { - "start": { - "line": 0, - "character": 0 - }, - "end": { - "line": 0, - "character": 0 - } - }, - "severity": 2, - "code": "RUF950", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/redirected-to-test-rule" - }, - "source": "Ruff", - "message": "Hey this is a test rule that was redirected from another.", - "tags": [], - "data": { - "code": "RUF950", - "edits": [], - "noqa_edit": { - "newText": " # noqa: RUF950\n", - "range": { - "end": { - "character": 0, - "line": 1 - }, - "start": { - "character": 71, - "line": 0 - } - } - }, - "title": "redirected-to-test-rule" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=0": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=1": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=2": [ { @@ -313,6 +42,7 @@ expression: diagnostics } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: I001\n", "range": { @@ -330,167 +60,10 @@ expression: diagnostics } } ], - "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [ - { - "range": { - "start": { - "line": 5, - "character": 29 - }, - "end": { - "line": 5, - "character": 30 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 30, - "line": 5 - }, - "start": { - "character": 29, - "line": 5 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 6 - }, - "start": { - "character": 30, - "line": 5 - } - } - }, - "title": "Remove unnecessary semicolon" - } - } - ], + "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=3": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=4": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=5": [], "notebook-cell:///Users/test/notebooks/tensorflow_test_notebook.ipynb.ipynb?cell=6": [ - { - "range": { - "start": { - "line": 3, - "character": 22 - }, - "end": { - "line": 3, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 3 - }, - "start": { - "character": 22, - "line": 3 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 4 - }, - "start": { - "character": 23, - "line": 3 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, - { - "range": { - "start": { - "line": 8, - "character": 22 - }, - "end": { - "line": 8, - "character": 23 - } - }, - "severity": 2, - "code": "E703", - "codeDescription": { - "href": "https://kotlinisland.github.io/basedpython/rules/useless-semicolon" - }, - "source": "Ruff", - "message": "Statement ends with an unnecessary semicolon\n\nhelp: Remove unnecessary semicolon", - "tags": [], - "data": { - "code": "E703", - "edits": [ - { - "newText": "", - "range": { - "end": { - "character": 23, - "line": 8 - }, - "start": { - "character": 22, - "line": 8 - } - } - } - ], - "noqa_edit": { - "newText": " # noqa: E703\n", - "range": { - "end": { - "character": 0, - "line": 9 - }, - "start": { - "character": 23, - "line": 8 - } - } - }, - "title": "Remove unnecessary semicolon" - } - }, { "range": { "start": { @@ -527,6 +100,7 @@ expression: diagnostics } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: F541\n", "range": { diff --git a/crates/ruff_server/tests/e2e/workspace.rs b/crates/ruff_server/tests/e2e/workspace.rs index 60f695127e..f118be5842 100644 --- a/crates/ruff_server/tests/e2e/workspace.rs +++ b/crates/ruff_server/tests/e2e/workspace.rs @@ -81,6 +81,7 @@ ignore = ["F401"] } } ], + "is_preferred": true, "noqa_edit": { "newText": " # noqa: F401\n", "range": { diff --git a/crates/ruff_source_file/Cargo.toml b/crates/ruff_source_file/Cargo.toml index e918a959a0..8a1864984e 100644 --- a/crates/ruff_source_file/Cargo.toml +++ b/crates/ruff_source_file/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_source_file" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_source_file/README.md b/crates/ruff_source_file/README.md index 2d61cd4f33..c852948427 100644 --- a/crates/ruff_source_file/README.md +++ b/crates/ruff_source_file/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_source_file). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_source_file). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_text_size/Cargo.toml b/crates/ruff_text_size/Cargo.toml index 3ad9d4da5f..a81351a797 100644 --- a/crates/ruff_text_size/Cargo.toml +++ b/crates/ruff_text_size/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_text_size" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_text_size/README.md b/crates/ruff_text_size/README.md index e14fefa0f7..232d11d5a7 100644 --- a/crates/ruff_text_size/README.md +++ b/crates/ruff_text_size/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_text_size). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_text_size). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index 1e3b3000f7..16ae0f07cc 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.16.2" +version = "0.16.6" description = "WebAssembly bindings for Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_wasm/README.md b/crates/ruff_wasm/README.md index 29b092ebfd..4147895706 100644 --- a/crates/ruff_wasm/README.md +++ b/crates/ruff_wasm/README.md @@ -55,8 +55,8 @@ const formatted = workspace.format(exampleDocument); This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.16.2) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_wasm). +This version (0.16.6) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_wasm). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_wasm/tests/api.rs b/crates/ruff_wasm/tests/api.rs index fd63457b9a..ee018525ef 100644 --- a/crates/ruff_wasm/tests/api.rs +++ b/crates/ruff_wasm/tests/api.rs @@ -59,7 +59,7 @@ fn empty_config() { "if (1, 2):\n pass", r#"{}"#, [ExpandedMessage { - code: Rule::IfTuple.noqa_code().to_string(), + code: Rule::IfTuple.noqa_code().unwrap().to_string(), message: "If test is a tuple, which is always `True`".to_string(), tags: vec![], annotations: vec![primary_annotation( diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index 2ad0e6af61..062ef463fa 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_workspace" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_workspace/README.md b/crates/ruff_workspace/README.md index 996e2998cb..f836894843 100644 --- a/crates/ruff_workspace/README.md +++ b/crates/ruff_workspace/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ruff_workspace). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ruff_workspace). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 468a56c056..bf4dc5dd7e 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -13,6 +13,7 @@ use glob::{GlobError, Paths, PatternError, glob}; use itertools::Itertools; use log::debug; use regex::Regex; +use ruff_linter::codes::Category; use ruff_linter::preview::is_warn_on_unknown_selectors_enabled; use rustc_hash::{FxHashMap, FxHashSet}; use shellexpand; @@ -907,8 +908,16 @@ impl LintConfiguration { require_explicit: self.explicit_preview_rules.unwrap_or_default(), }; + let preview_selectors; + let selectors = if preview.mode.is_enabled() { + preview_selectors = Category::default_categories().map(RuleSelector::Category); + &preview_selectors + } else { + DEFAULT_SELECTORS + }; + // The select_set keeps track of which rules have been selected. - let mut select_set: RuleSet = DEFAULT_SELECTORS + let mut select_set: RuleSet = selectors .iter() .flat_map(|selector| selector.rules(&preview)) .collect(); diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 9ec56bce5b..689a7131d4 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -516,6 +516,13 @@ pub struct Options { pub analyze: Option, } +impl Options { + /// Deserialize inline configuration in one crate, avoiding repeated code generation. + pub fn from_toml_table(table: toml::Table) -> Result { + table.try_into() + } +} + /// Configures how Ruff checks your code. /// /// Options specified in the `lint` section take precedence over the deprecated top-level settings. @@ -812,13 +819,16 @@ pub struct LintCommonOptions { pub fixable: Option>, /// A list of rule codes or prefixes to ignore. Prefixes can specify exact - /// rules (like `F841`), entire categories (like `F`), or anything in + /// rules (like `F841`), entire groups (like `F`), or anything in /// between. /// /// When breaking ties between enabled and disabled rules (via `select` and /// `ignore`, respectively), more specific prefixes override less /// specific prefixes. `ignore` takes precedence over `select` if the same /// prefix appears in both. + /// + /// In preview, categories like `correctness` and `suspicious` can be used + /// in addition to rule codes and linter group prefixes. #[option( default = "[]", value_type = "list[RuleSelector]", @@ -902,13 +912,16 @@ pub struct LintCommonOptions { pub logger_objects: Option>, /// A list of rule codes or prefixes to enable. Prefixes can specify exact - /// rules (like `F841`), entire categories (like `F`), or anything in + /// rules (like `F841`), entire groups (like `F`), or anything in /// between. /// /// When breaking ties between enabled and disabled rules (via `select` and /// `ignore`, respectively), more specific prefixes override less /// specific prefixes. `ignore` takes precedence over `select` if the /// same prefix appears in both. + /// + /// In preview, categories like `correctness` and `suspicious` can be used + /// in addition to rule codes and linter group prefixes. #[option( default = r#"See https://docs.astral.sh/ruff/default-rules/ or run `ruff check --show-settings --isolated`"#, value_type = "list[RuleSelector]", @@ -1079,6 +1092,8 @@ pub struct LintCommonOptions { /// A list of mappings from file pattern to rule codes or prefixes to /// exclude, when considering any matching files. An initial '!' negates /// the file pattern. + /// + /// For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax). #[option( default = "{}", value_type = "dict[str, list[RuleSelector]]", @@ -1089,6 +1104,8 @@ pub struct LintCommonOptions { "path/to/file.py" = ["E402"] # Ignore `D` rules everywhere except for the `src/` directory. "!src/**.py" = ["D"] + # Ignore check for packages that are missing an `__init__.py` file. + "{benchmark,scripts,.github/action-name/}/*.py" = ["INP001"] "# )] pub per_file_ignores: Option>>, diff --git a/crates/ruff_workspace/src/pyproject.rs b/crates/ruff_workspace/src/pyproject.rs index e8b64d0f78..7e6c7c6766 100644 --- a/crates/ruff_workspace/src/pyproject.rs +++ b/crates/ruff_workspace/src/pyproject.rs @@ -33,9 +33,7 @@ pub struct Pyproject { project: Option, } -fn parse_toml, T: DeserializeOwned>(path: P, table_path: &[&str]) -> Result { - let path = path.as_ref(); - +fn parse_toml(path: &Path, table_path: &[&str]) -> Result { let _guard = ValueSourceGuard::new( ValueSource::File(Arc::new(SystemPathBuf::from_path_buf_lossy( path.to_path_buf(), @@ -66,18 +64,18 @@ fn parse_toml, T: DeserializeOwned>(path: P, table_path: &[&str]) } /// Parse a `ruff.toml` file. -fn parse_ruff_toml>(path: P) -> Result { +fn parse_ruff_toml(path: &Path) -> Result { parse_toml(path, &[]) } /// Parse a `pyproject.toml` file. -fn parse_pyproject_toml>(path: P) -> Result { +fn parse_pyproject_toml(path: &Path) -> Result { parse_toml(path, &["tool", "ruff"]) } /// Return `true` if a `pyproject.toml` contains a `[tool.ruff]` section. fn ruff_enabled>(path: P) -> Result { - let pyproject = parse_pyproject_toml(path)?; + let pyproject = parse_pyproject_toml(path.as_ref())?; Ok(pyproject.tool.and_then(|tool| tool.ruff).is_some()) } @@ -547,7 +545,7 @@ per-file-ignores = { "__init__.py" = ["F401"] } let pyproject = find_settings_toml(tempdir.path())?.context("Failed to find pyproject.toml")?; - let pyproject = parse_pyproject_toml(pyproject)?; + let pyproject = parse_pyproject_toml(&pyproject)?; let config = pyproject .tool .context("Expected to find [tool] field")? diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 02662ba9a4..ffebbe5f06 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -5,7 +5,7 @@ use std::cmp::Ordering; use std::collections::BTreeSet; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use anyhow::{Context, Result}; use anyhow::{anyhow, bail}; @@ -303,16 +303,57 @@ pub trait ConfigurationTransformer { fn transform(&self, config: Configuration) -> Configuration; } +/// Configurations shared during one traversal, before inheritance and overrides. +/// +/// Paths are already normalized, so the project root is part of the cache key. +/// Target-version fallbacks and CLI overrides remain specific to each chain. +#[derive(Default)] +struct ConfigurationCache { + configurations: RwLock>>, +} + +impl ConfigurationCache { + fn get_or_try_insert_with( + &self, + path: &Path, + project_root: &Path, + load: impl FnOnce() -> Result, + ) -> Result { + let key = (path.to_path_buf(), project_root.to_path_buf()); + let cached = self.configurations.read().unwrap().get(&key).cloned(); + if let Some(configuration) = cached { + return Ok((*configuration).clone()); + } + + // Parsing, conversion, and cloning stay outside the lock so unrelated + // configurations can load in parallel. + let configuration = Arc::new(load()?); + let shared = self + .configurations + .write() + .unwrap() + .entry(key) + .or_insert_with(|| Arc::clone(&configuration)) + .clone(); + Ok((*shared).clone()) + } +} + /// Recursively resolve a [`Configuration`] from a `pyproject.toml` file at the /// specified [`Path`]. -// TODO(charlie): This whole system could do with some caching. Right now, if a -// configuration file extends another in the same path, we'll re-parse the same -// file at least twice (possibly more than twice, since we'll also parse it when -// resolving the "default" configuration). pub fn resolve_configuration( initial_config_path: &Path, transformer: &dyn ConfigurationTransformer, origin: ConfigurationOrigin, +) -> Result { + resolve_configuration_with_cache(initial_config_path, transformer, origin, None) +} + +fn resolve_configuration_with_cache( + initial_config_path: &Path, + transformer: &dyn ConfigurationTransformer, + origin: ConfigurationOrigin, + configuration_cache: Option<&ConfigurationCache>, ) -> Result { let relativity = Relativity::from(origin); let mut configurations = indexmap::IndexMap::new(); @@ -329,27 +370,33 @@ pub fn resolve_configuration( )); } - let options = pyproject::load_options(&path).with_context(|| { - if configurations.is_empty() { - format!( - "Failed to load configuration `{path}`", - path = path.display() - ) - } else { - let chain = configurations - .keys() - .chain([&path]) - .map(|p| format!("`{}`", p.display())) - .join(" extends "); - format!( - "Failed to load extended configuration `{path}` ({chain})", - path = path.display() - ) - } - })?; - let project_root = relativity.resolve(&path); - let configuration = Configuration::from_options(options, Some(&path), project_root)?; + let load = || { + let options = pyproject::load_options(&path).with_context(|| { + if configurations.is_empty() { + format!( + "Failed to load configuration `{path}`", + path = path.display() + ) + } else { + let chain = configurations + .keys() + .chain([&path]) + .map(|p| format!("`{}`", p.display())) + .join(" extends "); + format!( + "Failed to load extended configuration `{path}` ({chain})", + path = path.display() + ) + } + })?; + Configuration::from_options(options, Some(&path), project_root) + }; + let configuration = if let Some(cache) = configuration_cache { + cache.get_or_try_insert_with(&path, project_root, load)? + } else { + load()? + }; // If extending, continue to collect. next = configuration.extend.as_ref().map(|extend| { @@ -383,10 +430,12 @@ fn resolve_scoped_settings( pyproject: &Path, transformer: &dyn ConfigurationTransformer, origin: ConfigurationOrigin, + configuration_cache: Option<&ConfigurationCache>, ) -> Result<(PathBuf, Settings)> { let relativity = Relativity::from(origin); - let configuration = resolve_configuration(pyproject, transformer, origin)?; + let configuration = + resolve_configuration_with_cache(pyproject, transformer, origin, configuration_cache)?; let project_root = relativity.resolve(pyproject); let settings = configuration.into_settings(project_root)?; Ok((project_root.to_path_buf(), settings)) @@ -399,7 +448,7 @@ pub fn resolve_root_settings( transformer: &dyn ConfigurationTransformer, origin: ConfigurationOrigin, ) -> Result { - let (_project_root, settings) = resolve_scoped_settings(pyproject, transformer, origin)?; + let (_project_root, settings) = resolve_scoped_settings(pyproject, transformer, origin, None)?; Ok(settings) } @@ -439,6 +488,7 @@ pub fn project_files_in_path<'a>( // Search for `pyproject.toml` files in all parent directories. let mut resolver = Resolver::new(pyproject_config); let mut seen = FxHashSet::default(); + let configuration_cache = ConfigurationCache::default(); // Insert the path to the root configuration to avoid parsing the configuration a second time. if let Some(config_path) = &pyproject_config.path { @@ -454,6 +504,7 @@ pub fn project_files_in_path<'a>( &pyproject, transformer, ConfigurationOrigin::Ancestor, + Some(&configuration_cache), )?; resolver.add(&root, settings, pyproject); // We found the closest configuration. @@ -500,7 +551,7 @@ pub fn project_files_in_path<'a>( let walker = builder.build_parallel(); // Run the `WalkParallel` to collect all files. - let state = WalkPythonFilesState::new(resolver); + let state = WalkPythonFilesState::new(resolver, configuration_cache); let mut visitor = PythonFilesVisitorBuilder::new(transformer, &state); walker.visit(&mut visitor); @@ -513,14 +564,16 @@ struct WalkPythonFilesState<'config> { is_hierarchical: bool, merged: std::sync::Mutex<(ResolvedFiles, Result<()>)>, resolver: RwLock>, + configuration_cache: ConfigurationCache, } impl<'config> WalkPythonFilesState<'config> { - fn new(resolver: Resolver<'config>) -> Self { + fn new(resolver: Resolver<'config>, configuration_cache: ConfigurationCache) -> Self { Self { is_hierarchical: resolver.is_hierarchical(), merged: std::sync::Mutex::new((Vec::new(), Ok(()))), resolver: RwLock::new(resolver), + configuration_cache, } } @@ -645,6 +698,7 @@ impl ParallelVisitor for PythonFilesVisitor<'_, '_> { &pyproject, self.transformer, ConfigurationOrigin::Ancestor, + Some(&self.global.configuration_cache), ) { Ok((root, settings)) => { self.global @@ -769,8 +823,12 @@ pub fn project_file_at_path( if resolver.is_hierarchical() { for ancestor in path.ancestors() { if let Some(pyproject) = settings_toml(ancestor)? { - let (root, settings) = - resolve_scoped_settings(&pyproject, transformer, ConfigurationOrigin::Unknown)?; + let (root, settings) = resolve_scoped_settings( + &pyproject, + transformer, + ConfigurationOrigin::Unknown, + None, + )?; resolver.add(&root, settings, pyproject); break; } diff --git a/crates/ty/CONTRIBUTING.md b/crates/ty/CONTRIBUTING.md index 0313be6d4e..fb4bc1ad56 100644 --- a/crates/ty/CONTRIBUTING.md +++ b/crates/ty/CONTRIBUTING.md @@ -25,31 +25,26 @@ that are ready for contributions. ty is written in Rust. You'll need to install the [Rust toolchain](https://www.rust-lang.org/tools/install) for development. -You'll also need [Insta](https://insta.rs/docs/) to update snapshot tests: +You'll need [uv](https://docs.astral.sh/uv/getting-started/installation/) to +run Python utility commands. uv also manages our development toolchain. + +We use [Insta](https://insta.rs/docs/) to update snapshot tests. It's already part +of the development toolchain: ```shell -cargo install cargo-insta +uv run --only-dev cargo insta --version ``` -You'll need [uv](https://docs.astral.sh/uv/getting-started/installation/) (or `pipx` and `pip`) to -run Python utility commands. - You can optionally install hooks to automatically run the validation checks when making a commit: ```shell -uv run --only-group dev --locked prek install -``` - -We recommend [nextest](https://nexte.st/) to run ty's test suite (via `cargo nextest run`), -though it's not strictly necessary: - -```shell -cargo install cargo-nextest --locked +uv run --only-dev --locked prek install ``` -Throughout this guide, any usages of `cargo test` can be replaced with `cargo nextest run`, -if you choose to install `nextest`. +We recommend [nextest](https://nexte.st/) to run ty's test suite (via `uv run --only-dev cargo nextest run`), +though it's not strictly necessary. Throughout this guide, any usages of `cargo test` can be +replaced with `uv run --only-dev cargo nextest run`. ### Development @@ -65,7 +60,7 @@ and that it passes both the lint and test validation checks: ```shell cargo clippy --workspace --all-targets --all-features -- -D warnings # Rust linting cargo test # Rust testing -uv run --only-group dev --locked prek run --all-files # Rust and Python formatting, Markdown and Python linting, etc. +uv run --only-dev --locked prek run --all-files # Rust and Python formatting, Markdown and Python linting, etc. ``` These checks will run on GitHub Actions when you open your pull request, but running them locally @@ -77,7 +72,7 @@ Note that many code changes also require updating the snapshot tests, which is d after running `cargo test` like so: ```shell -cargo insta review +uv run --only-dev cargo insta review ``` Include the text `[ty]` at the beginning of your pull request title, to distinguish ty pull requests diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml index 30e5998bad..0e9e756d46 100644 --- a/crates/ty/Cargo.toml +++ b/crates/ty/Cargo.toml @@ -79,10 +79,11 @@ insta-cmd = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +zip = { workspace = true } [features] default = [] -test-uv = [] +test-uv = ["ty_server/test-uv"] [lints] workspace = true diff --git a/crates/ty/build.rs b/crates/ty/build.rs index 69afef2fd8..26901b6783 100644 --- a/crates/ty/build.rs +++ b/crates/ty/build.rs @@ -14,9 +14,10 @@ fn main() { version_info(&ty_workspace_root); - // If not in a git repository, do not attempt to retrieve commit information + // An independent ty checkout has its own dist-workspace.toml. Without one, a parent Git + // repository is unrelated, so use the nested Ruff checkout's commit information instead. let git_dir = ty_workspace_root.join(".git"); - if git_dir.exists() { + if ty_workspace_root.join("dist-workspace.toml").is_file() && git_dir.exists() { commit_info(&git_dir, &ty_workspace_root, false); } else { // Try if we're inside the ruff repository and, if so, use that commit hash. @@ -61,18 +62,17 @@ fn commit_info(git_dir: &Path, workspace_root: &Path, is_ruff: bool) { if let Some(git_head_path) = git_head(git_dir) { println!("cargo:rerun-if-changed={}", git_head_path.display()); - let git_head_contents = fs::read_to_string(git_head_path); + let git_head_contents = fs::read_to_string(&git_head_path); if let Ok(git_head_contents) = git_head_contents { // The contents are either a commit or a reference in the following formats // - "" when the head is detached - // - "ref " when working on a branch + // - "ref: " when working on a branch // If a commit, checking if the HEAD file has changed is sufficient - // If a ref, we need to add the head file for that ref to rebuild on commit + // If a ref, we also need to watch where Git stores its current commit let mut git_ref_parts = git_head_contents.split_whitespace(); git_ref_parts.next(); if let Some(git_ref) = git_ref_parts.next() { - let git_ref_path = git_dir.join(git_ref); - println!("cargo:rerun-if-changed={}", git_ref_path.display()); + watch_git_ref(&git_head_path, git_ref); } } } @@ -117,27 +117,74 @@ fn commit_info(git_dir: &Path, workspace_root: &Path, is_ruff: bool) { fn git_head(git_dir: &Path) -> Option { // The typical case is a standard git repository. - let git_head_path = git_dir.join("HEAD"); - if git_head_path.exists() { - return Some(git_head_path); + if git_dir.is_dir() { + return Some(git_dir.join("HEAD")); } if !git_dir.is_file() { return None; } - // If `.git/HEAD` doesn't exist and `.git` is actually a file, - // then let's try to attempt to read it as a worktree. If it's - // a worktree, then its contents will look like this, e.g.: + + // Watch the pointer in case the worktree's Git directory changes. + println!("cargo:rerun-if-changed={}", git_dir.display()); + // A linked worktree has a `.git` file instead of a `.git` directory. + // Its contents point to the worktree-specific Git directory, e.g.: // - // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 + // gitdir: /home/andrew/astral/ruff/main/.git/worktrees/pr2 // // And the HEAD file we want to watch will be at: // - // /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD + // /home/andrew/astral/ruff/main/.git/worktrees/pr2/HEAD let contents = fs::read_to_string(git_dir).ok()?; let (label, worktree_path) = contents.split_once(':')?; if label != "gitdir" { return None; } - let worktree_path = worktree_path.trim(); - Some(PathBuf::from(worktree_path)) + // Relative `gitdir:` paths are relative to the directory containing `.git`. + let worktree_path = PathBuf::from(worktree_path.trim()); + let worktree_path = if worktree_path.is_absolute() { + worktree_path + } else { + git_dir.parent()?.join(worktree_path) + }; + Some(worktree_path.join("HEAD")) +} + +/// Watch the loose or packed Git reference for the current branch. +fn watch_git_ref(git_head_path: &Path, git_ref: &str) { + let Some(worktree_git_dir) = git_head_path.parent() else { + return; + }; + + // Worktrees have their own HEAD, but branch refs live in the shared Git directory. Their + // `commondir` file points to that directory, either absolutely or relative to this Git directory. + let common_dir_path = worktree_git_dir.join("commondir"); + let common_git_dir = if let Ok(common_dir) = fs::read_to_string(&common_dir_path) { + println!("cargo:rerun-if-changed={}", common_dir_path.display()); + let common_dir = PathBuf::from(common_dir.trim()); + if common_dir.is_absolute() { + common_dir + } else { + worktree_git_dir.join(common_dir) + } + } else { + worktree_git_dir.to_path_buf() + }; + + let git_ref_path = common_git_dir.join(git_ref); + if git_ref_path.exists() { + println!("cargo:rerun-if-changed={}", git_ref_path.display()); + } else { + // A packed branch ref has no loose ref file. Watch `packed-refs` instead of the missing + // loose ref, since Cargo would rebuild on every invocation for a nonexistent watched path. + let packed_refs = common_git_dir.join("packed-refs"); + if packed_refs.exists() { + println!("cargo:rerun-if-changed={}", packed_refs.display()); + } + // A later commit can recreate the loose ref, even when its parent directories do not exist + // yet. Watch the nearest existing ancestor so Cargo notices that transition. This can + // also rebuild when another ref in that directory changes. + if let Some(parent) = git_ref_path.ancestors().find(|parent| parent.is_dir()) { + println!("cargo:rerun-if-changed={}", parent.display()); + } + } } diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index f4fdacf6c0..cef194bf2a 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -1208,6 +1208,10 @@ your environment from an activated Conda environment, and will look for a `.venv in the project root if none of the above apply. Failing that, ty will look for a `python3` or `python` binary available in `PATH`. +Scripts with inline metadata use their own Python environment. They can use an explicitly +configured environment, an activated environment, or an environment selected by the editor. +Unlike projects, they do not automatically use a `.venv` directory. + [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix **Default value**: `null` @@ -1290,6 +1294,9 @@ to determine a value: and attempt to infer the Python version of that environment 3. Fall back to the default value (see below) +Scripts with inline metadata use their `requires-python` field instead of +`project.requires-python`. They do not inherit the Python version of the enclosing project. + For some language features, ty can also understand conditionals based on comparisons with `sys.version_info`. These are commonly found in typeshed, for example, to reflect the differing contents of the standard library across Python versions. @@ -1330,6 +1337,9 @@ if they exist and are not packages (i.e. they do not contain `__init__.py` or `_ * `./` (if a `.//` directory exists) * `./python` +Scripts with inline metadata have no first-party roots by default because they are +single-file programs. Set `root = ["."]` to allow importing local modules. + **Default value**: `null` **Type**: `list[str]` diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 700a118e2f..ff4daac4a3 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.64 · Related issues · -View source +View source @@ -20,9 +20,9 @@ Checks for methods decorated with both `@abstractmethod` and `@final`. **Why is this bad?** -An abstract method must be overridden for a subclass to become concrete, but a final -method cannot be overridden. Combining the decorators therefore makes it impossible -for a subclass to provide a concrete implementation. +An abstract method must be overridden for a subclass to become concrete, but a final method cannot +be overridden. Combining the decorators therefore makes it impossible for a subclass to provide a +concrete implementation. **Example** @@ -44,7 +44,7 @@ class Base(ABC): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -56,14 +56,14 @@ Checks for `@final` classes that have unimplemented abstract methods. **Why is this bad?** -A class decorated with `@final` cannot be subclassed. If such a class has abstract -methods that are not implemented, the class can never be properly instantiated, as -the abstract methods can never be implemented (since subclassing is prohibited). +A class decorated with `@final` cannot be subclassed. If such a class has abstract methods that are +not implemented, the class can never be properly instantiated, as the abstract methods can never be +implemented (since subclassing is prohibited). -At runtime, instantiation of classes with unimplemented abstract methods is only -prevented for classes that have `ABCMeta` (or a subclass of it) as their metaclass. -However, type checkers also enforce this for classes that do not use `ABCMeta`, since -the intent for the class to be abstract is clear from the use of `@abstractmethod`. +At runtime, instantiation of classes with unimplemented abstract methods is only prevented for +classes that have `ABCMeta` (or a subclass of it) as their metaclass. However, type checkers also +enforce this for classes that do not use `ABCMeta`, since the intent for the class to be abstract is +clear from the use of `@abstractmethod`. **Example** @@ -90,7 +90,7 @@ class Derived(Base): # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -123,7 +123,7 @@ f(1, b=s1) # ok — explicit Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -159,7 +159,7 @@ report(Celsius()) # error: two conversions apply Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -194,7 +194,7 @@ extension list: Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -206,10 +206,10 @@ Checks for protocol classes with members that will lead to ambiguous interfaces. **Why is this bad?** -Assigning to an undeclared variable in a protocol class, or to an undeclared attribute -through a protocol method's `self` or `cls` receiver, leads to an ambiguous interface -which may lead to the type checker inferring unexpected things. It's recommended to -ensure that all members of a protocol class are explicitly declared. +Assigning to an undeclared variable in a protocol class, or to an undeclared attribute through a +protocol method's `self` or `cls` receiver, leads to an ambiguous interface which may lead to the +type checker inferring unexpected things. It's recommended to ensure that all members of a protocol +class are explicitly declared. **Examples** @@ -258,25 +258,24 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source **What it does** -Checks for `assert_type()` calls where the actual type -is an unspellable subtype of the asserted type. +Checks for `assert_type()` calls where the actual type is an unspellable subtype of the asserted +type. **Why is this bad?** -`assert_type()` is intended to ensure that the inferred type of a value -is exactly the same as the asserted type. But in some situations, ty -has nonstandard extensions to the type system that allow it to infer -more precise types than can be expressed in user annotations. ty emits a -different error code to [`type-assertion-failure`](#type-assertion-failure) in these situations so -that users can easily differentiate between the two cases. +`assert_type()` is intended to ensure that the inferred type of a value is exactly the same as the +asserted type. But in some situations, ty has nonstandard extensions to the type system that allow +it to infer more precise types than can be expressed in user annotations. ty emits a different error +code to [`type-assertion-failure`](#type-assertion-failure) in these situations so that users can easily differentiate between +the two cases. **Example** @@ -317,9 +316,9 @@ Checks for `ty: ignore` comments that don't specify which rules to ignore. **Why is this bad?** -A blanket `ty: ignore` comment suppresses every type-checking diagnostic on the -applicable line or file. Specifying rule codes documents which diagnostics are -expected and prevents the comment from silencing unrelated errors. +A blanket `ty: ignore` comment suppresses every type-checking diagnostic on the applicable line or +file. Specifying rule codes documents which diagnostics are expected and prevents the comment from +silencing unrelated errors. **Examples** @@ -341,7 +340,7 @@ value = unknown # ty: ignore[unresolved-reference] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -384,37 +383,34 @@ a4 = True + 1 # ok — a boolean used as a boolean Default level: error · Added in 0.0.16 · Related issues · -View source +View source **What it does** -Checks for calls to abstract `@classmethod`s or `@staticmethod`s -with "trivial bodies" when accessed on the class object itself. +Checks for calls to abstract `@classmethod`s or `@staticmethod`s with "trivial bodies" when accessed +on the class object itself. -"Trivial bodies" are bodies that solely consist of `...`, `pass`, -a docstring, and/or `raise NotImplementedError`. +"Trivial bodies" are bodies that solely consist of `...`, `pass`, a docstring, and/or +`raise NotImplementedError`. **Why is this bad?** -An abstract method with a trivial body has no concrete implementation -to execute, so calling such a method directly on the class will probably -not have the desired effect. +An abstract method with a trivial body has no concrete implementation to execute, so calling such a +method directly on the class will probably not have the desired effect. -It is also unsound to call these methods directly on the class. Unlike -other methods, ty permits abstract methods with trivial bodies to have -non-`None` return types even though they always return `None` at runtime. -This is because it is expected that these methods will always be -overridden rather than being called directly. As a result of this -exception to the normal rule, ty may infer an incorrect type if one of -these methods is called directly, which may then mean that type errors +It is also unsound to call these methods directly on the class. Unlike other methods, ty permits +abstract methods with trivial bodies to have non-`None` return types even though they always return +`None` at runtime. This is because it is expected that these methods will always be overridden +rather than being called directly. As a result of this exception to the normal rule, ty may infer an +incorrect type if one of these methods is called directly, which may then mean that type errors elsewhere in your code go undetected by ty. -Calling abstract classmethods or staticmethods via `type[X]` is allowed, -since the actual runtime type could be a concrete subclass with an implementation. +Calling abstract classmethods or staticmethods via `type[X]` is allowed, since the actual runtime +type could be a concrete subclass with an implementation. **Example** @@ -439,7 +435,7 @@ Foo.method() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -467,24 +463,24 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source **What it does** -Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all -callable types with return type `T`). +Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all callable +types with return type `T`). **Why is this bad?** When an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or -`isinstance(x, Callable)`), we know the object is callable, but we don't know its -precise signature. This type represents the set of all possible callable types -(including, e.g., functions that take no arguments and functions that require arguments), -so no specific set of arguments can be guaranteed to be valid. +`isinstance(x, Callable)`), we know the object is callable, but we don't know its precise signature. +This type represents the set of all possible callable types (including, e.g., functions that take no +arguments and functions that require arguments), so no specific set of arguments can be guaranteed +to be valid. **Examples** @@ -502,7 +498,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -514,9 +510,8 @@ Checks whether a variable has been declared as two conflicting types. **Why is this bad** -A variable with two conflicting declarations likely indicates a mistake. -Moreover, it could lead to incorrect or ill-defined type inference for -other code that relies on these variables. +A variable with two conflicting declarations likely indicates a mistake. Moreover, it could lead to +incorrect or ill-defined type inference for other code that relies on these variables. **Examples** @@ -536,16 +531,15 @@ a = 1 # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for class definitions where the metaclass of the class -being created would not be a subclass of the metaclasses of -all the class's bases. +Checks for class definitions where the metaclass of the class being created would not be a subclass +of the metaclasses of all the class's bases. **Why is it bad?** @@ -571,22 +565,20 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for class definitions in stub files that inherit -(directly or indirectly) from themselves. +Checks for class definitions in stub files that inherit (directly or indirectly) from themselves. **Why is it bad?** -Although forward references are natively supported in stub files, -inheritance cycles are still disallowed, as it is impossible to -resolve a consistent [method resolution order] for a class that +Although forward references are natively supported in stub files, inheritance cycles are still +disallowed, as it is impossible to resolve a consistent [method resolution order] for a class that inherits from itself. **Examples** @@ -607,20 +599,21 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source **What it does** -Checks for type alias definitions that (directly or mutually) refer to themselves. +Checks for circular type alias definitions. **Why is it bad?** -Although it is permitted to define a recursive type alias, it is not meaningful -to have a type alias whose expansion can only result in itself, and is therefore not allowed. +Recursive aliases are valid when recursive references occur inside another type, such as +`list[Tree]`. An alias cannot expand directly to itself or include itself as a union member. This +applies to both `type` statements and aliases created with `TypeAliasType`. **Examples** @@ -631,10 +624,18 @@ python-version = "3.12" ``` ```python +from typing import TypeAliasType + type Itself = Itself # error type A = B # error type B = A # error + +type IntOr = int | IntOr # error + +Cycle = TypeAliasType("Cycle", "Cycle") # error + +type Tree = int | list[Tree] # valid recursive alias ``` ## `dataclass-field-order` @@ -643,22 +644,21 @@ type B = A # error Default level: error · Added in 0.0.15 · Related issues · -View source +View source **What it does** -Checks for dataclass definitions where required fields are defined after -fields with default values. +Checks for dataclass definitions where required fields are defined after fields with default values. **Why is this bad?** -In dataclasses, all required fields (fields without default values) must be -defined before fields with default values. This is a Python requirement that -will raise a `TypeError` at runtime if violated. +In dataclasses, all required fields (fields without default values) must be defined before fields +with default values. This is a Python requirement that will raise a `TypeError` at runtime if +violated. **Example** @@ -680,7 +680,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -713,13 +713,185 @@ def old_func(): ... old_func() # error: [deprecated] ``` +## `disjoint-cast` + + +Default level: ignore · +Added in 0.0.78 · +Related issues · +View source + + + +**What it does** + + +Detects `cast` calls where the inferred type of the value is disjoint from the destination type. + +Two types are disjoint if they are entirely non-overlapping. For example, `str` and `int` are +disjoint types because it is impossible to create a Python object that is both a `str` and an `int` +at the same time: Python forbids multiple inheritance between these two classes: + +```pycon +>>> class StrAndInt(int, str): ... +Traceback (most recent call last): + File "", line 1, in + class StrAndInt(int, str): ... +TypeError: multiple bases have instance lay-out conflict +``` + +This means that any object of type `int` can never also be of type `str`, and any object of type +`str` can never also inhabit the type `int`. The only common subtype of these two types is +[`Never`][never], the uninhabited type, which has no members. + +**Why is this bad?** + + +`cast()` is deliberately designed as an "escape hatch" in the type system that is neither validated +at runtime nor, by default, by type checkers. While upcasting to a supertype is always sound, and +casting to a subtype can be sound in some situations if accompanied by careful validation checks, +`cast()` is also deliberately designed to allow unsound narrowing, and most useful applications of +`cast()` in real-world code cannot be fully validated by a type checker. + +Nonetheless, even while acknowledging the fact that `cast()` is intentionally designed to allow +unsoundness, casting a value to an entirely *disjoint* type is especially likely to indicate a +mistake in your code. A cast from an `int` to a `str`, for example, likely indicates a bug or +misunderstanding. + +This rule therefore provides a means for codebases to partially validate their uses of `cast()` +without banning the API -- or even banning all unsound uses of the API -- entirely. + +**Example** + + +```py +from typing import cast + + +def parse(value: int) -> str: + return cast(str, value) # error: [disjoint-cast] +``` + +Casts between overlapping (non-disjoint) types are allowed: + +```py +from collections.abc import Sequence +from typing import cast + + +def validate(numbers: Sequence[int | None]) -> Sequence[int]: + if None in numbers: + raise TypeError("must provide a sequence of numbers!") + return cast(Sequence[int], numbers) +``` + +Note that disjointness between types can sometimes be surprising. For example, `list[int]` is +disjoint from `list[bool]` even though `bool` is a subtype of `int`. Due to the fact that `list` is +[mutable and invariant], it would be deeply unsound for ty to ever narrow an object of type +`list[int]` to the type `list[bool]`. As such, ty will complain about a cast from `list[int]` to +`list[bool]` when this rule is enabled. + +Similarly, two `NewType`s can be disjoint even when they share the same underlying nominal base +type, unless one `NewType` is explicitly declared as a sub-newtype of the other. + +```py +from typing import NewType, cast + + +UserId = NewType("UserId", int) +ProUserId = NewType("ProUserId", int) + + +def f(x: list[int], user_id: UserId): + y = cast(list[bool], x) # error: [disjoint-cast] + pro_user_id = cast(ProUserId, user_id) # error: [disjoint-cast] +``` + +**Alternatives** + + +In many cases, the diagnostic can be avoided by switching to use covariant generic types rather than +invariant ones: + +```py +# `Sequence`, unlike `list`, is immutable and covariant +from collections.abc import Sequence +from typing import cast + + +def f(x: Sequence[int]): + y = cast(Sequence[bool], x) # no diagnostic +``` + +Though if you're able to use covariant types, a type-safe narrowing mechanism that provides runtime +validation, such as using `TypeIs`, is generally preferable to using `cast`: + +```py +# `Sequence`, unlike `list`, is immutable and covariant +from collections.abc import Sequence +from typing_extensions import TypeIs, reveal_type + + +def is_sequence_of_bools(x: Sequence[int]) -> TypeIs[Sequence[bool]]: + return all(isinstance(item, bool) for item in x) + + +def f(x: Sequence[int]): + assert is_sequence_of_bools(x) + reveal_type(x) # revealed: Sequence[bool] +``` + +If you're unable to switch to an immutable, covariant generic type, other solutions to this +particular diagnostic might include assigning a new list altogether: + +```py +def f(x: list[int]): + y: list[bool] = [] + for item in x: + assert isinstance(item, bool) + y.append(item) +``` + +Or using a `TypeGuard`. While the "narrowing" below is still unsound, there is at least some runtime +validation of the element types taking place, making it superior to the `cast`: + +```py +from typing_extensions import TypeGuard, reveal_type + + +def is_list_of_bools(x: list[int]) -> TypeGuard[list[bool]]: + return all(isinstance(item, bool) for item in x) + + +def f(x: list[int]): + assert is_list_of_bools(x) + reveal_type(x) # revealed: list[bool] +``` + +**Default level** + + +This rule is disabled by default. It is designed as a strict rule for users who want additional +soundness checks from their type checker, and it may have false positives in some situations. + +**See also** + + +- The Ruff rule [`banned-api`][banned-api] can be used to ban the use of `cast()` entirely in your + codebase. +- [`redundant-cast`](#redundant-cast) detects casts where the value already has the destination type. + +[banned-api]: https://docs.astral.sh/ruff/rules/banned-api/ +[mutable and invariant]: https://docs.astral.sh/ty/reference/typing-faq/#invariant-generics +[never]: https://docs.python.org/3/library/typing.html#typing.Never + ## `division-by-zero` Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -736,8 +908,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. **Rule status** -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. **Examples** @@ -752,7 +923,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -783,25 +954,22 @@ class B(A, A): ... # error Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source **What it does** -Checks for dataclass definitions with more than one field -annotated with `KW_ONLY`. +Checks for dataclass definitions with more than one field annotated with `KW_ONLY`. **Why is this bad?** -`dataclasses.KW_ONLY` is a special marker used to -emulate the `*` syntax in normal signatures. -It can only be used once per dataclass. +`dataclasses.KW_ONLY` is a special marker used to emulate the `*` syntax in normal signatures. It +can only be used once per dataclass. -Attempting to annotate two different fields with -it will lead to a runtime error. +Attempting to annotate two different fields with it will lead to a runtime error. **Examples** @@ -820,13 +988,124 @@ class A: # error d: bytes ``` +## `dynamic-function-decorator-return` + + +Default level: ignore · +Added in 0.0.73 · +Related issues · +View source + + + +**What it does** + + +Detects decorator applications that replace a function with `Any` or another [dynamic type]. + +**Why is this bad?** + + +A decorator can replace the function it receives with any object. Type checkers therefore use the +decorator's return type as the type of the decorated function. If the decorator returns `Any` or +`Unknown`, the original type is lost, along with the type checker's ability to catch invalid calls +and attribute accesses. basedpython infers an unannotated return, so a decorator reaches this state +by saying `Any` outright, or by coming from code the checker cannot read: + +```py +from collections.abc import Callable +from typing import Any + + +def untyped_decorator(function: Callable[..., object]) -> Any: + return function + + +# error: "Decorator returns `Any`" +@untyped_decorator +def stringify(value: int) -> str: + return str(value) + + +# No type error is reported, even though `stringify` expects an integer. +stringify("not an integer") +``` + +This rule identifies the point where a decorator erases useful type information, before that +imprecision spreads to every use of the decorated function. It can be especially useful in cases +where the decorator is defined in a third-party library. Whereas linter rules such as +[`ANN201`][ann201] and [`ANN202`][ann202] can complain about missing annotations in your first-party +code, they cannot identify instances where unsound types leak into your code due to missing type +annotations in third-party code installed into `site-packages`. + +**Examples** + + +`third_party_library.py`: + +```py +from collections.abc import Callable +from typing import Any + + +def untyped_decorator(function: Callable[..., object]) -> Any: + return function +``` + +`first_party.py`: + +```py +from third_party_library import untyped_decorator + + +# error: "Decorator returns `Any`" +@untyped_decorator +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +If making a PR to the third-party library to improve their annotations is not possible, fixes for +this diagnostic could include writing your own decorator or introducing a type-safe wrapper: + +```py +from collections.abc import Callable +from typing import TypeVar + +from third_party_library import untyped_decorator + + +FunctionT = TypeVar("FunctionT", bound=Callable[..., object]) + + +def typed_wrapper(f: FunctionT) -> FunctionT: + decorated = untyped_decorator(f) + assert decorated is f + return decorated + + +@typed_wrapper +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +**Default level** + + +This rule is disabled by default. It is intended for advanced users wanting additional soundness +checks from their type checker, not for users who have just started to use type checkers on their +Python code. + +[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ +[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ +[dynamic type]: https://typing.python.org/en/latest/spec/glossary.html#term-dynamic-type + ## `empty-body` Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -835,22 +1114,21 @@ Added in 0.0.14 str: # error: [empty-body] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -937,7 +1215,7 @@ def h(x: object): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -1039,7 +1317,7 @@ def foo() -> "intt\b": ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1078,7 +1356,7 @@ def f(local fn: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1119,7 +1397,7 @@ for x in [1, 2, 3]: Default level: warn · Added in 0.0.50 · Related issues · -View source +View source @@ -1159,7 +1437,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax] Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -1171,9 +1449,8 @@ Checks for `@final` decorators applied to non-method functions. **Why is this bad?** -The `@final` decorator is only meaningful on methods and classes. -Applying it to a module-level function or a nested function has no -effect and is likely a mistake. +The `@final` decorator is only meaningful on methods and classes. Applying it to a module-level +function or a nested function has no effect and is likely a mistake. **Example** @@ -1194,7 +1471,7 @@ def my_function() -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.40 · Related issues · -View source +View source @@ -1229,23 +1506,23 @@ let a = 1 Default level: error · Added in 0.0.15 · Related issues · -View source +View source **What it does** -Checks for `Final` symbols that are declared without a value and are never -assigned a value in their scope. +Checks for `Final` symbols that are declared without a value and are never assigned a value in their +scope. **Why is this bad?** -A `Final` symbol must be initialized with a value at the time of declaration -or in a subsequent assignment. At module or function scope, the assignment must -occur in the same scope. In a class body, the assignment may occur in `__init__`. -Protocol members are declarations of an interface and do not require a value. +A `Final` symbol must be initialized with a value at the time of declaration or in a subsequent +assignment. At module or function scope, the assignment must occur in the same scope. In a class +body, the assignment may occur in `__init__`. Protocol members are declarations of an interface and +do not require a value. **Examples** @@ -1273,13 +1550,14 @@ Added in 0. **What it does** -Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule. +Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint +rule. **Why is this bad?** -A `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match -any known rule will not suppress any type errors, and is probably a mistake. +A `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match any +known rule will not suppress any type errors, and is probably a mistake. **Examples** @@ -1345,7 +1623,7 @@ def test() -> "Literal[5]": Default level: ignore · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -1357,23 +1635,21 @@ Checks for a variable that a basedpython file assigns without ever declaring it. **Why is this bad?** -Python introduces a variable by assigning to it, so a typo makes a new variable -rather than an error, and reading a statement tells you nothing about whether -the name is new or one you have seen before. +Python introduces a variable by assigning to it, so a typo makes a new variable rather than an +error, and reading a statement tells you nothing about whether the name is new or one you have seen +before. -basedpython has a keyword for each: `let` for a binding that never changes, and -`var` for one that does. With this rule on, every variable a scope binds has to -be declared once with one of them, and every later assignment is visibly a -re-assignment. +basedpython has a keyword for each: `let` for a binding that never changes, and `var` for one that +does. With this rule on, every variable a scope binds has to be declared once with one of them, and +every later assignment is visibly a re-assignment. -This rule is off by default, because a file written without the keywords is -valid basedpython. +This rule is off by default, because a file written without the keywords is valid basedpython. **Examples** -Every assignment to a name the scope never declares is reported, so a variable -introduced this way is reported wherever it is written: +Every assignment to a name the scope never declares is reported, so a variable introduced this way +is reported wherever it is written: `undeclared.by`: @@ -1391,8 +1667,8 @@ var count = 0 count = count + 1 ``` -An assignment to something other than a plain name — an attribute, a subscript, -an item of an unpacking — is not a declaration, and is never reported. +An assignment to something other than a plain name — an attribute, a subscript, an item of an +unpacking — is not a declaration, and is never reported. ## `implicit-object-repr` @@ -1400,7 +1676,7 @@ an item of an unpacking — is not a declaration, and is never reported. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -1475,7 +1751,7 @@ print(Labelled) # warning: prints `` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1511,15 +1787,14 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for attempts to use an out of bounds index to get an item from -a container. +Checks for attempts to use an out of bounds index to get an item from a container. **Why is this bad?** @@ -1541,7 +1816,7 @@ t[3] # error Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -1553,9 +1828,9 @@ Checks for calls to `final()` that type checkers cannot interpret. **Why is this bad?** -The `final()` function is designed to be used as a decorator. When called directly -as a function (e.g., `final(type(...))`), type checkers will not understand the -application of `final` and will not prevent subclassing. +The `final()` function is designed to be used as a decorator. When called directly as a function +(e.g., `final(type(...))`), type checkers will not understand the application of `final` and will +not prevent subclassing. **Example** @@ -1578,36 +1853,32 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source **What it does** -Checks for classes definitions which will fail at runtime due to -"instance memory layout conflicts". +Checks for classes definitions which will fail at runtime due to "instance memory layout conflicts". -This error is usually caused by attempting to combine multiple classes -that define non-empty `__slots__` in a class's [Method Resolution Order][method-resolution-order] -(MRO), or by attempting to combine multiple builtin classes in a class's -MRO. +This error is usually caused by attempting to combine multiple classes that define non-empty +`__slots__` in a class's [Method Resolution Order][method-resolution-order] (MRO), or by attempting +to combine multiple builtin classes in a class's MRO. **Why is this bad?** -Inheriting from bases with conflicting instance memory layouts -will lead to a `TypeError` at runtime. +Inheriting from bases with conflicting instance memory layouts will lead to a `TypeError` at +runtime. -An instance memory layout conflict occurs when CPython cannot determine -the memory layout instances of a class should have, because the instance -memory layout of one of its bases conflicts with the instance memory layout -of one or more of its other bases. +An instance memory layout conflict occurs when CPython cannot determine the memory layout instances +of a class should have, because the instance memory layout of one of its bases conflicts with the +instance memory layout of one or more of its other bases. -For example, if a Python class defines non-empty `__slots__`, this will -impact the memory layout of instances of that class. Multiple inheritance -from more than one different class defining non-empty `__slots__` is not -allowed: +For example, if a Python class defines non-empty `__slots__`, this will impact the memory layout of +instances of that class. Multiple inheritance from more than one different class defining non-empty +`__slots__` is not allowed: ```python class A: @@ -1622,17 +1893,16 @@ class B: class C(A, B): ... # error ``` -An instance layout conflict can also be caused by attempting to use -multiple inheritance with two builtin classes, due to the way that these -classes are implemented in a CPython C extension: +An instance layout conflict can also be caused by attempting to use multiple inheritance with two +builtin classes, due to the way that these classes are implemented in a CPython C extension: ```python # TypeError: multiple bases have instance lay-out conflict class A(int, float): ... # error ``` -Note that pure-Python classes with no `__slots__`, or pure-Python classes -with empty `__slots__`, are always compatible: +Note that pure-Python classes with no `__slots__`, or pure-Python classes with empty `__slots__`, +are always compatible: ```python class A: ... @@ -1653,17 +1923,16 @@ class D(A, B, C): ... **Known problems** -Classes that have "dynamic" definitions of `__slots__` (definitions do not consist -of string literals, or tuples of string literals) are not currently considered disjoint -bases by ty. +Classes whose `__slots__` values cannot be determined statically are not always considered disjoint +bases by ty. Static definitions can include string literals, fixed-length tuples, and literal lists, +sets, or dictionaries of string literals. -Additionally, this check is not exhaustive: many C extensions (including several in -the standard library) define classes that use extended memory layouts and thus cannot -coexist in a single MRO. Since it is currently not possible to represent this fact in -stub files, having a full knowledge of these classes is also impossible. When it comes -to classes that do not define `__slots__` at the Python level, therefore, ty, currently -only hard-codes a number of cases where it knows that a class will produce instances with -an atypical memory layout. +Additionally, this check is not exhaustive: many C extensions (including several in the standard +library) define classes that use extended memory layouts and thus cannot coexist in a single MRO. +Since it is currently not possible to represent this fact in stub files, having a full knowledge of +these classes is also impossible. When it comes to classes that do not define `__slots__` at the +Python level, therefore, ty, currently only hard-codes a number of cases where it knows that a class +will produce instances with an atypical memory layout. **Further reading** @@ -1679,7 +1948,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1691,9 +1960,9 @@ Detects call arguments whose type is not assignable to the corresponding typed p **Why is this bad?** -Passing an argument of a type the function (or callable object) does not accept violates -the expectations of the function author and may cause unexpected runtime errors within the -body of the function. +Passing an argument of a type the function (or callable object) does not accept violates the +expectations of the function author and may cause unexpected runtime errors within the body of the +function. **Examples** @@ -1711,21 +1980,20 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for assignments where the type of the value -is not [assignable to] the type of the assignee. +Checks for assignments where the type of the value is not [assignable to] the type of the assignee. **Why is this bad?** -Such assignments break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Such assignments break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. **Examples** @@ -1742,24 +2010,30 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for assignments to class variables from instances -and assignments to instance-only attributes from their class. +Checks for assignments to class variables from instances and assignments to instance-only attributes +from their class. Also checks for reads and writes of generic instance attributes through a generic +class or a specialized generic alias. + +An "instance-only" variable is one which is only ever assigned to or declared when accessed via +`self` in an instance method. -An "instance-only" variable is one which is only ever assigned to or declared -when accessed via `self` in an instance method. +A generic instance attribute has a type that depends on the class's type parameters. Specializing a +generic class does not create separate class attribute storage, so these attributes cannot be +accessed through the generic class or a specialized alias. Access through a `type[...]` receiver is +allowed because it can refer to a concrete subclass with its own class attributes. **Why is this bad?** -Incorrect assignments break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Incorrect assignments break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. **Examples** @@ -1794,32 +2068,48 @@ C().class_var = 3 # error C.instance_only_var = 56 # error ``` +```python +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Box(Generic[T]): + value: T + + +Box[int].value = 1 # error +Box.value # error + +box = Box[int]() +box.value = 1 # okay +``` + ## `invalid-attribute-override` Default level: error · Added in 0.0.33 · Related issues · -View source +View source **What it does** -Detects attribute overrides that change whether an inherited attribute -is a class variable or an instance variable. +Detects attribute overrides that change whether an inherited attribute is a class variable or an +instance variable. -This rule currently only covers class-variable and instance-variable -category changes. +This rule currently only covers class-variable and instance-variable category changes. **Why is this bad?** -Pure class variables and instance variables have different access and -assignment behavior. Overriding one with the other violates the -[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"), because code that is valid for -the superclass may no longer be valid for the subclass. +Pure class variables and instance variables have different access and assignment behavior. +Overriding one with the other violates the +[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"), because code that is valid +for the superclass may no longer be valid for the subclass. **Example** @@ -1846,7 +2136,7 @@ class Sub(Base): Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1888,7 +2178,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1915,7 +2205,7 @@ class A(42): ... # error: [invalid-base] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -1953,7 +2243,7 @@ build: # error: `build` is an experimental feature, and is off Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.5 · Related issues · -View source +View source @@ -1990,15 +2280,14 @@ extension str(A): # error: `str` does not answer every member of `A` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for expressions used in `with` statements -that do not implement the context manager protocol. +Checks for expressions used in `with` statements that do not implement the context manager protocol. **Why is this bad?** @@ -2020,7 +2309,7 @@ with 1: # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -2053,7 +2342,7 @@ class Fahrenheit: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -2065,17 +2354,17 @@ Checks for invalid applications of the `@dataclass` decorator. **Why is this bad?** -Applying `@dataclass` with incompatible arguments raises an exception while creating the -class: +Applying `@dataclass` with incompatible arguments raises an exception while creating the class: - `order=True` with `eq=False` - `weakref_slot=True` with `slots=False` +- `slots=True` when the class already defines `__slots__` -Applying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`, -`Enum`, or `Protocol` is also invalid: +Applying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`, `Enum`, or `Protocol` +is also invalid: -- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when - instantiating the class. +- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when instantiating the + class. - `Enum` classes with `@dataclass` are [explicitly not supported]. - `Protocol` classes define interfaces and cannot be instantiated. @@ -2106,7 +2395,7 @@ See: Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -2142,21 +2431,21 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for declarations where the inferred type of an existing symbol -is not [assignable to] its post-hoc declared type. +Checks for declarations where the inferred type of an existing symbol is not [assignable to] its +post-hoc declared type. **Why is this bad?** -Such declarations break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Such declarations break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. **Examples** @@ -2174,7 +2463,7 @@ a: str # error Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -2186,13 +2475,12 @@ Checks for enum members that have explicit type annotations. **Why is this bad?** -The [typing spec] states that type checkers should infer a literal type -for all enum members. An explicit type annotation on an enum member is -misleading because the annotated type will be incorrect — the actual -runtime type is the enum class itself, not the annotated type. +The [typing spec] states that type checkers should infer a literal type for all enum members. An +explicit type annotation on an enum member is misleading because the annotated type will be +incorrect — the actual runtime type is the enum class itself, not the annotated type. -In CPython's `enum` module, annotated assignments with values are still -treated as members at runtime, but the annotation will confuse readers of the code. +In CPython's `enum` module, annotated assignments with values are still treated as members at +runtime, but the annotation will confuse readers of the code. **Examples** @@ -2231,7 +2519,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2287,7 +2575,8 @@ except ZeroDivisionError: **Ruff rule** -This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) +This rule corresponds to Ruff's +[`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) ## `invalid-explicit-override` @@ -2295,20 +2584,21 @@ This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](h Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source **What it does** -Checks for methods that are decorated with `@override` but do not override any method in a superclass. +Checks for methods that are decorated with `@override` but do not override any method in a +superclass. **Why is this bad?** -Decorating a method with `@override` declares to the type checker that the intention is that it should -override a method from a superclass. +Decorating a method with `@override` declares to the type checker that the intention is that it +should override a method from a superclass. **Example** @@ -2348,7 +2638,7 @@ class D(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -2381,7 +2671,7 @@ extension list[T: int]: # error: `list` declares no type parameter `T` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -2410,7 +2700,7 @@ Author.objects.filter(name__startswith=1) # error: lookup wants `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -2446,7 +2736,7 @@ def test_user(user: int) -> None: # error: fixture provides `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -2494,7 +2784,7 @@ f"{'name':>10}" # ok Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -2509,8 +2799,7 @@ Checks for dataclasses with invalid frozen inheritance: **Why is this bad?** -Python raises a `TypeError` at runtime when either of these inheritance -patterns occurs. +Python raises a `TypeError` at runtime when either of these inheritance patterns occurs. **Example** @@ -2545,7 +2834,7 @@ class NonFrozenChild(FrozenBase): # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2557,8 +2846,8 @@ Checks for the creation of invalid generic classes **Why is this bad?** -There are several requirements that you must follow when defining a generic class. -Many of these result in `TypeError` being raised at runtime if they are violated. +There are several requirements that you must follow when defining a generic class. Many of these +result in `TypeError` being raised at runtime if they are violated. **Examples** @@ -2585,8 +2874,8 @@ class D(Generic[U, T]): ... # error # covariant type parameter used in a position that requires contravariance -class E(Generic[V]): # error - def set(self, value: V) -> None: ... +class E(Generic[V]): + def set(self, value: V) -> None: ... # error ``` **References** @@ -2600,7 +2889,7 @@ class E(Generic[V]): # error Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -2612,10 +2901,9 @@ Checks for enum classes that are also generic. **Why is this bad?** -Enum classes cannot be generic. Python does not support generic enums: -attempting to create one will either result in an immediate `TypeError` -at runtime, or will create a class that cannot be specialized in the way -that a normal generic class can. +Enum classes cannot be generic. Python does not support generic enums: attempting to create one will +either result in an immediate `TypeError` at runtime, or will create a class that cannot be +specialized in the way that a normal generic class can. **Examples** @@ -2696,23 +2984,22 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source **What it does** -Checks for subscript accesses with invalid keys and `TypedDict` construction with an -unknown key. +Checks for subscript accesses with invalid keys and `TypedDict` construction with an unknown key. **Why is this bad?** Subscripting with an invalid key will raise a `KeyError` at runtime. -Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is -`closed=true` it also violates the expectations of the type. +Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is `closed=true` +it also violates the expectations of the type. **Examples** @@ -2744,29 +3031,28 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source **What it does** -Checks for parameters that appear to be attempting to use the legacy convention -to specify that a parameter is positional-only, but do so incorrectly. +Checks for parameters that appear to be attempting to use the legacy convention to specify that a +parameter is positional-only, but do so incorrectly. -The "legacy convention" for specifying positional-only parameters was -specified in [PEP 484][pep-484]. It states that parameters with names starting with -`__` should be considered positional-only by type checkers. [PEP 570][pep-570], introduced -in Python 3.8, added dedicated syntax for specifying positional-only parameters, -rendering the legacy convention obsolete. However, some codebases may still -use the legacy convention for compatibility with older Python versions. +The "legacy convention" for specifying positional-only parameters was specified in +[PEP 484][pep-484]. It states that parameters with names starting with `__` should be considered +positional-only by type checkers. [PEP 570][pep-570], introduced in Python 3.8, added dedicated +syntax for specifying positional-only parameters, rendering the legacy convention obsolete. However, +some codebases may still use the legacy convention for compatibility with older Python versions. **Why is this bad?** -In most cases, a type checker will not consider a parameter to be positional-only -if it comes after a positional-or-keyword parameter, even if its name starts with -`__`. This may be unexpected to the author of the code. +In most cases, a type checker will not consider a parameter to be positional-only if it comes after +a positional-or-keyword parameter, even if its name starts with `__`. This may be unexpected to the +author of the code. **Example** @@ -2806,7 +3092,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2846,7 +3132,7 @@ def f(t: TypeVar("U")): ... # ty: ignore[invalid-type-form] Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -2858,8 +3144,7 @@ Checks for invalid match patterns. **Why is this bad?** -Invalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime. -This includes: +Invalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime. This includes: - Using a non-type object in a class pattern. - Providing positional subpatterns when `__match_args__` is missing or has an invalid static type. @@ -2898,7 +3183,7 @@ match object(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2910,9 +3195,8 @@ Checks for arguments to `metaclass=` that are invalid. **Why is this bad?** -Python allows arbitrary expressions to be used as the argument to `metaclass=`. -These expressions, however, need to be callable and accept the same arguments -as `type.__new__`. +Python allows arbitrary expressions to be used as the argument to `metaclass=`. These expressions, +however, need to be callable and accept the same arguments as `type.__new__`. **Example** @@ -2933,29 +3217,29 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source **What it does** -Detects method overrides that violate the [Liskov Substitution Principle][liskov-substitution-principle] ("LSP"). +Detects method overrides that violate the +[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"). -The LSP states that an instance of a subtype should be substitutable for an instance of its supertype. -Applied to Python, this means: +The LSP states that an instance of a subtype should be substitutable for an instance of its +supertype. Applied to Python, this means: -1. All argument combinations a superclass method accepts - must also be accepted by an overriding subclass method. -1. The return type of an overriding subclass method must be a subtype - of the return type of the superclass method. +1. All argument combinations a superclass method accepts must also be accepted by an overriding + subclass method. +1. The return type of an overriding subclass method must be a subtype of the return type of the + superclass method. **Why is this bad?** -Violating the Liskov Substitution Principle will lead to many of ty's assumptions and -inferences being incorrect, which will mean that it will fail to catch many possible -type errors in your code. +Violating the Liskov Substitution Principle will lead to many of ty's assumptions and inferences +being incorrect, which will mean that it will fail to catch many possible type errors in your code. **Example** @@ -3000,8 +3284,8 @@ accepts_super(Sub2()) **Why does ty complain about my `__eq__` method?** -`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary -objects as their second argument, for example: +`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary objects as their +second argument, for example: ```python class A: @@ -3015,30 +3299,29 @@ class A: return self.x == other.x ``` -If `A.__eq__` here were annotated as only accepting `A` instances for its second argument, -it would imply that you wouldn't be able to use `==` between instances of `A` and -instances of unrelated classes without an exception possibly being raised. While some -classes in Python do indeed behave this way, the strongly held convention is that it should -be avoided wherever possible. As part of this check, therefore, ty enforces that `__eq__` -and `__ne__` methods accept `object` as their second argument. +If `A.__eq__` here were annotated as only accepting `A` instances for its second argument, it would +imply that you wouldn't be able to use `==` between instances of `A` and instances of unrelated +classes without an exception possibly being raised. While some classes in Python do indeed behave +this way, the strongly held convention is that it should be avoided wherever possible. As part of +this check, therefore, ty enforces that `__eq__` and `__ne__` methods accept `object` as their +second argument. **Why does ty disagree with Ruff about how to write my method?** -Ruff has several rules that will encourage you to rename a parameter, or change its type -signature, if it thinks you're falling into a certain anti-pattern. For example, Ruff's -[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an -unused parameter should either be removed or renamed to start with `_`. Applying either of -these suggestions can cause ty to start reporting an [`invalid-method-override`](#invalid-method-override) error if -the function in question is a method on a subclass that overrides a method on a superclass, -and the change would cause the subclass method to no longer accept all argument combinations -that the superclass method accepts. +Ruff has several rules that will encourage you to rename a parameter, or change its type signature, +if it thinks you're falling into a certain anti-pattern. For example, Ruff's +[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an unused +parameter should either be removed or renamed to start with `_`. Applying either of these +suggestions can cause ty to start reporting an [`invalid-method-override`](#invalid-method-override) error if the function in +question is a method on a subclass that overrides a method on a superclass, and the change would +cause the subclass method to no longer accept all argument combinations that the superclass method +accepts. -This can usually be resolved by adding [`@typing.override`][override] to your method -definition. Ruff knows that a method decorated with `@typing.override` is intended to -override a method by the same name on a superclass, and avoids reporting rules like ARG002 -for such methods; it knows that the changes recommended by ARG002 would violate the Liskov -Substitution Principle. +This can usually be resolved by adding [`@typing.override`][override] to your method definition. +Ruff knows that a method decorated with `@typing.override` is intended to override a method by the +same name on a superclass, and avoids reporting rules like ARG002 for such methods; it knows that +the changes recommended by ARG002 would violate the Liskov Substitution Principle. Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#invalid-explicit-override) rule. @@ -3051,7 +3334,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -3088,13 +3371,51 @@ class Backend: implements Backend # error: `Backend` is not a protocol ``` +## `invalid-module-getattr-call` + + +Default level: error · +Added in 0.0.72 · +Related issues · +View source + + + +**What it does** + + +Checks for imports that fail when calling a module-level `__getattr__` function. + +**Why is this bad?** + + +If a module defines `__getattr__`, Python calls it when a `from` import requests a name that is not +otherwise defined. The import raises an exception if `__getattr__` cannot accept the requested name. + +**Examples** + + +`module.py`: + +```python +def __getattr__() -> str: + return "fallback" +``` + +`main.py`: + +```python +# TypeError: __getattr__() takes 0 positional arguments but 1 was given +from module import missing # error +``` + ## `invalid-named-tuple` Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -3106,17 +3427,15 @@ Checks for invalidly defined `NamedTuple` classes. **Why is this bad?** -An invalidly defined `NamedTuple` class may lead to the type checker -drawing incorrect conclusions. It may also lead to `TypeError`s or -`AttributeError`s at runtime. +An invalidly defined `NamedTuple` class may lead to the type checker drawing incorrect conclusions. +It may also lead to `TypeError`s or `AttributeError`s at runtime. **Examples** -A class definition cannot combine `NamedTuple` with other base classes -in multiple inheritance; doing so raises a `TypeError` at runtime. The sole -exception to this rule is `Generic[]`, which can be used alongside `NamedTuple` -in a class's bases list. +A class definition cannot combine `NamedTuple` with other base classes in multiple inheritance; +doing so raises a `TypeError` at runtime. The sole exception to this rule is `Generic[]`, which can +be used alongside `NamedTuple` in a class's bases list. ```pycon >>> from typing import NamedTuple @@ -3133,9 +3452,9 @@ Further, `NamedTuple` field names cannot start with an underscore: ValueError: Field names cannot start with an underscore: '_bar' ``` -`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, -`_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes -without a type annotation will raise an `AttributeError` at runtime. +`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, `_replace`, +etc.) that cannot be overwritten. Attempting to assign to these attributes without a type annotation +will raise an `AttributeError` at runtime. ```pycon >>> from typing import NamedTuple @@ -3145,8 +3464,8 @@ without a type annotation will raise an `AttributeError` at runtime. AttributeError: Cannot overwrite NamedTuple attribute _asdict ``` -Finally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type -qualifiers. These qualifiers also cause a runtime error when annotations are evaluated eagerly: +Finally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type qualifiers. These +qualifiers also cause a runtime error when annotations are evaluated eagerly: ```pycon >>> from typing import ClassVar, NamedTuple @@ -3161,7 +3480,7 @@ TypeError: typing.ClassVar[int] is not valid as type argument Default level: warn · Added in 0.0.31 · Related issues · -View source +View source @@ -3173,15 +3492,13 @@ Checks for subclass members that override inherited `NamedTuple` fields. **Why is this bad?** -Reusing an inherited `NamedTuple` field name in a subclass creates a -class where tuple indexing and `repr()` still reflect the original -field, while attribute access follows the subclass member. +Reusing an inherited `NamedTuple` field name in a subclass creates a class where tuple indexing and +`repr()` still reflect the original field, while attribute access follows the subclass member. **Default level** -This rule is a warning by default because these overrides do not make -the class invalid at runtime. +This rule is a warning by default because these overrides do not make the class invalid at runtime. **Examples** @@ -3209,7 +3526,7 @@ admin[0] # "Alice" Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -3247,7 +3564,7 @@ Baz = NewType("Baz", int | str) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3260,9 +3577,9 @@ Checks for various invalid `@overload` usages. The `@overload` decorator is used to define functions and methods that accepts different -combinations of arguments and return different types based on the arguments passed. This is -mainly beneficial for type checkers. But, if the `@overload` usage is invalid, the type -checker may not be able to provide correct type information. +combinations of arguments and return different types based on the arguments passed. This is mainly +beneficial for type checkers. But, if the `@overload` usage is invalid, the type checker may not be +able to provide correct type information. **Examples** @@ -3304,21 +3621,20 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for default values that can't be -assigned to the parameter's annotated type. +Checks for default values that can't be assigned to the parameter's annotated type. **Why is this bad?** -This breaks the rules of the type system and -weakens a type checker's ability to accurately reason about your code. +This breaks the rules of the type system and weakens a type checker's ability to accurately reason +about your code. **Examples** @@ -3333,7 +3649,7 @@ def f(a: int = ""): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3366,7 +3682,7 @@ def test_add(a: int, b: int) -> None: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3402,26 +3718,25 @@ P2 = ParamSpec() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for protocol classes that will raise `TypeError` at runtime. +Checks for protocol classes that are invalid at runtime or do not satisfy the typing specification. **Why is this bad?** -An invalidly defined protocol class may lead to the type checker inferring -unexpected things. It may also lead to `TypeError`s at runtime. +An invalidly defined protocol class may lead to the type checker inferring unexpected things or +accepting unsafe operations. Some invalid protocol definitions also raise `TypeError` at runtime. **Examples** -A `Protocol` class cannot inherit from a non-`Protocol` class; -this raises a `TypeError` at runtime: +A `Protocol` class cannot inherit from a non-`Protocol` class; this raises a `TypeError` at runtime: ```pycon >>> from typing import Protocol @@ -3432,25 +3747,42 @@ Traceback (most recent call last): TypeError: Protocols can only inherit from other protocols, got ``` +A generic protocol's declared type-variable variance must match how that variable is used by its +protocol members. For example, a type variable that appears only in a method's return type must be +covariant: + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + + +class Source(Protocol[T]): # error: [invalid-protocol] + def read(self) -> T: ... +``` + +Although Python constructs this protocol successfully at runtime, it is invalid for static typing. +Declare the type variable with `TypeVar("T", covariant=True)` instead. + ## `invalid-raise` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source -Checks for `raise` statements that raise non-exceptions or use invalid -causes for their raised exceptions. +Checks for `raise` statements that raise non-exceptions or use invalid causes for their raised +exceptions. **Why is this bad?** -Only subclasses or instances of `BaseException` can be raised. -For an exception's cause, the same rules apply, except that `None` is also -permitted. Violating these rules results in a `TypeError` at runtime. +Only subclasses or instances of `BaseException` can be raised. For an exception's cause, the same +rules apply, except that `None` is also permitted. Violating these rules results in a `TypeError` at +runtime. **Examples** @@ -3509,7 +3841,7 @@ def g(): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -3537,7 +3869,7 @@ def f() raises int: # error: `int` is not an exception Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3570,7 +3902,7 @@ if m := re.match("(a)(b)", "ab"): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -3602,7 +3934,7 @@ type Alias[reified T] = list[T] # error: an alias's parameters are erased Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3611,14 +3943,14 @@ Added in 0. Detects returned values that can't be assigned to the function's annotated return type. -Note that the special case of a function with a non-`None` return type and an empty body -is handled by the separate [`empty-body`](#empty-body) error code. +Note that the special case of a function with a non-`None` return type and an empty body is handled +by the separate [`empty-body`](#empty-body) error code. **Why is this bad?** -Returning an object of a type incompatible with the annotated return type -is unsound, and will lead to ty inferring incorrect types elsewhere. +Returning an object of a type incompatible with the annotated return type is unsound, and will lead +to ty inferring incorrect types elsewhere. **Examples** @@ -3748,7 +4080,7 @@ def detail(request, pk: int): ... # ok Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -3760,10 +4092,10 @@ Checks for basedpython static resource imports that cannot be read. **Why is this bad?** -`import "data/config.yaml" as config` says the file is part of the program. A -path that names nothing, a path that names a place on one machine, a file in a -format that is not `.json`, `.toml`, `.yaml` or `.yml`, and a document the -format's own parser rejects all leave the import with no value to bind. +`import "data/config.yaml" as config` says the file is part of the program. A path that names +nothing, a path that names a place on one machine, a file in a format that is not `.json`, `.toml`, +`.yaml` or `.yml`, and a document the format's own parser rejects all leave the import with no value +to bind. **Examples** @@ -3787,7 +4119,7 @@ import "data/missing.json" as missing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3850,20 +4182,17 @@ Added in 0. **What it does** -Checks for string-literal annotations where the string cannot be -parsed as a Python expression. +Checks for string-literal annotations where the string cannot be parsed as a Python expression. **Why is this bad?** -Type annotations are expected to be Python expressions that -describe the expected type of a variable, parameter, attribute or -`return` statement. +Type annotations are expected to be Python expressions that describe the expected type of a +variable, parameter, attribute or `return` statement. -Type annotations are permitted to be string-literal expressions, in -order to enable forward references to names not yet defined. -However, it must be possible to parse the contents of that string -literal as a normal Python expression. +Type annotations are permitted to be string-literal expressions, in order to enable forward +references to names not yet defined. However, it must be possible to parse the contents of that +string literal as a normal Python expression. **Example** @@ -3898,21 +4227,21 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source **What it does** -Checks for classes decorated with `@functools.total_ordering` that don't -define any ordering method (`__lt__`, `__le__`, `__gt__`, or `__ge__`). +Checks for classes decorated with `@functools.total_ordering` that don't define any ordering method +(`__lt__`, `__le__`, `__gt__`, or `__ge__`). **Why is this bad?** -The `@total_ordering` decorator requires the class to define at least one -ordering method. If none is defined, Python raises a `ValueError` at runtime. +The `@total_ordering` decorator requires the class to define at least one ordering method. If none +is defined, Python raises a `ValueError` at runtime. **Example** @@ -3949,7 +4278,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -3995,7 +4324,7 @@ InvalidAlias = TypeAliasType("InvalidAlias", list[T], type_params=(list[T],)) # Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -4007,10 +4336,9 @@ Checks for invalid type arguments in explicit type specialization. **Why is this bad?** -Providing the wrong number of type arguments or type arguments that don't -satisfy the type variable's bounds or constraints will lead to incorrect -type inference and may indicate a misunderstanding of the generic type's -interface. +Providing the wrong number of type arguments or type arguments that don't satisfy the type +variable's bounds or constraints will lead to incorrect type inference and may indicate a +misunderstanding of the generic type's interface. **Examples** @@ -4062,24 +4390,24 @@ Bar[int] # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an -annotation not assignable from `bool`. +Checks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an annotation not +assignable from `bool`. **Why is this bad?** -The name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional -code seen only by the type checker, and not at runtime. Normally this flag is imported from -`typing` or `typing_extensions`, but it can also be defined locally. If defined locally, it -must be assigned the value `False` at runtime; the type checker will consider its value to -be `True`. If annotated, it must be annotated as a type that can accept `bool` values. +The name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional code seen +only by the type checker, and not at runtime. Normally this flag is imported from `typing` or +`typing_extensions`, but it can also be defined locally. If defined locally, it must be assigned the +value `False` at runtime; the type checker will consider its value to be `True`. If annotated, it +must be annotated as a type that can accept `bool` values. **Examples** @@ -4095,21 +4423,20 @@ TYPE_CHECKING = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for expressions that are used as [type expressions] -but cannot validly be interpreted as such. +Checks for expressions that are used as [type expressions] but cannot validly be interpreted as +such. **Why is this bad?** -Such expressions cannot be understood by ty. -In some cases, they might raise errors at runtime. +Such expressions cannot be understood by ty. In some cases, they might raise errors at runtime. **Examples** @@ -4131,21 +4458,21 @@ b: Annotated[int] # error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source **What it does** -Checks for type guard functions without -a first non-self-like non-keyword-only non-variadic parameter. +Checks for type guard functions without a first non-self-like non-keyword-only non-variadic +parameter. **Why is this bad?** -Type narrowing functions must accept at least one positional argument -(non-static methods must accept another in addition to `self`/`cls`). +Type narrowing functions must accept at least one positional argument (non-static methods must +accept another in addition to `self`/`cls`). Extra parameters/arguments are allowed but do not affect narrowing. @@ -4188,7 +4515,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -4232,15 +4559,15 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for constrained [type variables] with only one constraint, -or that those constraints reference type variables. +Checks for constrained [type variables] with only one constraint, or that those constraints +reference type variables. **Why is this bad?** @@ -4289,22 +4616,22 @@ V = TypeVar("V", list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source **What it does** -Checks for [type variables] whose default type is not compatible with -the type variable's bound or constraints. +Checks for [type variables] whose default type is not compatible with the type variable's bound or +constraints. **Why is this bad?** -If a type variable has a bound, the default must be assignable to that -bound (see: [bound rules]). If a type variable has constraints, the default -must be one of the constraints (see: [constraint rules]). +If a type variable has a bound, the default must be assignable to that bound (see: [bound rules]). +If a type variable has constraints, the default must be one of the constraints (see: +[constraint rules]). **Examples** @@ -4331,7 +4658,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -4343,8 +4670,8 @@ Detects invalid `TypedDict` field declarations. **Why is this bad?** -`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the -subtype guarantees that `TypedDict` inheritance is meant to preserve. +`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the subtype +guarantees that `TypedDict` inheritance is meant to preserve. **Example** @@ -4367,23 +4694,21 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source **What it does** -Detects errors in `TypedDict` class headers, such as unexpected arguments -or invalid base classes. +Detects errors in `TypedDict` class headers, such as unexpected arguments or invalid base classes. **Why is this bad?** -The typing spec states that `TypedDict`s are not permitted to have -custom metaclasses. Using `**` unpacking in a `TypedDict` header -is also prohibited by ty, as it means that ty cannot statically determine -whether keys in the `TypedDict` are intended to be required or optional. +The typing spec states that `TypedDict`s are not permitted to have custom metaclasses. Using `**` +unpacking in a `TypedDict` header is also prohibited by ty, as it means that ty cannot statically +determine whether keys in the `TypedDict` are intended to be required or optional. **Example** @@ -4410,7 +4735,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -4422,10 +4747,9 @@ Detects statements other than annotated declarations in `TypedDict` class bodies **Why is this bad?** -`TypedDict` class bodies aren't allowed to contain any other types of statements. For -example, method definitions and field values aren't allowed. None of these will be -available on "instances of the `TypedDict`" at runtime (as `dict` is the runtime class of -all "`TypedDict` instances"). +`TypedDict` class bodies aren't allowed to contain any other types of statements. For example, +method definitions and field values aren't allowed. None of these will be available on "instances of +the `TypedDict`" at runtime (as `dict` is the runtime class of all "`TypedDict` instances"). **Example** @@ -4445,7 +4769,7 @@ class Foo(TypedDict): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -4486,23 +4810,22 @@ type Alias[out T] = list[T] # error: `list` is invariant Default level: error · Added in 0.0.25 · Related issues · -View source +View source **What it does** -Detects `yield` and `yield from` expressions where the "yield" or "send" type -is incompatible with the generator function's annotated return type. +Detects `yield` and `yield from` expressions where the "yield" or "send" type is incompatible with +the generator function's annotated return type. **Why is this bad?** -Yielding a value of a type that doesn't match the generator's declared yield type, -or using `yield from` with a sub-iterator whose yield or send type is incompatible, -is a type error that may cause downstream consumers of the generator to receive -values of an unexpected type. +Yielding a value of a type that doesn't match the generator's declared yield type, or using +`yield from` with a sub-iterator whose yield or send type is incompatible, is a type error that may +cause downstream consumers of the generator to receive values of an unexpected type. **Examples** @@ -4521,17 +4844,16 @@ def gen() -> Iterator[int]: Default level: error · Added in 0.0.14 · Related issues · -View source +View source **What it does** -Reports invalid runtime checks against `Protocol` classes. -This includes explicit calls `isinstance()`/`issubclass()` against -non-runtime-checkable protocols, `issubclass()` calls against protocols -that have non-method members, and implicit `isinstance()` checks against +Reports invalid runtime checks against `Protocol` classes. This includes explicit calls +`isinstance()`/`issubclass()` against non-runtime-checkable protocols, `issubclass()` calls against +protocols that have non-method members, and implicit `isinstance()` checks against non-runtime-checkable protocols via pattern matching. **Why is this bad?** @@ -4588,16 +4910,15 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source **What it does** -Reports runtime checks against `TypedDict` classes. -This includes explicit calls to `isinstance()`/`issubclass()` and implicit -checks performed by `match` class patterns. +Reports runtime checks against `TypedDict` classes. This includes explicit calls to +`isinstance()`/`issubclass()` and implicit checks performed by `match` class patterns. **Why is this bad?** @@ -4638,7 +4959,7 @@ def g(arg: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -4669,29 +4990,28 @@ def f(s: str): Default level: warn · Added in 0.0.30 · Related issues · -View source +View source **What it does** -Checks for functional typing definitions whose declared name does not match -the variable they are assigned to. +Checks for functional typing definitions whose declared name does not match the variable they are +assigned to. **Why is this bad?** -Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, -`TypedDict`, and `TypeAliasType` all take a name argument that is -normally expected to match the assigned variable. A mismatch is usually a -typo and makes later diagnostics harder to understand. +Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, `TypedDict`, and `TypeAliasType` +all take a name argument that is normally expected to match the assigned variable. A mismatch is +usually a typo and makes later diagnostics harder to understand. **Default level** -This rule is a warning by default because ty can usually recover and -continue understanding the resulting type. +This rule is a warning by default because ty can usually recover and continue understanding the +resulting type. **Examples** @@ -4712,7 +5032,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -4780,7 +5100,7 @@ and nothing is reported. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4811,7 +5131,7 @@ func() # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -4836,13 +5156,102 @@ context s = "hello" f(1) # ok — `s` is passed implicitly ``` +## `missing-direct-dependency` + + +Default level: ignore · +Preview (since 0.0.76) · +Related issues · +View source + + + +**What it does** + + +Checks for imports from installable packages that the current project or PEP 723 script does not +declare as direct dependencies. + +The name used in dependency declarations can differ from the import name: for example, the `pillow` +package is imported as `PIL`. + +**Why is this bad?** + + +A dependency can be installed because another package requires it. Importing that dependency without +declaring it makes your code rely on another package's dependency list. If that package removes the +dependency, your imports can fail. + +Declare the packages that provide your imports in `project.dependencies` or +`project.optional-dependencies` in `pyproject.toml`. Non-package files, such as tests and +development scripts, can also use dependencies declared in dependency groups. + +See uv's [guide to managing dependencies](https://docs.astral.sh/uv/concepts/projects/dependencies/) +for how to add these declarations. + +**Rule status** + + +This rule is disabled by default and requires uv integration. + +For projects, enable uv workspace integration (`TY_UV=1`) and use an existing, synchronized +environment. Running [`uv check`](https://docs.astral.sh/uv/reference/cli/#uv-check) synchronizes +the environment automatically before invoking ty, unless `--no-sync` is passed. For these checks, ty +reads the dependency graph and module ownership returned by `uv workspace metadata` without changing +installed packages. uv may update the lockfile to match the current dependency declarations. uv +0.12.3 or later is required. + +For PEP 723 scripts, enable uv script integration with `TY_UV=scripts` or `TY_UV=1`. ty synchronizes +each script's environment and checks imports against its inline `dependencies` list. Declarations +and environments from the enclosing workspace or other scripts do not apply. + +**Known limitations** + + +The current workspace integration applies to directory checks. Explicit file arguments and +`--config-file` bypass uv workspace discovery. + +Imports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They +can use development-only dependencies, such as type stub packages, without requiring those packages +as runtime dependencies. + +Standard-library imports and imports whose owning package cannot be identified unambiguously are +also not reported. + +Imports of [namespace packages](https://docs.python.org/3/reference/import.html#namespace-packages) +themselves, such as `import ns`, are not reported: the namespace can contain modules from several +installable packages. Imports of their submodules, such as `import ns.child`, are checked when the +owning package is known. An `__init__.pyi` stub does not change this distinction. + +Native packages that ty can resolve only as namespace packages at runtime are also skipped. For +other native modules, ty can use stubs to resolve the import and uv's ownership map to identify +which package to declare. + +Some editable installations add the whole project directory to Python's import path, making both +package code and files such as `tests/test_app.py` importable. If uv does not identify which modules +belong to the installable package, ty allows dependency-group imports throughout that directory, +including in package code, to avoid incorrectly flagging imports in tests and scripts. + +**Examples** + + +With `requests` as a direct dependency, `urllib3` may also be installed because `requests` depends +on it: + +```python {data-mdtest="ignore"} +import requests +import urllib3 # error: [missing-direct-dependency] +``` + +Add `urllib3` to `project.dependencies` if your code imports it directly. + ## `missing-framework-stubs` Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -4870,16 +5279,18 @@ from django.db import models # warning: install `django-stubs` for precise type Default level: error · Level under ty-compatible: ignore · Added in 0.0.41 · Related issues · -View source +View source **What it does** -Checks for methods that override a method or attribute in a superclass but are not decorated with `@override`. +Checks for methods that override a method or attribute in a superclass but are not decorated with +`@override`. -This rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a project. +This rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a +project. **Exemptions** @@ -4923,13 +5334,100 @@ class ExplicitChild(Parent): return 2 ``` +## `missing-slot` + + +Default level: error · +Added in 0.0.75 · +Related issues · +View source + + + +**What it does** + + +Checks for assignments to declared attributes that have no matching `__slots__` entry on the class +or its bases, and no instance dictionary to store their values. + +**Why is this bad?** + + +Most Python objects store their attributes in an "instance dictionary". Assigning to a new attribute +adds an entry to this dictionary; deleting that attribute removes it again. Accordingly, most Python +objects allow for **arbitrary attributes to be set and read**. The advantage of this is that it +allows for many dynamic features; the disadvantage is that it can be costly in terms of memory, and +can easily allow for typos to slip in accidentally, e.g.: + +```py +class Foo: + def __init__(self, x): + self.x = x + + def update_x(self, x): + self.xx = x # oops, this was meant to be the same attribute set in `__init__`, + # but ended up being an entirely separate one! +``` + +Defining `__slots__` lets a class reserve space for a fixed set of instance attributes instead. +Unless an instance dictionary is inherited from a base class or requested by including `"__dict__"` +in `__slots__`, instances of the class have no dictionary in which to store additional attributes. +Attempting to assign to an attribute not declared in `__slots__` will often raise `AttributeError` +at runtime if the instance has no instance dictionary. + +**Examples** + + +**Class definitions** + + +```python +class Item: + __slots__ = () + value: int + + +Item().value = 1 # error: [missing-slot] +``` + +If you control the class, include the attribute in `__slots__` to make the assignment valid: + +```python +class Item: + __slots__ = ("value",) + value: int + + +Item().value = 1 +``` + +**Stub files** + + +Stub files can use properties to indicate that instances have attributes that are readable and +writable but do not appear in `__slots__`, for example: + +```pyi +class Item: + __slots__ = () + @property + def value(self) -> int: ... + @value.setter + def value(self, value: int) -> None: ... +``` + +**References** + + +- [Python data model: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) + ## `missing-type-argument` Default level: error · Level under ty-compatible: ignore · Added in 0.0.45 · Related issues · -View source +View source @@ -4941,10 +5439,9 @@ Checks for generic types used without type parameters in type expressions. **Why is this bad?** -Using a generic type without specifying its type parameters results in the -type parameters being implicitly filled with `Unknown`, reducing the -precision of type checking. Explicit type parameters make the intended types -clear and enable the type checker to catch more errors. +Using a generic type without specifying its type parameters results in the type parameters being +implicitly filled with `Unknown`, reducing the precision of type checking. Explicit type parameters +make the intended types clear and enable the type checker to catch more errors. **Examples** @@ -4968,7 +5465,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -4980,8 +5477,8 @@ Detects missing required keys in `TypedDict` constructor calls. **Why is this bad?** -`TypedDict` requires all non-optional keys to be provided during construction. -Missing items can lead to a `KeyError` at runtime. +`TypedDict` requires all non-optional keys to be provided during construction. Missing items can +lead to a `KeyError` at runtime. **Example** @@ -5007,7 +5504,7 @@ alice["age"] # KeyError Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5041,7 +5538,7 @@ def f(a: int | None): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5053,8 +5550,8 @@ Checks for calls to an overloaded function that do not match any of the overload **Why is this bad?** -Failing to provide the correct arguments to one of the overloads will raise a `TypeError` -at runtime. +Failing to provide the correct arguments to one of the overloads will raise a `TypeError` at +runtime. **Examples** @@ -5079,21 +5576,20 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.30 · Related issues · -View source +View source **What it does** -Checks for class definitions that will fail due to non-callable `__init_subclass__` -methods. +Checks for class definitions that will fail due to non-callable `__init_subclass__` methods. **Why is this bad?** -If a class defines a non-callable `__init_subclass__` method/attribute, any attempt -to subclass that class will raise a `TypeError` at runtime. +If a class defines a non-callable `__init_subclass__` method/attribute, any attempt to subclass that +class will raise a `TypeError` at runtime. **Examples** @@ -5117,7 +5613,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -5148,7 +5644,7 @@ def f(x: int | str) -> int: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5177,7 +5673,7 @@ def f(a: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5221,7 +5717,7 @@ def g(o: object, shape: Shape): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5250,7 +5746,7 @@ for i in 34: # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5278,7 +5774,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5307,7 +5803,7 @@ def f(once done: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5335,7 +5831,7 @@ def f(once done: () -> None): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5373,7 +5869,7 @@ def f(x: int?): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5428,7 +5924,7 @@ def g(name: str | None): Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -5440,8 +5936,8 @@ Checks for methods on subclasses that override superclass methods decorated with **Why is this bad?** -Decorating a method with `@final` declares to the type checker that it should not be -overridden on any subclass. +Decorating a method with `@final` declares to the type checker that it should not be overridden on +any subclass. **Example** @@ -5465,21 +5961,21 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source **What it does** -Checks for class variables on subclasses that override a superclass variable -that has been declared as `Final`. +Checks for class variables on subclasses that override a superclass variable that has been declared +as `Final`. **Why is this bad?** -Declaring a variable as `Final` indicates to the type checker that it should not be -overridden on any subclass. +Declaring a variable as `Final` indicates to the type checker that it should not be overridden on +any subclass. **Example** @@ -5502,7 +5998,7 @@ class B(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.38 · Related issues · -View source +View source @@ -5545,7 +6041,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5576,7 +6072,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -5607,7 +6103,7 @@ f(x=1) # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -5624,8 +6120,7 @@ Attempting to access a missing attribute will raise an `AttributeError` at runti **Rule status** -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. **Examples** @@ -5646,7 +6141,7 @@ A.c # error Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -5658,9 +6153,8 @@ Checks for implicit calls to possibly missing methods. **Why is this bad?** -Expressions such as `x[y]` and `x * y` call methods -under the hood (`__getitem__` and `__mul__` respectively). -Calling a missing method will raise an `AttributeError` at runtime. +Expressions such as `x[y]` and `x * y` call methods under the hood (`__getitem__` and `__mul__` +respectively). Calling a missing method will raise an `AttributeError` at runtime. **Examples** @@ -5685,7 +6179,7 @@ A()[0] # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -5697,14 +6191,12 @@ Checks for imports of symbols that may be missing. **Why is this bad?** -Importing a missing module or name will raise a `ModuleNotFoundError` -or `ImportError` at runtime. +Importing a missing module or name will raise a `ModuleNotFoundError` or `ImportError` at runtime. **Rule status** -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. **Examples** @@ -5731,7 +6223,7 @@ from module import a # error Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -5743,9 +6235,9 @@ Checks for accesses of submodules that might not've been imported. **Why is this bad?** -When module `a` has a submodule `b`, `import a` isn't generally enough to let you access -`a.b.` You either need to explicitly `import a.b`, or else you need the `__init__.py` file -of `a` to include `from . import b`. Without one of those, `a.b` is an `AttributeError`. +When module `a` has a submodule `b`, `import a` isn't generally enough to let you access `a.b.` You +either need to explicitly `import a.b`, or else you need the `__init__.py` file of `a` to include +`from . import b`. Without one of those, `a.b` is an `AttributeError`. **Examples** @@ -5763,7 +6255,7 @@ html.parser # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5780,8 +6272,7 @@ Using an undefined variable will raise a `NameError` at runtime. **Rule status** -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. **Example** @@ -5800,7 +6291,7 @@ print(x) # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5832,7 +6323,7 @@ from helpers import Key # error: `Key` is private to `helpers` Default level: warn · Added in 0.0.60 · Related issues · -View source +View source @@ -5863,8 +6354,8 @@ class User(BaseModel): user = User(name="Alice", admni=True) # error: [pydantic-discarded-extra-argument] ``` -If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument, -or explicitly configure the model with `extra="allow"`. +If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra +argument, or explicitly configure the model with `extra="allow"`. ## `raw-string-type-annotation` @@ -5907,7 +6398,7 @@ def test() -> "int": Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5949,7 +6440,7 @@ def g(a: bool | None): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5984,7 +6475,7 @@ cast(int, f()) # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6038,7 +6529,7 @@ if sys.version_info >= (3, 12): # ok — artificially constant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -6050,11 +6541,11 @@ Checks for redundant combinations of the `ClassVar` and `Final` type qualifiers. **Why is this bad?** -An attribute that is marked `Final` in a class body is implicitly a class variable. -Marking it as `ClassVar` is therefore redundant. +An attribute that is marked `Final` in a class body is implicitly a class variable. Marking it as +`ClassVar` is therefore redundant. -Note that this diagnostic is not emitted for dataclass fields or protocol members, -where `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. +Note that this diagnostic is not emitted for dataclass fields or protocol members, where +`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. **Examples** @@ -6076,7 +6567,7 @@ class C: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6131,25 +6622,25 @@ class Sub(Base): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source **What it does** -Checks for a basedpython destructuring binder whose pattern may not match the -value it destructures, with nothing to handle the failure. +Checks for a basedpython destructuring binder whose pattern may not match the value it destructures, +with nothing to handle the failure. **Why is this bad?** -A destructuring binder — a `let` statement, a `for` target, a `with` item, a -parameter — binds its captures unconditionally. A pattern that does not match -leaves them unbound, which is a `NameError` at the first use. +A destructuring binder — a `let` statement, a `for` target, a `with` item, a parameter — binds its +captures unconditionally. A pattern that does not match leaves them unbound, which is a `NameError` +at the first use. -A `let` statement can handle the failure with an `else` block, but only if the -block diverges: control that falls out of it reaches the same unbound captures. +A `let` statement can handle the failure with an `else` block, but only if the block diverges: +control that falls out of it reaches the same unbound captures. **Examples** @@ -6165,8 +6656,7 @@ def g(value: int | str) -> int: return n # error: [possibly-unresolved-reference] ``` -Use a pattern that matches every value of the type, or an `else` block that -diverges: +Use a pattern that matches every value of the type, or an `else` block that diverges: ```by def f(value: int | str) -> int: @@ -6181,34 +6671,33 @@ def f(value: int | str) -> int: Default level: error · Added in 0.0.71 · Related issues · -View source +View source **What it does** -Checks for an unpacking assignment whose value is not known to have the number -of elements the targets require. +Checks for an unpacking assignment whose value is not known to have the number of elements the +targets require. **Why is this bad?** -`a, b = value` binds both names unconditionally, but the unpacking only succeeds -if `value` yields exactly two elements. A `tuple[int, ...]`, a `list[int]`, or -any other iterable whose length is not part of its type satisfies the annotation -at every length, so nothing rules out a `ValueError` at runtime. +`a, b = value` binds both names unconditionally, but the unpacking only succeeds if `value` yields +exactly two elements. A `tuple[int, ...]`, a `list[int]`, or any other iterable whose length is not +part of its type satisfies the annotation at every length, so nothing rules out a `ValueError` at +runtime. -A starred target absorbs any number of elements, so it only requires the ones -around it: `a, *rest = value` still needs at least one element, and reports for -the same reason. A splatted argument is the same question against a parameter -list: `f(*value)` binds the parameters positionally, so a length that does not -match raises `TypeError` rather than `ValueError`. +A starred target absorbs any number of elements, so it only requires the ones around it: +`a, *rest = value` still needs at least one element, and reports for the same reason. A splatted +argument is the same question against a parameter list: `f(*value)` binds the parameters +positionally, so a length that does not match raises `TypeError` rather than `ValueError`. -Three values are left alone: one whose type is `Any`, which has opted out of -checking altogether; one whose element type is `Unknown`, which ty fills in -where the code said nothing at all; and an unannotated parameter, whose type is -bounded by what its function's body asks of it — including the unpacking itself. +Three values are left alone: one whose type is `Any`, which has opted out of checking altogether; +one whose element type is `Unknown`, which ty fills in where the code said nothing at all; and an +unannotated parameter, whose type is bounded by what its function's body asks of it — including the +unpacking itself. **Examples** @@ -6246,7 +6735,7 @@ def g(values: tuple[int, ...]) -> None: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -6279,7 +6768,7 @@ class C: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -6315,7 +6804,7 @@ class C[T]: Default level: warn · Added in 0.0.71 · Related issues · -View source +View source @@ -6358,15 +6847,15 @@ def build(t: Tag) -> None: Default level: error · Added in 0.0.20 · Related issues · -View source +View source **What it does** -Checks for type variables in nested generic classes or functions that shadow type variables -from an enclosing scope. +Checks for type variables in nested generic classes or functions that shadow type variables from an +enclosing scope. **Why is this bad?** @@ -6402,7 +6891,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6414,9 +6903,8 @@ Makes sure that the argument of `static_assert` is statically known to be true. **Why is this bad?** -A `static_assert` call represents an explicit request from the user -for the type checker to emit an error if the argument cannot be verified -to evaluate to `True` in a boolean context. +A `static_assert` call represents an explicit request from the user for the type checker to emit an +error if the argument cannot be verified to evaluate to `True` in a boolean context. **Examples** @@ -6437,7 +6925,7 @@ static_assert(int(2.0 * 3.0) == 6) # error Default level: warn · Added in 0.0.39 · Related issues · -View source +View source @@ -6449,13 +6937,13 @@ Checks for classes that inherit from a dataclass with `order=True`. **Why is this bad?** -When a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`) -are generated that compare instances as tuples of their fields. These methods raise a -`TypeError` at runtime when comparing instances of different classes in the inheritance -hierarchy, even if one is a subclass of the other. +When a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`) are +generated that compare instances as tuples of their fields. These methods raise a `TypeError` at +runtime when comparing instances of different classes in the inheritance hierarchy, even if one is a +subclass of the other. -This violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class instances cannot be -used in all contexts where parent class instances are expected. +This violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class +instances cannot be used in all contexts where parent class instances are expected. **Example** @@ -6477,7 +6965,8 @@ class Child(Parent): # error # Child(1) < Parent(2) ``` -Consider using [`functools.total_ordering`][total_ordering] instead, which does not have this limitation. +Consider using [`functools.total_ordering`][total_ordering] instead, which does not have this +limitation. [liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle [total_ordering]: https://docs.python.org/3/library/functools.html#functools.total_ordering @@ -6488,7 +6977,7 @@ Consider using [`functools.total_ordering`][total_ordering] instead, which does Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6522,7 +7011,7 @@ class B(A): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6554,7 +7043,7 @@ class Circle(Shape): ... # error: `Shape` is sealed in another workspace Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -6663,7 +7152,7 @@ class Book: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6693,7 +7182,7 @@ f("foo") # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6732,7 +7221,7 @@ def find(items: list[int]) -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6779,7 +7268,7 @@ g: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6814,15 +7303,15 @@ f: # error: the block returns `None`, not `str` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for `assert_type()` and `assert_never()` calls where the actual type -is not the same as the asserted type. +Checks for `assert_type()` and `assert_never()` calls where the actual type is not the same as the +asserted type. **Why is this bad?** @@ -6853,7 +7342,7 @@ def _(x: int): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -6884,21 +7373,22 @@ class User(BaseModel): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Detects invalid `super()` calls where implicit arguments like the enclosing class or first method argument are unavailable. +Detects invalid `super()` calls where implicit arguments like the enclosing class or first method +argument are unavailable. **Why is this bad?** -When `super()` is used without arguments, Python tries to find two things: -the nearest enclosing class and the first argument of the immediately enclosing function (typically self or cls). -If either of these is missing, the call will fail at runtime with a `RuntimeError`. +When `super()` is used without arguments, Python tries to find two things: the nearest enclosing +class and the first argument of the immediately enclosing function (typically self or cls). If +either of these is missing, the call will fail at runtime with a `RuntimeError`. **Examples** @@ -6942,15 +7432,15 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source **What it does** -Checks for type variables that are used in a scope where they are not bound -to any enclosing generic context. +Checks for type variables that are used in a scope where they are not bound to any enclosing generic +context. **Why is this bad?** @@ -7015,7 +7505,7 @@ the project registers with `@register.simple_block_tag`. Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -7081,7 +7571,7 @@ what the projects depending on it read. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7110,7 +7600,7 @@ def f() raises TypeError: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7139,7 +7629,7 @@ reveal_type(1) # revealed: Literal[1] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7169,7 +7659,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7200,7 +7690,7 @@ f(x=1, y=2) # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -7411,7 +7901,7 @@ page does not render at all. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -7445,7 +7935,7 @@ implements Backend # error: this module does not answer `Backend` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7457,9 +7947,9 @@ Checks for unresolved attributes. **Why is this bad?** -Accessing an unbound attribute will raise an `AttributeError` at runtime. -An unresolved attribute is not guaranteed to exist from the type alone, -so this could also indicate that the object is not of the type that the user expects. +Accessing an unbound attribute will raise an `AttributeError` at runtime. An unresolved attribute is +not guaranteed to exist from the type alone, so this could also indicate that the object is not of +the type that the user expects. **Examples** @@ -7478,22 +7968,22 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source **What it does** -Detects variables declared as `global` in an inner scope that have no explicit -bindings or declarations in the global scope. +Detects variables declared as `global` in an inner scope that have no explicit bindings or +declarations in the global scope. **Why is this bad?** -Function bodies with `global` statements can run in any order (or not at all), which makes -it hard for static analysis tools to infer the types of globals without -explicit definitions or declarations. +Function bodies with `global` statements can run in any order (or not at all), which makes it hard +for static analysis tools to infer the types of globals without explicit definitions or +declarations. **Example** @@ -7553,7 +8043,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7565,8 +8055,7 @@ Checks for import statements for which the module cannot be resolved. **Why is this bad?** -Importing a module that cannot be resolved will raise a `ModuleNotFoundError` -at runtime. +Importing a module that cannot be resolved will raise a `ModuleNotFoundError` at runtime. **Examples** @@ -7582,7 +8071,7 @@ import foo # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7612,7 +8101,7 @@ def check(value: int | None) -> asserts values: # error: `values` is nothing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7724,13 +8213,141 @@ is one whose template set cannot be established. {% extends "blog/bass.html" %} {# error: the template is `blog/base.html` #} ``` +## `unsound-assignment` + + +Default level: ignore · +Added in 0.0.73 · +Related issues · +View source + + + +**What it does** + + +Detects variable assignments that unsoundly assign a type that is not a [subtype] of a variable's +declared type. + +This rule is a stricter version of [`invalid-assignment`](#invalid-assignment). Whereas that rule also flags assignments to +attributes and subscripts, however, this rule is only applied to variable assignments. + +This rule has no effect on stub files. + +**Why is this bad?** + + +By default, type checkers consider an assignment valid if the inferred type of the assigned value is +[assignable] to the target's declared type. However, this makes it easy for incorrect types to +percolate through your code unexpectedly due to a single expression being inferred as `Any`. This +can easily lead to runtime errors that are not caught by the type checker: + +```py +from typing import Any + + +def returns_any() -> Any: + return "not an integer" + + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +my_integer: int = returns_any() + +# Fails at runtime, even though the type checker infers both operands as being of type `int`! +my_integer + 42 +``` + +This rule treats ["fully static"][fully-static] declared types as "typed boundaries" for your code. +With this rule enabled, ty would emit an error on the `my_integer: int = returns_any()` assignment, +since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of +`int`. This helps prevent the unsoundness from spreading far from its original source (in this case, +the return type of the `returns_any` function). + +Note that this rule is only applied to assignments where the declared type is +[fully static][fully-static]. It will not trigger if `Any` or `Unknown` appear anywhere in the +declared type, either implicitly or explicitly: + +```py +from typing import Any + + +def returns_any() -> Any: + return "not an integer" + + +explicitly_dynamic: Any = returns_any() # no error +also_dynamic: list[Any] = returns_any() # no error + +# no `unsound-assignment` error, since `list` is implicitly the same as `list[Unknown]` +# (which is what the `missing-type-argument` error is complaining about) +# +# error: [missing-type-argument] +implicitly_dynamic: list = returns_any() +``` + +This rule works especially well when combined with ty's [`missing-type-argument`](#missing-type-argument) rule. + +**Examples** + + +```py +from typing import Any + + +def returns_any() -> Any: + return 42 + + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +my_integer: int = returns_any() + +another_integer: int + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +another_integer = returns_any() +``` + +Narrow the value before assigning it to fix the diagnostics: + +```py +from typing import Any + + +def returns_any() -> Any: + return 42 + + +value = returns_any() +assert isinstance(value, int) +my_integer: int = value # no error: `Any & int` is a subtype of `int` +``` + +**Default level** + + +This rule is disabled by default. It is intended for advanced users wanting additional soundness +checks from their type checker, not for users who have just started to use type checkers on their +Python code. + +**See also** + + +- [`unsound-return-statement`](#unsound-return-statement) is a similar rule that triggers on unsound `return` statements rather + than unsound assignments +- [`unsound-yield`](#unsound-yield) is a similar rule that triggers on unsound `yield` expressions rather than unsound + assignments + +[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable +[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type +[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype + ## `unsound-cast` Default level: error · Added in 0.0.71 · Related issues · -View source +View source @@ -7771,7 +8388,7 @@ def f(a: object, b: int, c: Any): Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -7810,14 +8427,14 @@ returns_int() + 42 ``` This rule allows you to use ["fully static"][fully-static] return types as "typed boundaries" for -your code. With this rule enabled, ty would emit an error on the `return returns_any()` statement -in `returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not -a subtype of `int`. This helps prevent the unsoundness from spreading far from its original source -(in this case, the return type of the `returns_any` function). +your code. With this rule enabled, ty would emit an error on the `return returns_any()` statement in +`returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a +subtype of `int`. This helps prevent the unsoundness from spreading far from its original source (in +this case, the return type of the `returns_any` function). -Note that this rule is only applied to functions annotated as returning -[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in -your return type, either implicitly or explicitly: +Note that this rule is only applied to functions annotated as returning [fully static][fully-static] +types. It will not trigger if `Any` or `Unknown` appear anywhere in your return type, either +implicitly or explicitly: ```py from typing import Any @@ -7839,12 +8456,12 @@ def returns_list_of_any() -> list[Any]: return returns_any() ``` -This rule works especially well when combined with ty's -[`missing-type-argument`](#missing-type-argument) rule, and the Ruff rules [`ANN201`][ann201], -[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all -these rules at once effectively makes it much less likely that a `return` statement can lead to -unsoundness "leaking" out of a function unless that function has been *explicitly* annotated with -a dynamic type in some way (`-> Any` or `-> tuple[Any]`, for example). +This rule works especially well when combined with ty's [`missing-type-argument`](#missing-type-argument) and +[`unsound-assignment`](#unsound-assignment) rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202], +[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once +effectively makes it much less likely that a `return` statement can lead to unsoundness "leaking" +out of a function unless that function has been *explicitly* annotated with a dynamic type in some +way (`-> Any` or `-> tuple[Any]`, for example). This rule is analogous to mypy's [`no-any-return`][no-any-return] error code, which is enabled by mypy’s [`--strict`][mypy-strict] mode and can also be enabled on its own using mypy’s @@ -7894,7 +8511,9 @@ Python code. **See also** -- [`unsound-yield`](#unsound-yield) is a similar rule that triggers on unsound `yield` expressions rather than unsound `return` statements +- [`unsound-yield`](#unsound-yield) is a similar rule that triggers on unsound `yield` expressions rather than unsound + `return` statements +- [`unsound-assignment`](#unsound-assignment) is a similar rule that triggers on unsound assignments [ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ [ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ @@ -7914,7 +8533,7 @@ Python code. Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -7930,10 +8549,9 @@ This lint is a stricter version of [`invalid-yield`](#invalid-yield). By default, type checkers consider a yielded value valid if its inferred type is [assignable] to the -generator's annotated yield type. However, this -makes it easy for incorrect types to percolate through your code unexpectedly due to a single -expression being inferred as `Any`. This can easily lead to runtime errors that are not caught by -the type checker: +generator's annotated yield type. However, this makes it easy for incorrect types to percolate +through your code unexpectedly due to a single expression being inferred as `Any`. This can easily +lead to runtime errors that are not caught by the type checker: ```py from typing import Any, Generator @@ -7952,14 +8570,16 @@ def integers() -> Generator[int]: sum(integers()) ``` -This rule treats [fully static][fully-static] yield types as "typed boundaries" for your code. With this rule enabled, ty would emit an error on the `yield returns_any()` statement -in `integers`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not -a subtype of `int`. This helps prevent the unsoundness from spreading far from its original source -(in this case, the return type of the `returns_any` function). +This rule treats ["fully static"][fully-static] yield types as "typed boundaries" for your code. +With this rule enabled, ty would emit an error on the `yield returns_any()` statement in `integers`, +since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of +`int`. This helps prevent the unsoundness from spreading far from its original source (in this case, +the return type of the `returns_any` function). -Note that this rule is only applied to functions annotated as yielding -[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in -your function's yield type, either implicitly or explicitly. It will still trigger on functions that have non-fully-static send and/or return types, however: +Note that this rule is only applied to functions annotated as yielding [fully static][fully-static] +types. It will not trigger if `Any` or `Unknown` appear anywhere in your function's yield type, +either implicitly or explicitly. It will still trigger on functions that have non-fully-static send +and/or return types, however: ```py from typing import Any, Generator @@ -7970,6 +8590,7 @@ def returns_any() -> Any: def dynamic_yield_type() -> Generator[Any]: + # no error yield returns_any() @@ -7978,12 +8599,12 @@ def static_yield_type() -> Generator[int, Any, Any]: yield returns_any() ``` -This rule works especially well when combined with ty's -[`missing-type-argument`](#missing-type-argument) rule, and the Ruff rules [`ANN201`][ann201], -[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all -these rules at once effectively makes it much less likely that a `yield` expression can lead to -unsoundness "leaking" out of a function unless that function has been *explicitly* annotated with -a dynamic type in some way (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example). +This rule works especially well when combined with ty's [`missing-type-argument`](#missing-type-argument) and +[`unsound-assignment`](#unsound-assignment) rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202], +[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once +effectively makes it much less likely that a `yield` expression can lead to unsoundness "leaking" +out of a function unless that function has been *explicitly* annotated with a dynamic type in some +way (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example). **Examples** @@ -8040,7 +8661,9 @@ generator boundaries. **See also** -- [`unsound-return-statement`](#unsound-return-statement) is a similar rule that triggers on unsound `return` statements rather than unsound `yield` expressions +- [`unsound-return-statement`](#unsound-return-statement) is a similar rule that triggers on unsound `return` statements rather + than unsound `yield` expressions +- [`unsound-assignment`](#unsound-assignment) is a similar rule that triggers on unsound assignments [ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ [ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ @@ -8057,7 +8680,7 @@ generator boundaries. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -8107,7 +8730,7 @@ A() # error: nothing says which specialization this is Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -8119,10 +8742,9 @@ Checks for class definitions that have bases which are unsupported by ty. **Why is this bad?** -If a class has a base that is an instance of a complex type such as a union type, -ty will not be able to resolve the [method resolution order] (MRO) for the class. -This will lead to an inferior understanding of your codebase and unpredictable -type-checking behavior. +If a class has a base that is an instance of a complex type such as a union type, ty will not be +able to resolve the [method resolution order] (MRO) for the class. This will lead to an inferior +understanding of your codebase and unpredictable type-checking behavior. **Examples** @@ -8154,7 +8776,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8166,8 +8788,8 @@ Checks for bool conversions where the object doesn't correctly implement `__bool **Why is this bad?** -If an exception is raised when you attempt to evaluate the truthiness of an object, -using the object in a boolean context will fail at runtime. +If an exception is raised when you attempt to evaluate the truthiness of an object, using the object +in a boolean context will fail at runtime. **Examples** @@ -8203,32 +8825,30 @@ b1 < b2 < b1 # error Default level: warn · Level under ty-compatible: ignore · Added in 0.0.12 · Related issues · -View source +View source **What it does** -Checks for dynamic class definitions (using `type()`) that have bases -which are unsupported by ty. +Checks for dynamic class definitions (using `type()`) that have bases which are unsupported by ty. -This is equivalent to [`unsupported-base`](#unsupported-base) but applies to classes created -via `type()` rather than `class` statements. +This is equivalent to [`unsupported-base`](#unsupported-base) but applies to classes created via `type()` rather than +`class` statements. **Why is this bad?** -If a dynamically created class has a base that is an unsupported type -such as `type[T]`, ty will not be able to resolve the -[method resolution order] (MRO) for the class. This may lead to an inferior +If a dynamically created class has a base that is an unsupported type such as `type[T]`, ty will not +be able to resolve the [method resolution order] (MRO) for the class. This may lead to an inferior understanding of your codebase and unpredictable type-checking behavior. **Default level** -This rule is disabled by default because it will not cause a runtime error, -and may be noisy on codebases that use `type()` in highly dynamic ways. +This rule is disabled by default because it will not cause a runtime error, and may be noisy on +codebases that use `type()` in highly dynamic ways. **Examples** @@ -8250,21 +8870,20 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source **What it does** -Checks for binary expressions, comparisons, and unary expressions where -the operands don't support the operator. +Checks for binary expressions, comparisons, and unary expressions where the operands don't support +the operator. **Why is this bad?** -Attempting to use an unsupported operator will raise a `TypeError` at -runtime. +Attempting to use an unsupported operator will raise a `TypeError` at runtime. **Examples** @@ -8283,7 +8902,7 @@ A() + A() # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -8295,14 +8914,12 @@ Checks for keys in an imported static resource that python cannot name. **Why is this bad?** -A static resource is read through attributes, so a key that is not a valid -python identifier — `build-backend`, `class`, `2` — has no attribute to be read -through, and is left out of the value the import binds. The document still holds -it; nothing in the program can reach it. +A static resource is read through attributes, so a key that is not a valid python identifier — +`build-backend`, `class`, `2` — has no attribute to be read through, and is left out of the value +the import binds. The document still holds it; nothing in the program can reach it. -Names with two leading underscores are left out for the same reason: python -mangles `__x` inside a class body, so the attribute the reader would write is -not the one that would exist. +Names with two leading underscores are left out for the same reason: python mangles `__x` inside a +class body, so the attribute the reader would write is not the one that would exist. **Examples** @@ -8328,22 +8945,21 @@ reveal_type(project.root) # revealed: "." Default level: warn · Added in 0.0.21 · Related issues · -View source +View source **What it does** -Checks for awaitable objects (such as coroutines) used as expression -statements without being awaited. +Checks for awaitable objects (such as coroutines) used as expression statements without being +awaited. **Why is this bad?** -Calling an `async def` function returns a coroutine object. If the -coroutine is never awaited, the body of the async function will never -execute, which is almost always a bug. Python emits a +Calling an `async def` function returns a coroutine object. If the coroutine is never awaited, the +body of the async function will never execute, which is almost always a bug. Python emits a `RuntimeWarning: coroutine was never awaited` at runtime in this case. **Examples** @@ -8378,8 +8994,8 @@ Checks for `ty: ignore` directives that are no longer applicable. **Why is this bad?** -A `ty: ignore` directive that no longer matches any diagnostic violations is likely -included by mistake, and should be removed to avoid confusion. +A `ty: ignore` directive that no longer matches any diagnostic violations is likely included by +mistake, and should be removed to avoid confusion. **Examples** @@ -8398,7 +9014,8 @@ a = 20 / 2 **Options** -Set [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) +Set +[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) to `false` to prevent this rule from reporting unused `type: ignore` comments. ## `unused-return-value` @@ -8407,7 +9024,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.71 · Related issues · -View source +View source @@ -8463,8 +9080,8 @@ Checks for `type: ignore` directives that are no longer applicable. **Why is this bad?** -A `type: ignore` directive that no longer matches any diagnostic violations is likely -included by mistake, and should be removed to avoid confusion. +A `type: ignore` directive that no longer matches any diagnostic violations is likely included by +mistake, and should be removed to avoid confusion. **Examples** @@ -8483,7 +9100,8 @@ a = 20 / 2 **Options** -This rule is skipped if [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) +This rule is skipped if +[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) to `false`. ## `useless-overload-body` @@ -8492,7 +9110,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -8504,10 +9122,10 @@ Checks for various `@overload`-decorated functions that have non-stub bodies. **Why is this bad?** -Functions decorated with `@overload` are ignored at runtime; they are overridden -by the implementation function that follows the series of overloads. While it is -not illegal to provide a body for an `@overload`-decorated function, it may indicate -a misunderstanding of how the `@overload` decorator works. +Functions decorated with `@overload` are ignored at runtime; they are overridden by the +implementation function that follows the series of overloads. While it is not illegal to provide a +body for an `@overload`-decorated function, it may indicate a misunderstanding of how the +`@overload` decorator works. **Example** @@ -8571,7 +9189,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8588,9 +9206,9 @@ Python's built-in sequence types raise a `ValueError` when sliced with a step si **Known problems** -This check is not exhaustive. It reports zero-step slices for certain built-in sequence -types where the operation is known to fail. A custom `__getitem__` implementation can -accept or reject such a slice, so ty cannot detect every runtime failure. +This check is not exhaustive. It reports zero-step slices for certain built-in sequence types where +the operation is known to fail. A custom `__getitem__` implementation can accept or reject such a +slice, so ty cannot detect every runtime failure. **Examples** diff --git a/crates/ty/docs/tracing.md b/crates/ty/docs/tracing.md index 898b8271d1..f47793ecee 100644 --- a/crates/ty/docs/tracing.md +++ b/crates/ty/docs/tracing.md @@ -124,7 +124,7 @@ TY_LOG_PROFILE=1 ty -- --current-directory=../test -vvv You can convert the textual representation into a visual one using `inferno`. ```shell -cargo install inferno +cargo install --locked inferno ``` ```shell diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 4521696b04..308fe6e800 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -41,7 +41,7 @@ pub(crate) enum Command { Check(CheckCommand), /// Start the language server - Server, + Server(ServerCommand), /// Display ty's version Version { @@ -390,7 +390,7 @@ pub(crate) struct CheckCommand { /// `ty-compatible` uses ty's own defaults instead, leaving basedpython's diagnostics and /// analysis options off, so that a project reports what ty itself would report. #[arg(long, value_name = "PRESET", value_enum)] - pub(crate) type_checking_preset: Option, + type_checking_preset: Option, #[clap(flatten)] pub(crate) verbosity: Verbosity, @@ -566,6 +566,19 @@ impl CheckCommand { } } +#[derive(Debug, Parser)] +pub(crate) struct ServerCommand { + /// Print the absolute path to the ty executable to use for the current folder. + /// + /// Discover the project from the current working directory. Use `environment.python` if it + /// is configured; otherwise, discover the Python environment in the normal order. + /// Print the path to ty if it is installed there. + /// If project discovery fails, use the current working directory as the discovery root. + /// Exit with status 0 if ty is found, 1 if discovery fails, or 2 on an unexpected error. + #[arg(long, hide = true)] + pub(crate) find_executable: bool, +} + /// A list of rules to enable or disable with a given severity. /// /// This type is used to parse the `--error`, `--warn`, and `--ignore` arguments diff --git a/crates/ty/src/by_commands.rs b/crates/ty/src/by_commands.rs index 406065095a..e0089b50d3 100644 --- a/crates/ty/src/by_commands.rs +++ b/crates/ty/src/by_commands.rs @@ -51,7 +51,7 @@ fn version_config(min_version: Option<&str>, cwd: &Path) -> anyhow::Result anyhow::Result { +fn parse_version(s: &str) -> anyhow::Result { let version = s .parse::() .map_err(|_| anyhow::anyhow!("unknown Python version {s:?} — use e.g. 3.12"))?; @@ -657,7 +657,7 @@ fn discover_interpreter( /// declares, which python it targets — has to be the same answer. Resolving it /// per question was not only repeated work: the copies disagreed about failure, /// one falling back to the working directory where another gave up. -pub(crate) struct ResolvedProject { +struct ResolvedProject { root: PathBuf, metadata: ProjectMetadata, } @@ -755,7 +755,7 @@ impl ResolvedProject { fn discovered_environment(root: &Path) -> Option { let sys_root = SystemPath::from_std_path(root)?; let system = OsSystem::new(sys_root); - let environment = PythonEnvironment::discover(sys_root, &system).ok()??; + let environment = PythonEnvironment::discover(Some(sys_root), &system).ok()??; let interpreter = environment.interpreter(&system)?; // discovery ends by falling back to whatever python is on `PATH`, which is // an interpreter but not a *project* environment — the difference is what diff --git a/crates/ty/src/by_wheels.rs b/crates/ty/src/by_wheels.rs index 85795aa77b..08154946fe 100644 --- a/crates/ty/src/by_wheels.rs +++ b/crates/ty/src/by_wheels.rs @@ -323,7 +323,7 @@ fn wheel_versions(cwd: &Path) -> anyhow::Result> { fn find_uv(cwd: &Path) -> anyhow::Result { if let Some(sys_cwd) = SystemPath::from_std_path(cwd) { let system = OsSystem::new(sys_cwd); - if let Ok(Some(environment)) = PythonEnvironment::discover(sys_cwd, &system) { + if let Ok(Some(environment)) = PythonEnvironment::discover(Some(sys_cwd), &system) { let binaries = if cfg!(windows) { environment.sys_prefix().join("Scripts") } else { diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 88fe69ad31..c81874bde3 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -9,8 +9,10 @@ mod logging; mod printer; mod python_version; mod rule; +mod server; mod version; +use std::fmt::Display; use std::io::{BufWriter, Write}; use std::path::Path; use std::process::{ExitCode, Termination}; @@ -33,10 +35,12 @@ use ruff_diagnostics::Applicability; use salsa::Database; use ty_project::metadata::settings::TerminalSettings; use ty_project::watch::ProjectWatcher; -use ty_project::{CollectReporter, Db, watch}; -use ty_project::{ProjectDatabase, ProjectMetadata}; +use ty_project::{ + ChangeResult, CollectReporter, Db, Project, ScriptEnvironmentAvailability, UvSyncProgress, + watch, +}; +use ty_project::{ProjectDatabase, ProjectMetadata, ProjectReloadResult}; use ty_python_semantic::{fix_all_diagnostics, suppress_all_diagnostics}; -use ty_server::run_server; use ty_static::EnvVars; use crate::args::{CheckCommand, Command, ExplainCommand, TerminalColor}; @@ -49,7 +53,7 @@ pub fn run() -> anyhow::Result { } /// run ty with an explicit arg list — used by `by` to pass remapped args without a subprocess -pub fn run_from_args(iter: I) -> anyhow::Result +fn run_from_args(iter: I) -> anyhow::Result where I: IntoIterator, T: Into + Clone, @@ -68,7 +72,9 @@ where // platform default — 1 MiB on windows — and the commands that check on the // calling thread rather than through the pool (`run`, `build`, `transpile`, // `compile`) were overflowing it there. so the whole command runs on a thread - // this codebase has sized for the job, wherever it is dispatched to + // this codebase has sized for the job, wherever it is dispatched to. a command + // does more per term than a check does, since it lowers the expression as well + // as inferring it, and `STACK_SIZE` is sized to leave room for that std::thread::scope(|scope| { let command = std::thread::Builder::new() .stack_size(STACK_SIZE) @@ -86,8 +92,8 @@ where fn run_command(command: Command) -> anyhow::Result { match command { - Command::Server => run_server().map(|()| ExitStatus::Success), Command::Check(check_args) => run_check(check_args), + Command::Server(server_args) => server::run(&server_args), Command::Version { output_format } => Ok(by_commands::cmd_version_by(output_format)), Command::GenerateShellCompletion { shell } => { use std::io::stdout; @@ -266,7 +272,6 @@ fn run_generate_api_file( let indexed = project.files(&db); let mut first_party_files: Vec<_> = indexed .iter() - .copied() .filter(|file| { let path = file.path(&db); // only system paths in first-party search paths @@ -334,16 +339,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result { tracing::debug!("Version: {}", version::version()); // The base path to which all CLI arguments are relative to. - let cwd = { - let cwd = std::env::current_dir().context("Failed to get the current working directory")?; - SystemPathBuf::from_path_buf(cwd).map_err(|path| { - anyhow!( - "The current working directory `{}` contains non-Unicode characters. \ - ty only supports Unicode paths.", - path.display() - ) - })? - }; + let cwd = current_directory()?; let project_path = args .project @@ -389,19 +385,13 @@ fn run_check(args: CheckCommand) -> anyhow::Result { ProjectMetadata::from_config_file(config_file.clone(), &project_path, &system)? } None if check_paths.iter().any(|path| system.is_file(path)) => { - // `uv check --script` passes a file as its check path. Disable uv workspace metadata - // for scripts until script integration is implemented in a follow-up. + // `uv check --script` passes a file as its check path. Standalone scripts must not + // inherit the enclosing workspace; their environments are synchronized separately. ProjectMetadata::discover_without_uv(&project_path, &system)? } None => ProjectMetadata::discover(&project_path, &system)?, }; - if watch && project_metadata.has_uv_workspace() { - return Err(anyhow!( - "`--watch` is not supported with uv workspace integration" - )); - } - project_metadata.apply_configuration_files(&system)?; project_metadata.apply_override_options(args.into_options()); @@ -487,13 +477,13 @@ fn run_check(args: CheckCommand) -> anyhow::Result { #[derive(Copy, Clone)] pub enum ExitStatus { - /// Checking was successful and there were no errors. + /// The command completed successfully. Success = 0, - /// Checking was successful but there were errors. + /// Checking was successful but there were errors, or executable discovery found no match. Failure = 1, - /// Checking failed due to an invocation error (e.g. the current directory no longer exists, incorrect CLI arguments, ...) + /// The command failed due to an invocation error (e.g. the current directory no longer exists, incorrect CLI arguments, ...) Error = 2, /// Internal ty error (panic, or any other error that isn't due to the user using the @@ -525,13 +515,12 @@ struct MainLoop { /// Receiver for the messages sent **to** the main loop. receiver: crossbeam_channel::Receiver, - /// Capacity-one channel used to coalesce pending workspace checks. - check_sender: crossbeam_channel::Sender<()>, - check_receiver: crossbeam_channel::Receiver<()>, - /// The file system watcher, if running in watch mode. watcher: Option, + /// Progress for the current synchronization batch, cleared before checking starts. + sync_progress: Option, + /// Interface for displaying information to the user. printer: Printer, @@ -544,7 +533,6 @@ struct MainLoop { impl MainLoop { fn new(mode: MainLoopMode, printer: Printer) -> (Self, MainLoopCancellationToken) { let (sender, receiver) = crossbeam_channel::bounded(10); - let (check_sender, check_receiver) = crossbeam_channel::bounded(1); let cancellation_token_source = CancellationTokenSource::new(); let cancellation_token = cancellation_token_source.token(); @@ -554,9 +542,8 @@ impl MainLoop { mode, sender: sender.clone(), receiver, - check_sender, - check_receiver, watcher: None, + sync_progress: None, printer, cancellation_token, }, @@ -579,8 +566,6 @@ impl MainLoop { } fn run(self, db: &mut ProjectDatabase) -> Result { - self.request_check(); - let result = self.main_loop(db); tracing::debug!("Exiting main loop"); @@ -588,37 +573,47 @@ impl MainLoop { result } - fn request_check(&self) { - // A pending request already represents a check of the latest database revision. - let _ = self.check_sender.try_send(()); - } - fn main_loop(mut self, db: &mut ProjectDatabase) -> Result { tracing::debug!("Starting main loop"); let mut revision = 0u64; + let uv_sync_wakeups = db.uv_environments().sync_wakeups(); + let (check_sender, check_receiver) = crossbeam_channel::bounded(1); + + // Initialize the uv environment for all scripts + let scripts: Vec<_> = db.project().script_files(db).iter().collect(); + self.synchronize_scripts(db, &scripts); + + request_check(&check_sender); // Apply all queued changes before starting a pending check because every applied change // cancels the running check. while let Ok(message) = crossbeam_channel::select_biased! { + recv(uv_sync_wakeups) -> wakeup => { + wakeup.map(|()| MainLoopMessage::PollUvEnvironments) + } recv(self.receiver) -> message => message, - recv(self.check_receiver) -> request => request.map(|()| MainLoopMessage::CheckWorkspace), + recv(check_receiver) -> request => request.map(|()| MainLoopMessage::CheckWorkspace), } { match message { MainLoopMessage::CheckWorkspace => { + // Synchronization may have started after this request was queued. + if db.uv_environments().has_pending_synchronizations() { + tracing::debug!("Deferring check until uv synchronization completes"); + continue; + } + self.sync_progress = None; let db = db.clone(); let sender = self.sender.clone(); + let printer = self.printer; // Spawn a new task that checks the project. This needs to be done in a separate thread // to prevent blocking the main loop here. rayon::spawn(move || { - let mut reporter = IndicatifReporter::from(self.printer); - let bar = reporter.bar.clone(); - match salsa::Cancelled::catch(|| { + let mut reporter = IndicatifReporter::new(printer.progress_target()); db.check_with_reporter(&mut reporter); - reporter.bar.finish_and_clear(); - reporter.collector.into_sorted(&db) + reporter.into_sorted_diagnostics(&db) }) { Ok(result) => { // Send the result back to the main loop for printing. @@ -627,13 +622,13 @@ impl MainLoop { .unwrap(); } Err(cancelled) => { - bar.finish_and_clear(); tracing::debug!("Check has been cancelled: {cancelled:?}"); } } }); + tracing::debug!("Waiting for next main loop message."); + continue; } - MainLoopMessage::CheckCompleted { result, revision: check_revision, @@ -744,17 +739,41 @@ impl MainLoop { return Ok(exit_status); } - MainLoopMessage::ApplyChanges(changes) => { - Printer::clear_screen()?; + MainLoopMessage::PollUvEnvironments => { + let environments = db.uv_environments().clone(); + let changes = environments.poll_sync(db); + if !changes.is_empty() { + revision += 1; + } + if let Some(project_change) = changes.project { + if matches!( + project_change, + ProjectReloadResult::Changed { + files_changed: true, + } + ) { + let scripts: Vec<_> = db.project().script_files(db).iter().collect(); + self.synchronize_scripts(db, &scripts); + } + + if let Some(watcher) = self.watcher.as_mut() { + watcher.update(db); + } + } + request_check(&check_sender); + } + MainLoopMessage::ApplyChanges(changes) => { revision += 1; // Automatically cancels any pending queries and waits for them to complete. - db.apply_changes(&changes); + let result = db.apply_changes(&changes); + self.synchronize_environments(db, &result)?; + if let Some(watcher) = self.watcher.as_mut() { watcher.update(db); } - self.request_check(); + request_check(&check_sender); } MainLoopMessage::Exit => { // Cancel any pending queries and wait for them to complete. @@ -769,6 +788,60 @@ impl MainLoop { Ok(ExitStatus::Success) } + fn synchronize_environments( + &mut self, + db: &mut ProjectDatabase, + changes: &ChangeResult, + ) -> Result<()> { + // Another filesystem event can arrive while a script is still synchronizing. + // Keep its progress visible after clearing the previous check's output. + if let Some(progress) = &self.sync_progress { + progress.bars.suspend(Printer::clear_screen)?; + } else { + Printer::clear_screen()?; + } + + let project_path = changes.project_sync_path(); + let scripts = changes.scripts_to_synchronize(db); + if project_path.is_none() && scripts.is_empty() { + return Ok(()); + } + + let progress = self + .sync_progress + .get_or_insert_with(|| SyncProgress::new(self.printer.progress_target())); + + if let Some(project_path) = project_path { + db.uv_environments() + .request_project_sync(db, project_path, &|db, project| { + progress.for_project(db, project) + }); + } + self.synchronize_scripts(db, &scripts); + Ok(()) + } + + fn synchronize_scripts(&mut self, db: &mut ProjectDatabase, scripts: &[File]) { + let environments = db.uv_environments().clone(); + + if scripts.is_empty() { + return; + } + + let progress = self + .sync_progress + .get_or_insert_with(|| SyncProgress::new(self.printer.progress_target())); + + for &file in scripts { + environments.request_sync( + db, + file, + ScriptEnvironmentAvailability::Pending, + &|db, file| progress.for_script(db, file), + ); + } + } + fn write_diagnostics( &self, db: &ProjectDatabase, @@ -910,45 +983,42 @@ fn exit_status_from_diagnostics( struct IndicatifReporter { collector: CollectReporter, - /// A reporter that is ready, containing a progress bar to report to. - /// - /// Initialization of the bar is deferred to [`ty_project::ProgressReporter::set_files`] so we - /// do not initialize the bar too early as it may take a while to collect the number of files to - /// process and we don't want to display an empty "0/0" bar. - bar: indicatif::ProgressBar, - - printer: Printer, + /// Kept hidden until the files to check have been collected, to avoid displaying "0/0". + checking_bar: indicatif::ProgressBar, } -impl From for IndicatifReporter { - fn from(printer: Printer) -> Self { +impl IndicatifReporter { + fn new(target: indicatif::ProgressDrawTarget) -> Self { Self { - bar: indicatif::ProgressBar::hidden(), + checking_bar: indicatif::ProgressBar::with_draw_target(None, target), collector: CollectReporter::default(), - printer, } } + + fn into_sorted_diagnostics(mut self, db: &dyn Db) -> Vec { + std::mem::take(&mut self.collector).into_sorted(db) + } } impl ty_project::ProgressReporter for IndicatifReporter { fn set_files(&mut self, files: usize) { self.collector.set_files(files); - self.bar.set_length(files as u64); - self.bar.set_message("Checking"); - self.bar.set_style( + self.checking_bar.set_length(files as u64); + self.checking_bar.set_message("Checking"); + self.checking_bar.set_style( indicatif::ProgressStyle::with_template( - "{msg:8.dim} {bar:60.green/dim} {pos}/{len} files", + "{msg:8.dim} {wide_bar:.green/dim} {pos}/{len} files", ) .unwrap() .progress_chars("--"), ); - self.bar.set_draw_target(self.printer.progress_target()); + self.checking_bar.force_draw(); } fn report_checked_file(&self, db: &ProjectDatabase, file: File, diagnostics: &[Diagnostic]) { self.collector.report_checked_file(db, file, diagnostics); - self.bar.inc(1); + self.checking_bar.inc(1); } fn report_diagnostics(&mut self, db: &ProjectDatabase, diagnostics: Vec) { @@ -956,6 +1026,111 @@ impl ty_project::ProgressReporter for IndicatifReporter { } } +impl Drop for IndicatifReporter { + fn drop(&mut self) { + self.checking_bar.finish_and_clear(); + } +} + +/// Shows the script count and individual uv status lines for one synchronization batch. +struct SyncProgress { + bars: indicatif::MultiProgress, + + /// Hidden until the first script synchronization is requested. + script_bar: indicatif::ProgressBar, +} + +impl SyncProgress { + fn new(target: indicatif::ProgressDrawTarget) -> Self { + let bars = indicatif::MultiProgress::with_draw_target(target); + let script_bar = indicatif::ProgressBar::hidden(); + script_bar.set_length(0); + script_bar.set_message("Syncing"); + script_bar.set_style( + indicatif::ProgressStyle::with_template( + "{msg:8.dim} {wide_bar:.green/dim} {pos}/{len} scripts", + ) + .unwrap() + .progress_chars("--"), + ); + let script_bar = bars.add(script_bar); + Self { bars, script_bar } + } + + fn for_project(&self, db: &dyn Db, project: Project) -> Option> { + Some(self.start("Refreshing", format_args!("{} metadata", project.name(db)))?) + } + + fn for_script(&self, db: &dyn Db, file: File) -> Option> { + let path = file.path(db).as_system_path()?; + let path = path.strip_prefix(db.project().root(db)).unwrap_or(path); + let mut progress = self.start("Syncing", path)?; + self.script_bar.inc_length(1); + self.script_bar.force_draw(); + progress.completion_bar = Some(self.script_bar.clone()); + Some(progress) + } + + fn start(&self, action: &str, target: impl Display) -> Option> { + if self.bars.is_hidden() { + return None; + } + + let bar = indicatif::ProgressBar::hidden(); + bar.set_style(indicatif::ProgressStyle::with_template("{wide_msg}").unwrap()); + bar.set_message(format!(" {} {target}", action.bold().cyan())); + Some(Box::new(UvSyncProgressBar { + bars: self.bars.clone(), + bar, + completion_bar: None, + })) + } +} + +impl Drop for SyncProgress { + fn drop(&mut self) { + self.script_bar.finish_and_clear(); + let _ = self.bars.clear(); + } +} + +struct UvSyncProgressBar { + bars: indicatif::MultiProgress, + bar: indicatif::ProgressBar, + /// The shared script synchronization bar, advanced when this request completes. + /// + /// This reporter handles both script synchronization (`Some`) and project metadata + /// refreshes (`None`), which do not contribute to the script count. + completion_bar: Option, +} + +impl UvSyncProgress for UvSyncProgressBar { + fn started(&mut self) { + self.bar.reset(); + self.bars.insert(0, self.bar.clone()); + // There are no periodic ticks to redraw this status line if drawing is rate-limited. + self.bar.force_draw(); + } + + fn finished(&mut self) { + self.bar.finish_and_clear(); + self.bars.remove(&self.bar); + } + + fn completed(self: Box) { + if let Some(bar) = &self.completion_bar { + bar.inc(1); + bar.force_draw(); + } + } +} + +impl Drop for UvSyncProgressBar { + fn drop(&mut self) { + self.finished(); + } +} + #[derive(Debug)] struct MainLoopCancellationToken { sender: crossbeam_channel::Sender, @@ -978,10 +1153,28 @@ enum MainLoopMessage { result: Vec, revision: u64, }, + PollUvEnvironments, ApplyChanges(Vec), Exit, } +fn request_check(sender: &crossbeam_channel::Sender<()>) { + // A full channel means that a check has already been requested. Keeping one pending request + // coalesces bursts of file changes while the main loop drains higher-priority work. + let _ = sender.try_send(()); +} + +fn current_directory() -> Result { + let cwd = std::env::current_dir().context("Failed to get the current working directory")?; + SystemPathBuf::from_path_buf(cwd).map_err(|path| { + anyhow!( + "The current working directory `{}` contains non-Unicode characters. \ + ty only supports Unicode paths.", + path.display() + ) + }) +} + fn set_colored_override(color: Option) { let Some(color) = color else { return; diff --git a/crates/ty/src/server.rs b/crates/ty/src/server.rs new file mode 100644 index 0000000000..43805d4871 --- /dev/null +++ b/crates/ty/src/server.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use anyhow::Result; +use ruff_db::system::OsSystem; +use ty_project::ProjectMetadata; +use ty_site_packages::PythonEnvironment; + +use crate::args::{ServerCommand, TerminalColor}; +use crate::logging::{VerbosityLevel, setup_tracing}; +use crate::printer::Printer; +use crate::{ExitStatus, current_directory}; + +/// Unix execute-permission bits for the file owner, group, and others. +#[cfg(unix)] +const EXECUTE_BITS: u32 = 0o111; + +pub(crate) fn run(args: &ServerCommand) -> Result { + if args.find_executable { + return find_executable(); + } + ty_server::run_server()?; + Ok(ExitStatus::Success) +} + +fn find_executable() -> Result { + let verbosity = VerbosityLevel::Quiet; + let printer = Printer::new(verbosity, true); + let _guard = setup_tracing(verbosity, TerminalColor::default())?; + let cwd = current_directory()?; + let system = OsSystem::new(&cwd); + + let environment = match ProjectMetadata::discover(&cwd, &system) { + Ok(project) => match project.to_merged_options().python_environment(&system) { + Ok(None) => PythonEnvironment::discover(Some(project.root()), &system) + .map_err(anyhow::Error::from), + configured => configured, + }, + Err(error) => { + tracing::debug!("Failed to discover a project, falling back to `{cwd}`: {error}"); + PythonEnvironment::discover(Some(&cwd), &system).map_err(anyhow::Error::from) + } + }; + + let environment = match environment { + Ok(Some(environment)) => environment, + Ok(None) => return Ok(ExitStatus::Failure), + Err(error) => { + tracing::debug!("Failed to discover a Python environment: {error}"); + return Ok(ExitStatus::Failure); + } + }; + + let candidate = environment.sys_prefix().join(if cfg!(windows) { + "Scripts/ty.exe" + } else { + "bin/ty" + }); + + let Ok(metadata) = fs::metadata(candidate.as_std_path()).inspect_err(|error| { + tracing::debug!("Failed to read file metadata for `{candidate}`: {error}"); + }) else { + return Ok(ExitStatus::Failure); + }; + + if !metadata.is_file() { + return Ok(ExitStatus::Failure); + } + + #[cfg(unix)] + if metadata.permissions().mode() & EXECUTE_BITS == 0 { + return Ok(ExitStatus::Failure); + } + + writeln!(printer.stream_for_requested_summary().lock(), "{candidate}")?; + + Ok(ExitStatus::Success) +} diff --git a/crates/ty/tests/cli/api_lockfile.rs b/crates/ty/tests/cli/api_lockfile.rs index 7a7759d383..476b90942f 100644 --- a/crates/ty/tests/cli/api_lockfile.rs +++ b/crates/ty/tests/cli/api_lockfile.rs @@ -41,7 +41,7 @@ def _private() -> None: exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:1 module.CONST:v=builtins.int @@ -90,7 +90,7 @@ class Dog(Animal): exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:2 base.Animal.speak:d(self:base.Animal)->builtins.str @@ -127,7 +127,7 @@ def f(a: int, b: str = '', /, c: float = 0.0, *args: bytes, d: bool = False, **k exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:1 sigs.f:d(a:builtins.int,b:builtins.str=,/,c:builtins.float | builtins.int=,*args:builtins.bytes,d:builtins.bool=,**kwargs:builtins.int)->None @@ -172,7 +172,7 @@ class D[T, U]: exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:1 g.A.f:d(self:Self)->T @@ -224,7 +224,7 @@ class Mutable[T]: exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:1 fr.Frozen.x:v=T @@ -273,7 +273,7 @@ class Cell(Generic[T]): exit_code: 0 ----- stdout ----- #api-lock:v=1 - #tool:by=0.0.8 + #tool:by=0.0.12 #python:default #modules:1 ex.Box.get:d(self:Self)->T_co diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs index a38fe1f61c..7306174f69 100644 --- a/crates/ty/tests/cli/file_selection.rs +++ b/crates/ty/tests/cli/file_selection.rs @@ -90,6 +90,27 @@ fn exclude_scripts_only_applies_to_implicitly_discovered_files() -> anyhow::Resu Ok(()) } +#[test] +fn exclude_scripts_ignores_scripts_with_invalid_toml() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("main.py", "value: int = 1"), + ( + "script.py", + r#" + # /// script + # requires-python = + # /// + value: int = "script" + "#, + ), + ])?; + + let output = case.command().arg("--exclude-scripts").output()?; + assert!(output.status.success(), "{output:?}"); + + Ok(()) +} + /// Test exclude CLI argument functionality #[test] fn exclude_argument() -> anyhow::Result<()> { diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 2237d0ed23..d073a599a9 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -9,6 +9,7 @@ mod python_environment; mod rule; mod rule_selection; mod scripts; +mod server; mod type_checking_preset; mod uv_workspace; @@ -1082,8 +1083,12 @@ impl CliTest { } pub(crate) fn command(&self) -> Command { + self.command_with_subcommand("check") + } + + fn command_with_subcommand(&self, subcommand: &str) -> Command { let mut command = Command::new(&self.ty_binary_path); - command.current_dir(&self.project_dir).arg("check"); + command.current_dir(&self.project_dir).arg(subcommand); // Unset all environment variables because they can affect test behavior. command.env_clear(); @@ -1096,6 +1101,20 @@ impl CliTest { command } + #[cfg(feature = "test-uv")] + pub(crate) fn command_inheriting_environment(&self) -> Command { + let mut command = Command::new(&self.ty_binary_path); + command.current_dir(&self.project_dir).arg("check"); + + // Point user config discovery at a test-local directory to avoid picking up host config. + command.env( + user_config_directory_env_var(), + self.user_config_directory(), + ); + + command + } + fn user_config_directory(&self) -> PathBuf { self.project_dir .parent() diff --git a/crates/ty/tests/cli/rule.rs b/crates/ty/tests/cli/rule.rs index bf74b1a9f1..ad8ba2ce3c 100644 --- a/crates/ty/tests/cli/rule.rs +++ b/crates/ty/tests/cli/rule.rs @@ -21,13 +21,13 @@ fn rule_default_output() { Detects returned values that can't be assigned to the function's annotated return type. - Note that the special case of a function with a non-`None` return type and an empty body - is handled by the separate `empty-body` error code. + Note that the special case of a function with a non-`None` return type and an empty body is handled + by the separate `empty-body` error code. ## Why is this bad? - Returning an object of a type incompatible with the annotated return type - is unsound, and will lead to ty inferring incorrect types elsewhere. + Returning an object of a type incompatible with the annotated return type is unsound, and will lead + to ty inferring incorrect types elsewhere. ## Examples @@ -49,19 +49,50 @@ fn rule_json_output() { { "name": "invalid-return-type", "summary": "detects returned values that can't be assigned to the function's annotated return type", - "documentation": "## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body\nis handled by the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type\nis unsound, and will lead to ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```", + "documentation": "## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body is handled\nby the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type is unsound, and will lead\nto ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```", "default_level": "error", "ty_compat": "same", "status": { "type": "stable", "since": "0.0.1-alpha.1" }, - "markdown": "# invalid-return-type\n\nDefault level: error | Stable (since 0.0.1-alpha.1)\n\n## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body\nis handled by the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type\nis unsound, and will lead to ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```" + "markdown": "# invalid-return-type\n\nDefault level: error | Stable (since 0.0.1-alpha.1)\n\n## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body is handled\nby the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type is unsound, and will lead\nto ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```" } ----- stderr ----- "###); } +#[test] +fn preview_rule_status() -> anyhow::Result<()> { + let output = ty_cmd() + .args(["explain", "rule", "missing-direct-dependency"]) + .output()?; + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!( + stdout.contains("Default level: ignore | Preview (since 0.0.76)"), + "{stdout}" + ); + + let output = ty_cmd() + .args([ + "explain", + "rule", + "missing-direct-dependency", + "--output-format", + "json", + ]) + .output()?; + assert!(output.status.success()); + let rule: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!( + rule["status"], + serde_json::json!({"type": "preview", "since": "0.0.76"}) + ); + + Ok(()) +} + #[test] fn rule_unknown() { assert_cmd_snapshot!(ty_cmd().args(["explain", "rule", "does-not-exist"]), @" diff --git a/crates/ty/tests/cli/scripts.rs b/crates/ty/tests/cli/scripts.rs index e26867a454..72f485ffff 100644 --- a/crates/ty/tests/cli/scripts.rs +++ b/crates/ty/tests/cli/scripts.rs @@ -1,4 +1,5 @@ use insta_cmd::assert_cmd_snapshot; +use ty_static::EnvVars; use crate::CliTest; @@ -52,6 +53,56 @@ fn project_settings_and_overrides_apply() -> anyhow::Result<()> { Ok(()) } +/// an override that names the file outranks the block only for the rules it actually +/// configures. one that is silent about a rule leaves the script's own answer standing, +/// rather than discarding the whole block for having been named +#[test] +fn override_silent_about_a_rule_leaves_the_script_block_standing() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.rules] + unresolved-reference = "ignore" + + [[tool.ty.overrides]] + include = ["script.py"] + + [tool.ty.overrides.rules] + division-by-zero = "warn" + "#, + ), + ( + "script.py", + r#" + # /// script + # [tool.ty.rules] + # unresolved-reference = "warn" + # /// + + print(missing) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `missing` used when not defined + --> script.py:7:7 + | + 7 | print(missing) + | ^^^^^^^ + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + /// with no override naming the file, the script's own block wins over the /// project's top-level options. #[test] @@ -221,34 +272,172 @@ fn metadata_without_tool_ty_uses_project_settings() -> anyhow::Result<()> { } #[test] -fn environment_options() -> anyhow::Result<()> { - // TODO: This is not yet supported, but we should support this. +fn verbose_rule_diagnostics_identify_script_metadata() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # [tool.ty.rules] + # unresolved-reference = "warn" + # /// + + print(missing) + "#, + )?; + + assert_cmd_snapshot!(case.command().arg("--verbose"), @" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `missing` used when not defined + --> script.py:7:7 + | + 7 | print(missing) + | ^^^^^^^ + info: rule `unresolved-reference` was selected in script metadata + + Found 1 diagnostic + + ----- stderr ----- + INFO Indexed 1 file(s) in 0.000s + "); + + Ok(()) +} + +#[test] +fn unknown_rule_diagnostics_point_to_script_metadata() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # [tool.ty.rules] + # unknown-script-rule = "warn" + # /// + "#, + )?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + warning[unknown-rule]: Unknown rule `unknown-script-rule` + --> script.py:4:3 + | + 4 | # unknown-script-rule = "warn" + | ^^^^^^^^^^^^^^^^^^^ + + Found 1 diagnostic + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn python_version_diagnostics_identify_script_metadata() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # /// + + PythonFinalizationError + "#, + )?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `PythonFinalizationError` used when not defined + --> script.py:6:1 + | + 6 | PythonFinalizationError + | ^^^^^^^^^^^^^^^^^^^^^^^ + info: `PythonFinalizationError` was added as a builtin in Python 3.13 + info: Python 3.12 was assumed when resolving types because it was specified in script metadata + --> script.py:3:21 + | + 3 | # requires-python = ">=3.12" + | ^^^^^^^^ Python version configured here + + Found 1 diagnostic + + ----- stderr ----- + "#); + assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @" + success: false + exit_code: 1 + ----- stdout ----- + script.py:6:1: error[unresolved-reference] Name `PythonFinalizationError` used when not defined + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn metadata_without_tool_ty_uses_default_settings() -> anyhow::Result<()> { let case = CliTest::with_files([ ( "pyproject.toml", r#" - [tool.ty.environment] - python-version = "3.12" + [tool.ty.rules] + all = "ignore" + + [tool.ty.analysis] + respect-type-ignore-comments = false "#, ), ( "script.py", r#" # /// script - # requires-python = ">=3.7" - # - # [tool.ty.environment] - # python-version = "3.7" + # dependencies = [] # /// - import sys - from typing import reveal_type - - reveal_type(sys.version_info[:2] == (3, 12)) + value: int = "not an int" + suppressed: int = "not an int" # type: ignore "#, ), ])?; + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn environment_options() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # + # [tool.ty.environment] + # python-version = "3.11" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + "#, + )?; + assert_cmd_snapshot!(case.command(), @" success: true exit_code: 0 @@ -256,8 +445,8 @@ fn environment_options() -> anyhow::Result<()> { info[revealed-type]: Revealed type --> script.py:12:13 | - 12 | reveal_type(sys.version_info[:2] == (3, 12)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Literal[True]` + 12 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[11]]` Found 1 diagnostic @@ -528,3 +717,1462 @@ fn explicit_config_replaces_inline_metadata() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn explicit_config_replaces_the_script_environment() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "explicit.toml", + r#" + [environment] + python-version = "3.12" + python-platform = "linux" + "#, + ), + ( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # [tool.ty.environment] + # python-platform = "win32" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + reveal_type(sys.platform) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command().arg("--config-file").arg("explicit.toml"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:11:13 + | + 11 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[12]]` + + info[revealed-type]: Revealed type + --> script.py:12:13 + | + 12 | reveal_type(sys.platform) + | ^^^^^^^^^^^^ `Literal["linux"]` + + Found 2 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn cli_arguments_override_script_environment() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # [tool.ty.environment] + # python-platform = "win32" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + reveal_type(sys.platform) + "#, + )?; + + assert_cmd_snapshot!( + case.command() + .arg("--python-version") + .arg("3.12") + .arg("--python-platform") + .arg("linux"), + @r#" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:11:13 + | + 11 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[12]]` + + info[revealed-type]: Revealed type + --> script.py:12:13 + | + 12 | reveal_type(sys.platform) + | ^^^^^^^^^^^^ `Literal["linux"]` + + Found 2 diagnostics + + ----- stderr ----- + "# + ); + + Ok(()) +} + +#[test] +fn script_version_and_platform_are_isolated_from_project_configuration() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-version = "3.12" + python-platform = "linux" + "#, + ), + ( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # [tool.ty.environment] + # python-version = "3.11" + # python-platform = "win32" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + reveal_type(sys.platform) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:12:13 + | + 12 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[11]]` + + info[revealed-type]: Revealed type + --> script.py:13:13 + | + 13 | reveal_type(sys.platform) + | ^^^^^^^^^^^^ `Literal["win32"]` + + Found 2 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn python_requirement_overrides_user_configuration() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + "#, + )?; + case.write_file( + case.user_config_directory().join("ty/ty.toml"), + r#" + [environment] + python-version = "3.12" + "#, + )?; + + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:9:13 + | + 9 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[13]]` + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn scripts_have_no_implicit_first_party_roots() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("shared.py", "value = 1\n"), + ("src/layout_dependency.py", "value = 1\n"), + ("scripts/local_dependency.py", "value = 1\n"), + ( + "scripts/script.py", + r#" + # /// script + # dependencies = [] + # /// + + from layout_dependency import value as layout_value + from local_dependency import value as local_value + from shared import value + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `layout_dependency` + --> scripts/script.py:6:6 + | + 6 | from layout_dependency import value as layout_value + | ^^^^^^^^^^^^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + error[unresolved-import]: Cannot resolve imported module `local_dependency` + --> scripts/script.py:7:6 + | + 7 | from local_dependency import value as local_value + | ^^^^^^^^^^^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + error[unresolved-import]: Cannot resolve imported module `shared` + --> scripts/script.py:8:6 + | + 8 | from shared import value + | ^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + Found 3 diagnostics + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn configured_source_roots_and_extra_paths_are_relative_to_the_script() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("scripts/source/first_party.py", "value = 1\n"), + ("scripts/extra/dependency.py", "value = 1\n"), + ( + "scripts/script.py", + r#" + # /// script + # [tool.ty.environment] + # root = ["./source"] + # extra-paths = ["./extra"] + # /// + + from dependency import value as dependency + from first_party import value as first_party + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn project_search_paths_do_not_apply_to_scripts() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + root = ["./project-source"] + extra-paths = ["./project-extra"] + "#, + ), + ("project-source/project_only.py", "value = 1\n"), + ("project-extra/extra_only.py", "value = 1\n"), + ( + "ordinary.py", + "from extra_only import value as extra\nfrom project_only import value as project\n", + ), + ( + "scripts/script.py", + r#" + # /// script + # dependencies = [] + # /// + + from extra_only import value as extra + from project_only import value as project + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `extra_only` + --> scripts/script.py:6:6 + | + 6 | from extra_only import value as extra + | ^^^^^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + error[unresolved-import]: Cannot resolve imported module `project_only` + --> scripts/script.py:7:6 + | + 7 | from project_only import value as project + | ^^^^^^^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + Found 2 diagnostics + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn shared_imports_use_each_scripts_platform() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "shared.py", + r#" + import sys + + if sys.platform == "win32": + value = "windows" + else: + value = "other" + "#, + ), + ( + "windows.py", + r#" + # /// script + # [tool.ty.environment] + # extra-paths = ["."] + # python-platform = "win32" + # /// + + from shared import value + from typing import reveal_type + + reveal_type(value) + "#, + ), + ( + "linux.py", + r#" + # /// script + # [tool.ty.environment] + # extra-paths = ["."] + # python-platform = "linux" + # /// + + from shared import value + from typing import reveal_type + + reveal_type(value) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> linux.py:11:13 + | + 11 | reveal_type(value) + | ^^^^^ `Literal["other"]` + + info[revealed-type]: Revealed type + --> windows.py:11:13 + | + 11 | reveal_type(value) + | ^^^^^ `Literal["windows"]` + + Found 2 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn inherited_file_settings_are_relative_to_the_script() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("user-extra/project_dependency.py", "value = 1\n"), + ("scripts/user-extra/user_dependency.py", "value = 1\n"), + ("cli-extra/cli_dependency.py", "value = 1\n"), + ( + "scripts/script.py", + r#" + # /// script + # dependencies = [] + # /// + + from user_dependency import value as user_value + from cli_dependency import value as cli_value + "#, + ), + ])?; + case.write_file( + case.user_config_directory().join("ty/ty.toml"), + r#" + [environment] + extra-paths = ["./user-extra"] + "#, + )?; + + assert_cmd_snapshot!(case.command().arg("--extra-search-path").arg("./cli-extra"), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn scripts_do_not_use_an_inactive_project_environment() -> anyhow::Result<()> { + let dependency = if cfg!(windows) { + ".venv/Lib/site-packages/project_dependency.py" + } else { + ".venv/lib/python3.13/site-packages/project_dependency.py" + }; + + let case = CliTest::with_files([ + (".venv/pyvenv.cfg", "home = ./\nversion = 3.13\n"), + (dependency, "value = 1\n"), + ( + "scripts/script.py", + r#" + # /// script + # dependencies = [] + # /// + + from project_dependency import value + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `project_dependency` + --> scripts/script.py:6:6 + | + 6 | from project_dependency import value + | ^^^^^^^^^^^^^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn scripts_use_an_activated_virtual_environment() -> anyhow::Result<()> { + let dependency = if cfg!(windows) { + ".venv/Lib/site-packages/project_dependency.py" + } else { + ".venv/lib/python3.13/site-packages/project_dependency.py" + }; + + let case = CliTest::with_files([ + (".venv/pyvenv.cfg", "home = ./\nversion = 3.13\n"), + (dependency, "value = 1\n"), + ( + "scripts/script.py", + r#" + # /// script + # dependencies = [] + # /// + + from project_dependency import value + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command().env("VIRTUAL_ENV", case.root().join(".venv")), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn invalid_toml_reports_configuration_error() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = + # /// + + print(missing) + "#, + )?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-script-metadata]: string values must be quoted, expected literal string + --> script.py:3:20 + | + 3 | # requires-python = + | ^ + + Found 1 diagnostic + + ----- stderr ----- + "); + assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @" + success: false + exit_code: 1 + ----- stdout ----- + script.py:3:20: error[invalid-script-metadata] string values must be quoted, expected literal string + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn invalid_metadata_options_report_configuration_error() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # [tool.ty.environment] + # python-version = true + # /// + + print(missing) + "#, + )?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-script-metadata]: wanted string or table + --> script.py:4:20 + | + 4 | # python-version = true + | ^^^^ + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn invalid_python_requirement_reports_configuration_error() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-platform = "linux" + + [tool.ty.rules] + unresolved-reference = "error" + "#, + ), + ( + "script.py", + r#" + # /// script + # requires-python = "<3.12" + # [tool.ty.environment] + # python-platform = "win32" + # [tool.ty.rules] + # unresolved-reference = "warn" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.platform) + print(missing) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-script-metadata]: value `<3.12` does not contain a lower bound + --> script.py:3:21 + | + 3 | # requires-python = "<3.12" + | ^^^^^^^ + info: Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`. + + Found 1 diagnostic + + ----- stderr ----- + "#); + assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @" + success: false + exit_code: 1 + ----- stdout ----- + script.py:3:21: error[invalid-script-metadata] value `<3.12` does not contain a lower bound + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn invalid_script_settings_report_configuration_error() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-platform = "linux" + "#, + ), + ( + "script.py", + r#" + # /// script + # [tool.ty.src] + # include = ["src/**test/"] + # [tool.ty.environment] + # python-platform = "win32" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.platform) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-glob]: Invalid pattern + --> script.py:4:14 + | + 4 | # include = ["src/**test/"] + | ^^^^^^^^^^^^^ Too many stars at position 5 + + Found 1 diagnostic + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn invalid_script_environment_reports_configuration_error() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-version = "3.13" + python-platform = "linux" + "#, + ), + ( + "script.py", + r#" + # /// script + # [tool.ty.environment] + # python = "./missing-environment" + # python-version = "3.12" + # python-platform = "win32" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2]) + reveal_type(sys.platform) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-script-metadata]: Invalid `environment.python` setting in script metadata `/missing-environment`: does not point to a Python executable or a directory on disk + --> script.py:4:12 + | + 4 | # python = "./missing-environment" + | ^^^^^^^^^^^^^^^^^^^^^^^ + + Found 1 diagnostic + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn invalid_script_search_paths_do_not_blame_python_environment() -> anyhow::Result<()> { + let dependency = if cfg!(windows) { + "environment/Lib/site-packages/dependency.py" + } else { + "environment/lib/python3.13/site-packages/dependency.py" + }; + + let case = CliTest::with_files([ + ("environment/pyvenv.cfg", "home = ./\nversion = 3.13\n"), + (dependency, "value = 1\n"), + ( + "script.py", + r#" + # /// script + # [tool.ty.environment] + # python = "./environment" + # typeshed = "./missing-typeshed" + # /// + + print(missing) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-script-metadata]: Failed to read the custom typeshed versions file '/missing-typeshed/stdlib/VERSIONS' + --> script.py:5:14 + | + 5 | # typeshed = "./missing-typeshed" + | ^^^^^^^^^^^^^^^^^^^^ + + Found 1 diagnostic + + ----- stderr ----- + "#); + assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @" + success: false + exit_code: 1 + ----- stdout ----- + script.py:5:14: error[invalid-script-metadata] Failed to read the custom typeshed versions file '/missing-typeshed/stdlib/VERSIONS' + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn unavailable_uv_reports_metadata_error() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + "#!/usr/bin/env python3\n\n# /// script\n# dependencies = []\n# ///\nprint(missing)\n", + )? + .with_filter( + "program not found", + "No such file or directory (os error 2)", + ); + + assert_cmd_snapshot!( + case.command() + .arg("script.py") + .env(EnvVars::TY_UV, "1") + .env(EnvVars::UV, "missing-uv-executable"), + @" + success: false + exit_code: 1 + ----- stdout ----- + error[uv-metadata]: Failed to invoke `uv workspace metadata`: No such file or directory (os error 2) + --> script.py:3:1 + + Found 1 diagnostic + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn ordinary_files_do_not_initialize_scripts() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "script.py", + "# /// script\n# dependencies = []\n# ///\nvalue = 1\n", + ), + ("ordinary.py", "value = 1\n"), + ])?; + assert_cmd_snapshot!( + case.command() + .arg("ordinary.py") + .env(EnvVars::TY_UV, "1") + .env(EnvVars::UV, "missing-uv-executable"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn disabled_integration_does_not_initialize_scripts() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + "# /// script\n# dependencies = []\n# ///\nvalue = 1\n", + )?; + + assert_cmd_snapshot!( + case.command() + .arg("script.py") + .env(EnvVars::UV, "missing-uv-executable"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn excluded_scripts_do_not_initialize_their_environments() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("ty.toml", "[src]\nexclude-scripts = true\n"), + ( + "script.py", + "# /// script\n# dependencies = []\n# ///\nvalue = 1\n", + ), + ("ordinary.py", "value = 1\n"), + ])?; + assert_cmd_snapshot!( + case.command() + .arg(".") + .args(["--config-file", "ty.toml"]) + .env(EnvVars::TY_UV, "1") + .env(EnvVars::UV, "missing-uv-executable"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[cfg(feature = "test-uv")] +mod uv_metadata { + use std::{fs, process::Command}; + + use insta_cmd::assert_cmd_snapshot; + use ty_static::EnvVars; + + use crate::CliTest; + use crate::uv_workspace::{uv_sync_command, write_dependency_wheel}; + + fn command_with_script_uv(case: &CliTest) -> Command { + let mut command = case.command_inheriting_environment(); + command + .env(EnvVars::TY_UV, "1") + .env(EnvVars::UV, "uv") + .env("UV_CACHE_DIR", case.root().join("cache")); + command + } + + fn assert_uv_supports_script_metadata() -> anyhow::Result<()> { + let output = Command::new("uv") + .args(["workspace", "metadata", "--help"]) + .output()?; + + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("--script"), + "installed uv does not support script metadata" + ); + + Ok(()) + } + + #[test] + fn uses_uv_script_environment_and_python_version() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # [tool.ty.environment] + # python-version = "3.10" + # /// + + import sys + from attrs import define + from typing import reveal_type + + @define + class User: + value: int + + reveal_type(User(1).value) + reveal_type(sys.version_info[:2]) + "#, + )? + .with_filter(r"Literal\[(?:1[2-9]|[2-9][0-9])\]", "Literal[]"); + + assert_cmd_snapshot!(command_with_script_uv(&case).arg("script.py"), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:17:13 + | + 17 | reveal_type(User(1).value) + | ^^^^^^^^^^^^^ `int` + + info[revealed-type]: Revealed type + --> script.py:18:13 + | + 18 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[]]` + + Found 2 diagnostics + + ----- stderr ----- + "); + + assert_cmd_snapshot!( + command_with_script_uv(&case) + .arg("script.py") + .args(["--python-version", "3.11"]), + @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:17:13 + | + 17 | reveal_type(User(1).value) + | ^^^^^^^^^^^^^ `int` + + info[revealed-type]: Revealed type + --> script.py:18:13 + | + 18 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[11]]` + + Found 2 diagnostics + + ----- stderr ----- + " + ); + + Ok(()) + } + + fn script_with_indirect_dependency() -> anyhow::Result { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.8" + # dependencies = ["direct-dependency"] + # [tool.uv] + # no-index = true + # find-links = ["wheels"] + # /// + + import direct_module + from indirect_module import value + import indirect_module + "#, + )?; + write_dependency_wheel(&case, "indirect-dependency", "indirect_module", &[])?; + write_dependency_wheel( + &case, + "direct-dependency", + "direct_module", + &["indirect-dependency"], + )?; + Ok(case) + } + + #[test] + fn indirect_dependencies_use_script_declarations() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = script_with_indirect_dependency()?; + let mut command = command_with_script_uv(&case); + command + .arg("script.py") + .env("UV_OFFLINE", "1") + .env("UV_PYTHON_DOWNLOADS", "never"); + + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + warning[undeclared-dependency]: `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + --> script.py:11:6 + | + 11 | from indirect_module import value + | ^^^^^^^^^^^^^^^ + info: It is installed because `direct_dependency` requires it + info: Add `indirect_dependency` to the project's dependencies + + warning[undeclared-dependency]: `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + --> script.py:12:8 + | + 12 | import indirect_module + | ^^^^^^^^^^^^^^^ + info: It is installed because `direct_dependency` requires it + info: Add `indirect_dependency` to the project's dependencies + + Found 2 diagnostics + + ----- stderr ----- + "); + + command.args(["--error", "missing-direct-dependency"]); + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + error[missing-direct-dependency]: Import of `indirect_module` requires a direct dependency on `indirect-dependency` + --> script.py:11:6 + | + 11 | from indirect_module import value + | ^^^^^^^^^^^^^^^ + help: Declare `indirect-dependency` in the script's inline `dependencies` metadata + info: See https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies + + warning[undeclared-dependency]: `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + --> script.py:11:6 + | + 11 | from indirect_module import value + | ^^^^^^^^^^^^^^^ + info: It is installed because `direct_dependency` requires it + info: Add `indirect_dependency` to the project's dependencies + + error[missing-direct-dependency]: Import of `indirect_module` requires a direct dependency on `indirect-dependency` + --> script.py:12:8 + | + 12 | import indirect_module + | ^^^^^^^^^^^^^^^ + help: Declare `indirect-dependency` in the script's inline `dependencies` metadata + info: See https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies + + warning[undeclared-dependency]: `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + --> script.py:12:8 + | + 12 | import indirect_module + | ^^^^^^^^^^^^^^^ + info: It is installed because `direct_dependency` requires it + info: Add `indirect_dependency` to the project's dependencies + + Found 4 diagnostics + + ----- stderr ----- + "); + + Ok(()) + } + + #[test] + fn workspace_dependencies_do_not_apply_to_scripts() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = script_with_indirect_dependency()?; + case.write_files([ + ( + "pyproject.toml", + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["indirect-dependency"] + + [tool.uv] + no-index = true + find-links = ["wheels"] + "#, + ), + ( + "ordinary.py", + r#" + import indirect_module + from typing_extensions import reveal_type + + reveal_type(indirect_module.value) + "#, + ), + ])?; + + assert_cmd_snapshot!( + uv_sync_command(&case, None)? + .args(["--error", "missing-direct-dependency"]), + @" + success: false + exit_code: 1 + ----- stdout ----- + ordinary.py:5:13: info[revealed-type] Revealed type: `int` + script.py:11:6: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + script.py:11:6: warning[undeclared-dependency] `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + script.py:12:8: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + script.py:12:8: warning[undeclared-dependency] `indirect_module` comes from `indirect_dependency`, which this project does not directly depend on + Found 5 diagnostics + + ----- stderr ----- + " + ); + + Ok(()) + } + + #[test] + fn imported_script_environment() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_files([ + ("a.py", "from b import foo\nprint(foo)\n"), + ( + "b.py", + r#" + # /// script + # requires-python = "==3.12.*" + # dependencies = [] + # [tool.ty.environment] + # python-version = "3.11" + # /// + import sys + from typing import Literal, assert_type + + foo = 1 + assert_type(sys.version_info[:2], tuple[Literal[3], Literal[12]]) + "#, + ), + ])?; + + // The script uses uv's Python version even when its importer is checked first. + assert_cmd_snapshot!( + command_with_script_uv(&case) + .args(["a.py", "b.py"]) + .env(EnvVars::TY_UV, "scripts") + .env(EnvVars::TY_MAX_PARALLELISM, "1"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) + } + + #[test] + fn synchronizes_imported_script_with_one_worker() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_files([ + ("a.py", "from b import foo\nprint(foo)\n"), + ( + "b.py", + r#" + # /// script + # requires-python = "==3.12.*" + # dependencies = [] + # /// + foo = 1 + "#, + ), + ])?; + + // Starting with the script must not run its importer on the same Rayon worker while + // initialization is waiting for uv. The importer would wait for that initialization. + assert_cmd_snapshot!( + command_with_script_uv(&case) + .args(["b.py", "a.py"]) + .env(EnvVars::TY_UV, "scripts") + .env(EnvVars::TY_MAX_PARALLELISM, "1"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) + } + + #[test] + fn synchronizes_multiple_scripts_with_one_worker() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let script = r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#; + let case = CliTest::with_files([("first.py", script), ("second.py", script)])?; + + assert_cmd_snapshot!( + command_with_script_uv(&case) + .args(["first.py", "second.py"]) + .env(EnvVars::TY_MAX_PARALLELISM, "1"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) + } + + #[test] + fn cli_python_selects_script_interpreter_without_replacing_its_environment() + -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_file( + "scripts/script.py", + r#" + # /// script + # requires-python = ">=3.11" + # dependencies = ["attrs==25.4.0"] + # /// + + import sys + from attrs import define + from typing import reveal_type + + @define + class User: + value: int + + reveal_type(User(1).value) + reveal_type(sys.version_info[:2]) + "#, + )?; + + // The CLI environment selects uv's interpreter, but the script's dependencies must still + // come from the separate environment that uv creates for the script. + let environment = case.root().join(".venv"); + let output = Command::new("uv") + .args(["venv", "--no-project", "--python", "3.12"]) + .arg(&environment) + .env("UV_CACHE_DIR", case.root().join("cache")) + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to create project environment: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_cmd_snapshot!( + command_with_script_uv(&case) + .arg("scripts/script.py") + .args(["--python", ".venv"]), + @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> scripts/script.py:15:13 + | + 15 | reveal_type(User(1).value) + | ^^^^^^^^^^^^^ `int` + + info[revealed-type]: Revealed type + --> scripts/script.py:16:13 + | + 16 | reveal_type(sys.version_info[:2]) + | ^^^^^^^^^^^^^^^^^^^^ `tuple[Literal[3], Literal[12]]` + + Found 2 diagnostics + + ----- stderr ----- + " + ); + + Ok(()) + } + + #[test] + fn fixes_script_using_uv_environment() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + + from attrs import define + + @define + class User: + value: int + + User(1) # ty: ignore[unresolved-reference] + "#, + )?; + + assert_cmd_snapshot!( + command_with_script_uv(&case) + .arg("script.py") + .arg("--fix") + .args(["--warn", "unused-ignore-comment"]), + @" + success: false + exit_code: 1 + ----- stdout ----- + warning[unused-return-value]: The result of this call is unused + --> script.py:13:1 + | + 13 | User(1) + | ^^^^^^^ + info: `User` returns `User` + help: Decorate `User` with `@ignorable_return_value` if discarding its result is expected + + Found 2 diagnostics (1 fixed, 1 remaining). + + ----- stderr ----- + " + ); + + let updated = fs::read_to_string(case.root().join("script.py"))?; + assert!(!updated.contains("ty: ignore")); + + Ok(()) + } + + #[test] + fn failed_uv_script_synchronization_reports_an_error() -> anyhow::Result<()> { + assert_uv_supports_script_metadata()?; + + let case = CliTest::with_file( + "script.py", + "# /// script\n# requires-python = '>=3.8'\n# dependencies = ['missing-script-dependency==99.0.0']\n# ///\nprint(missing)\n", + )? + .with_filter( + r"(?s)`uv workspace metadata` failed with status.*?missing-script-dependency==99\.0\.0.*?\n(Found 1 diagnostic)", + "`uv workspace metadata` failed: missing-script-dependency==99.0.0 could not be resolved\n$1", + ); + assert_cmd_snapshot!( + command_with_script_uv(&case) + .arg("script.py") + .env("UV_OFFLINE", "1"), + @" + success: false + exit_code: 1 + ----- stdout ----- + error[uv-metadata]: `uv workspace metadata` failed: missing-script-dependency==99.0.0 could not be resolved + Found 1 diagnostic + + ----- stderr ----- + " + ); + + Ok(()) + } +} diff --git a/crates/ty/tests/cli/server.rs b/crates/ty/tests/cli/server.rs new file mode 100644 index 0000000000..d411e5b86e --- /dev/null +++ b/crates/ty/tests/cli/server.rs @@ -0,0 +1,281 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::Context as _; +use insta_cmd::assert_cmd_snapshot; + +use crate::CliTest; + +#[test] +fn find_uses_discovered_project_root() -> anyhow::Result<()> { + let case = CliTest::new()?.with_filter(r"/Scripts/ty\b", "/bin/ty"); + venv_with_ty(&case, "project with spaces/.venv")?; + venv_with_ty(&case, "project with spaces/src/.venv")?; + let project = case.root().join("project with spaces"); + case.write_file(project.join("ty.toml"), "")?; + + assert_cmd_snapshot!(find_command(&case).current_dir(project.join("src")), @" + success: true + exit_code: 0 + ----- stdout ----- + /project with spaces/.venv/bin/ty + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_uses_configured_environment() -> anyhow::Result<()> { + let case = CliTest::new()?.with_filter(r"/Scripts/ty\b", "/bin/ty"); + venv_with_ty(&case, "project with spaces/configured environment")?; + venv_with_ty(&case, "project with spaces/.venv")?; + let project = case.root().join("project with spaces"); + fs::create_dir(project.join("src"))?; + case.write_file( + project.join("pyproject.toml"), + "[tool.ty.environment]\npython = 'configured environment'\n", + )?; + + assert_cmd_snapshot!(find_command(&case) + .current_dir(project.join("src")) + .env("VIRTUAL_ENV", project.join(".venv")), @" + success: true + exit_code: 0 + ----- stdout ----- + /project with spaces/configured environment/bin/ty + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_uses_configured_interpreter() -> anyhow::Result<()> { + let case = CliTest::new()?.with_filter(r"/Scripts/ty\b", "/bin/ty"); + venv_with_ty(&case, "configured environment")?; + let interpreter = if cfg!(windows) { + "configured environment/Scripts/python.exe" + } else { + "configured environment/bin/python3" + }; + write_executable(&case, interpreter)?; + case.write_file( + "ty.toml", + &format!("[environment]\npython = '{interpreter}'\n"), + )?; + + assert_cmd_snapshot!(find_command(&case), @" + success: true + exit_code: 0 + ----- stdout ----- + /configured environment/bin/ty + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_does_not_fall_back_from_configured_environment() -> anyhow::Result<()> { + let case = CliTest::with_file( + "ty.toml", + "[environment]\npython = 'configured environment'\n", + )?; + venv_with_ty(&case, ".venv")?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + case.write_file("configured environment/pyvenv.cfg", "home = .\n")?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_uses_cwd_when_project_discovery_fails() -> anyhow::Result<()> { + let case = CliTest::with_file("pyproject.toml", "not valid TOML [")? + .with_filter(r"/Scripts/ty\b", "/bin/ty"); + venv_with_ty(&case, "src/.venv")?; + + assert_cmd_snapshot!(find_command(&case).current_dir(case.root().join("src")), @" + success: true + exit_code: 0 + ----- stdout ----- + /src/.venv/bin/ty + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_does_not_fall_back_to_path_or_own_executable() -> anyhow::Result<()> { + let case = CliTest::new()?; + let own_ty = venv_with_ty(&case, "own environment")?; + let case = case.with_ty_at(&own_ty)?; + let path = own_ty.parent().context("ty must have a parent")?; + + assert_cmd_snapshot!(find_command(&case).env("PATH", path), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_rejects_wrong_layout() -> anyhow::Result<()> { + let case = CliTest::with_file(".venv/pyvenv.cfg", "home = .\n")?; + let other_layout = if cfg!(windows) { + "bin/ty" + } else { + "Scripts/ty.exe" + }; + write_executable(&case, Path::new(".venv").join(other_layout))?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_rejects_directory() -> anyhow::Result<()> { + let case = CliTest::with_file(".venv/pyvenv.cfg", "home = .\n")?; + fs::create_dir_all(case.root().join(ty_path(".venv")))?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[cfg(unix)] +#[test] +fn find_follows_symlinks() -> anyhow::Result<()> { + let case = CliTest::with_file(".venv/pyvenv.cfg", "home = .\n")?; + let target = write_executable(&case, "target with spaces")?; + case.write_symlink(&target, ty_path(".venv"))?; + + assert_cmd_snapshot!(find_command(&case), @" + success: true + exit_code: 0 + ----- stdout ----- + /.venv/bin/ty + + ----- stderr ----- + "); + + Ok(()) +} + +#[cfg(unix)] +#[test] +fn find_rejects_non_executable_file() -> anyhow::Result<()> { + let case = CliTest::new()?; + let candidate = venv_with_ty(&case, ".venv")?; + fs::set_permissions(&candidate, fs::Permissions::from_mode(0o644))?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[cfg(unix)] +#[test] +fn find_rejects_broken_symlink() -> anyhow::Result<()> { + let case = CliTest::with_file(".venv/pyvenv.cfg", "home = .\n")?; + case.write_symlink("missing", ty_path(".venv"))?; + + assert_cmd_snapshot!(find_command(&case), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn find_returns_no_match_on_python_environment_error() -> anyhow::Result<()> { + let case = CliTest::new()?; + + assert_cmd_snapshot!(find_command(&case).env("VIRTUAL_ENV", "missing"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +fn find_command(case: &CliTest) -> Command { + let mut command = case.command_with_subcommand("server"); + command.arg("--find-executable"); + command +} + +fn venv_with_ty(case: &CliTest, prefix: &str) -> anyhow::Result { + case.write_file(Path::new(prefix).join("pyvenv.cfg"), "home = .\n")?; + write_executable(case, ty_path(prefix)) +} + +fn ty_path(prefix: impl AsRef) -> PathBuf { + prefix.as_ref().join(if cfg!(windows) { + "Scripts/ty.exe" + } else { + "bin/ty" + }) +} + +fn write_executable(case: &CliTest, path: impl AsRef) -> anyhow::Result { + let path = case.root().join(path); + case.write_file(&path, "")?; + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?; + Ok(path) +} diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__honors_dunder_all.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__honors_dunder_all.snap index 5fecd8b856..12d8cdcf74 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__honors_dunder_all.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__honors_dunder_all.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 al._underscore_public:d()->None diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_class_kind_flags.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_class_kind_flags.snap index bd3d18c4f5..c6126340bd 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_class_kind_flags.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_class_kind_flags.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 kind.Colors.BLUE:v=Literal[2] diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_decorators_on_methods.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_decorators_on_methods.snap index d6f6c8233a..5a4dfe6b65 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_decorators_on_methods.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_decorators_on_methods.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 deco.C.abs:d{abstractmethod}(self:deco.C)->builtins.int diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_generic_type_alias.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_generic_type_alias.snap index db6addcc5e..e9290cb99c 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_generic_type_alias.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_generic_type_alias.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 ta.Plain:t=builtins.int | builtins.str diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_instance_attributes.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_instance_attributes.snap index 76d8648ec4..463a1d4376 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_instance_attributes.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_instance_attributes.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 ia.C.__init__:d(self:ia.C,x:builtins.int)->None diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_property_accessors.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_property_accessors.snap index be8994d49a..302a80b305 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_property_accessors.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_property_accessors.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 p.C.ro:p[getter]=builtins.int diff --git a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_qualifiers_on_variables.snap b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_qualifiers_on_variables.snap index cd5437f834..ed23294307 100644 --- a/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_qualifiers_on_variables.snap +++ b/crates/ty/tests/cli/snapshots/cli__api_lockfile__renders_qualifiers_on_variables.snap @@ -10,7 +10,7 @@ success: true exit_code: 0 ----- stdout ----- #api-lock:v=1 -#tool:by=0.0.8 +#tool:by=0.0.12 #python:default #modules:1 q.C.a:v[classvar]=builtins.int diff --git a/crates/ty/tests/cli/uv_workspace.rs b/crates/ty/tests/cli/uv_workspace.rs index a0c6b0fcd1..2bac200b65 100644 --- a/crates/ty/tests/cli/uv_workspace.rs +++ b/crates/ty/tests/cli/uv_workspace.rs @@ -4,9 +4,12 @@ //! . #[cfg(feature = "test-uv")] -use std::{path::Path, process::Command}; +use std::{fmt::Write as _, fs::File, io::Write, path::Path, process::Command}; use insta_cmd::assert_cmd_snapshot; +use ty_static::EnvVars; +#[cfg(feature = "test-uv")] +use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; use crate::CliTest; @@ -49,13 +52,23 @@ requires-python = ">=3.8" } #[cfg(feature = "test-uv")] -fn command_with_uv(case: &CliTest, virtual_env: Option<&Path>) -> anyhow::Result { - let mut sync = Command::new("uv"); - sync.current_dir(case.root()) - .args(["workspace", "metadata", "--sync"]) +fn uv_command(case: &CliTest) -> Command { + let mut command = Command::new("uv"); + command + .current_dir(case.root()) .env("UV_CACHE_DIR", case.root().join("cache")) .env("UV_OFFLINE", "1") .env("UV_PYTHON_DOWNLOADS", "never"); + command +} + +#[cfg(feature = "test-uv")] +pub(super) fn uv_sync_command( + case: &CliTest, + virtual_env: Option<&Path>, +) -> anyhow::Result { + let mut sync = uv_command(case); + sync.args(["workspace", "metadata", "--sync"]); if let Some(virtual_env) = virtual_env { sync.arg("--active").env("VIRTUAL_ENV", virtual_env); } @@ -84,6 +97,278 @@ fn command_with_uv(case: &CliTest, virtual_env: Option<&Path>) -> anyhow::Result Ok(command) } +#[cfg(feature = "test-uv")] +pub(super) fn write_dependency_wheel( + case: &CliTest, + distribution: &str, + module: &str, + dependencies: &[&str], +) -> anyhow::Result<()> { + let wheel_directory = case.root().join("wheels"); + std::fs::create_dir_all(&wheel_directory)?; + + let prefix = format!("{}-0.1.0", distribution.replace('-', "_")); + let mut wheel = ZipWriter::new(File::create( + wheel_directory.join(format!("{prefix}-py3-none-any.whl")), + )?); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let mut metadata = format!("Metadata-Version: 2.1\nName: {distribution}\nVersion: 0.1.0\n"); + for dependency in dependencies { + writeln!(metadata, "Requires-Dist: {dependency}")?; + } + let mut record = Vec::new(); + + for (path, contents) in [ + (format!("{module}.py"), "value: int = 1\n"), + (format!("{prefix}.dist-info/METADATA"), metadata.as_str()), + ( + format!("{prefix}.dist-info/WHEEL"), + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ), + ] { + wheel.start_file(&path, options)?; + wheel.write_all(contents.as_bytes())?; + record.push(format!("{path},,")); + } + + let record_path = format!("{prefix}.dist-info/RECORD"); + record.push(format!("{record_path},,")); + wheel.start_file(record_path, options)?; + writeln!(wheel, "{}", record.join("\n"))?; + wheel.finish()?; + + Ok(()) +} + +#[cfg(feature = "test-uv")] +fn dependency_workspace_case() -> anyhow::Result { + let case = workspace_case()?; + case.write_files([ + ( + "pyproject.toml", + r#" + [tool.uv.workspace] + members = ["packages/*"] + + [tool.uv] + no-index = true + find-links = ["wheels"] + "#, + ), + ( + "packages/member/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["direct-dependency"] + "#, + ), + ( + "packages/member/member.py", + r#" + import direct_module + from indirect_module import value + import indirect_module + "#, + ), + ("packages/sibling/sibling.py", "import direct_module\n"), + ])?; + write_dependency_wheel(&case, "indirect-dependency", "indirect_module", &[])?; + write_dependency_wheel( + &case, + "direct-dependency", + "direct_module", + &["indirect-dependency"], + )?; + + Ok(case) +} + +/// Imports are checked against each member's direct dependencies, using uv's mapping from import +/// names to distributions. A dependency declared by one member does not apply to its siblings. +#[cfg(feature = "test-uv")] +#[test] +fn indirect_dependencies_use_uv_module_ownership() -> anyhow::Result<()> { + let case = dependency_workspace_case()?; + let mut command = uv_sync_command(&case, None)?; + command.arg("packages"); + let lockfile = std::fs::read(case.root().join("uv.lock"))?; + + assert_cmd_snapshot!(command, @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + command + .args(["--error", "missing-direct-dependency"]) + .env("TY_OUTPUT_FORMAT", "full"); + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + error[missing-direct-dependency]: Import of `indirect_module` requires a direct dependency on `indirect-dependency` + --> packages/member/member.py:3:6 + | + 3 | from indirect_module import value + | ^^^^^^^^^^^^^^^ + help: Declare `indirect-dependency` in `project.dependencies` or `project.optional-dependencies` in your `pyproject.toml` + info: See https://docs.astral.sh/uv/concepts/projects/dependencies/ + + error[missing-direct-dependency]: Import of `indirect_module` requires a direct dependency on `indirect-dependency` + --> packages/member/member.py:4:8 + | + 4 | import indirect_module + | ^^^^^^^^^^^^^^^ + help: Declare `indirect-dependency` in `project.dependencies` or `project.optional-dependencies` in your `pyproject.toml` + info: See https://docs.astral.sh/uv/concepts/projects/dependencies/ + + error[missing-direct-dependency]: Import of `direct_module` requires a direct dependency on `direct-dependency` + --> packages/sibling/sibling.py:1:8 + | + 1 | import direct_module + | ^^^^^^^^^^^^^ + help: Declare `direct-dependency` in `project.dependencies` or `project.optional-dependencies` in your `pyproject.toml` + info: See https://docs.astral.sh/uv/concepts/projects/dependencies/ + + Found 3 diagnostics + + ----- stderr ----- + "); + + assert_eq!(std::fs::read(case.root().join("uv.lock"))?, lockfile); + + Ok(()) +} + +/// Dependency checks reflect changed declarations even before the environment is synchronized +/// again. A transitive dependency can become direct without changing the installed packages. +#[cfg(feature = "test-uv")] +#[test] +fn indirect_dependencies_use_updated_declarations() -> anyhow::Result<()> { + let case = dependency_workspace_case()?; + let mut command = uv_sync_command(&case, None)?; + // Leave the lockfile stale so ty has to refresh the dependency metadata. + let output = uv_command(&case) + .args([ + "add", + "--frozen", + "--package", + "member", + "indirect-dependency", + ]) + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to add dependency: {}", + String::from_utf8_lossy(&output.stderr) + ); + command.args(["packages/member", "--error", "missing-direct-dependency"]); + + assert_cmd_snapshot!(command, @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + Ok(()) +} + +/// An explicitly selected environment cannot use uv's module ownership, even when it is nested +/// inside uv's environment. Dependency checks are skipped, but ordinary type checking continues. +#[cfg(feature = "test-uv")] +#[test] +fn overridden_python_environment_disables_dependency_checks() -> anyhow::Result<()> { + let case = dependency_workspace_case()?.with_filter( + r"selected Python environment `/(?:\.venv/)?other`", + "selected Python environment ``", + ); + case.write_file( + "packages/member/member.py", + r#" + from indirect_module import value + number: str = value + "#, + )?; + + assert_cmd_snapshot!( + uv_sync_command(&case, None)? + .args(["packages/member", "--error", "missing-direct-dependency"]), + @" + success: false + exit_code: 1 + ----- stdout ----- + packages/member/member.py:2:6: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + packages/member/member.py:3:15: error[invalid-assignment] Object of type `int` is not assignable to `str` + Found 2 diagnostics + + ----- stderr ----- + " + ); + + for other_environment in ["other", ".venv/other"] { + let output = uv_command(&case) + .args(["venv", "--no-project", other_environment]) + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to create environment: {}", + String::from_utf8_lossy(&output.stderr) + ); + let output = uv_command(&case) + .args([ + "pip", + "install", + "--python", + other_environment, + "--no-index", + "--find-links", + "wheels", + "indirect-dependency", + ]) + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to install dependency: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let mut command = uv_sync_command(&case, None)?; + command.args([ + "packages/member", + "--error", + "missing-direct-dependency", + "--python", + other_environment, + ]); + insta::allow_duplicates! { + assert_cmd_snapshot!( + command, + @" + success: false + exit_code: 1 + ----- stdout ----- + packages/member/member.py:3:15: error[invalid-assignment] Object of type `int` is not assignable to `str` + pyproject.toml: warning[uv-metadata] Failed to load uv dependency metadata: selected Python environment `` (from `--python` argument) differs from uv's environment `/.venv` + Found 2 diagnostics + + ----- stderr ----- + " + ); + } + } + + Ok(()) +} + /// The workspace root provides first-party imports without expanding analysis to unselected /// sibling members. #[cfg(feature = "test-uv")] @@ -96,7 +381,7 @@ fn uses_uv_workspace_root_without_checking_siblings() -> anyhow::Result<()> { "import shared\nvalue: int = 'selected-member'", )?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command .current_dir(case.root().join("packages/member")) .arg("."); @@ -127,7 +412,7 @@ fn explicit_file_path_disables_uv_workspace_discovery() -> anyhow::Result<()> { "import shared\nvalue: int = 'selected-script'", )?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command .current_dir(case.root().join("packages/member")) .arg("member.py"); @@ -161,7 +446,7 @@ members = ["packages/*"] invalid-assignment = "ignore" "#, )?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command.arg("packages/member"); assert_cmd_snapshot!(command, @" @@ -207,7 +492,7 @@ requires-python = ">=3.8" ), ])?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command .args(["--project", "../external-package", "../external-package"]) .env("UV_PROJECT", case.root()); @@ -250,7 +535,7 @@ requires-python = ">=3.8" "value: int = 'unselected-nested-member'", )?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command.args(["--exclude", "packages/member/nested", "packages/member"]); assert_cmd_snapshot!(command, @r#" @@ -283,7 +568,7 @@ python = "missing-configured-environment" "#, )?; let environment = case.root().join("isolated"); - let mut command = command_with_uv(&case, Some(&environment))?; + let mut command = uv_sync_command(&case, Some(&environment))?; command .current_dir(case.root().join("packages/member")) .arg(".") @@ -337,13 +622,56 @@ fn uv_workspace_discovery_is_opt_in() -> anyhow::Result<()> { Ok(()) } +/// Script-only uv integration must not invoke uv to discover the enclosing workspace. +#[test] +fn scripts_only_mode_disables_uv_workspace_discovery() -> anyhow::Result<()> { + let case = workspace_case()?; + case.write_file("shared.py", "value: int = 'unselected-workspace-root'")?; + case.write_file( + "packages/member/member.py", + "import shared\nvalue: int = 'selected-member'", + )?; + + let mut command = case.command(); + command + .current_dir(case.root().join("packages/member")) + .env(EnvVars::TY_UV, "scripts") + .env(EnvVars::UV, "missing-uv-executable"); + + assert_cmd_snapshot!(command, @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `shared` + --> member.py:1:8 + | + 1 | import shared + | ^^^^^^ + info: Searched in the following paths during module resolution: + info: 1. /packages/member (first-party code) + info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + + error[invalid-assignment]: Object of type `Literal["selected-member"]` is not assignable to `int` + --> member.py:2:14 + | + 2 | value: int = 'selected-member' + | --- ^^^^^^^^^^^^^^^^^ Incompatible value of type `Literal["selected-member"]` + | | + | Declared type + + Found 2 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + /// Failures to locate uv are visible by default instead of silently disabling integration. #[test] fn warns_when_uv_workspace_metadata_cannot_be_loaded() -> anyhow::Result<()> { - let case = workspace_case()?.with_filter( - "no path to search and provided name is not an absolute path", - "cannot find binary path", - ); + let case = workspace_case()?; case.write_file("packages/member/member.py", "value: int = 1")?; let mut command = case.command(); @@ -355,14 +683,28 @@ fn warns_when_uv_workspace_metadata_cannot_be_loaded() -> anyhow::Result<()> { .env("PATH", "") .env("TY_OUTPUT_FORMAT", "concise"); + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + pyproject.toml: warning[uv-metadata] Failed to invoke `uv workspace metadata`: failed to resolve uv executable: cannot find binary path + Found 1 diagnostic + + ----- stderr ----- + "); + + command.env_remove("TY_OUTPUT_FORMAT"); + command.arg("--exit-zero-on-warning"); assert_cmd_snapshot!(command, @" success: true exit_code: 0 ----- stdout ----- - All checks passed! + warning[uv-metadata]: Failed to invoke `uv workspace metadata`: failed to resolve uv executable: cannot find binary path + --> pyproject.toml:1:1 + + Found 1 diagnostic ----- stderr ----- - WARN Failed to invoke `uv workspace metadata`: failed to resolve uv executable: cannot find binary path "); Ok(()) @@ -379,7 +721,7 @@ fn finds_uv_on_path_without_uv_environment_variable() -> anyhow::Result<()> { "import shared\nvalue: int = 'selected-member'", )?; - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command .current_dir(case.root().join("packages/member")) .arg(".") @@ -407,7 +749,7 @@ fn reports_uv_workspace_python_version_source() -> anyhow::Result<()> { case.write_file("packages/member/member.py", "frozendict")?; for output_format in ["full", "concise"] { - let mut command = command_with_uv(&case, None)?; + let mut command = uv_sync_command(&case, None)?; command .current_dir(case.root().join("packages/member")) .arg(".") @@ -419,7 +761,7 @@ fn reports_uv_workspace_python_version_source() -> anyhow::Result<()> { assert!(!output.status.success()); assert!(!stdout.contains("specified on the command line")); if output_format == "full" { - assert!(stdout.contains("provided by uv workspace metadata")); + assert!(stdout.contains("provided by uv metadata")); } } diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs index 59238ff5d4..7db0ead8d5 100644 --- a/crates/ty/tests/file_watching.rs +++ b/crates/ty/tests/file_watching.rs @@ -11,6 +11,7 @@ use ruff_db::system::{ file_time_now, }; use ruff_python_ast::PythonVersion; +use ruff_python_trivia::textwrap::dedent; use ruff_ranged_value::{RangedValue, ValueSource}; use ty_module_resolver::{Module, ModuleName}; use ty_project::metadata::options::{EnvironmentOptions, Options, SrcOptions}; @@ -323,7 +324,7 @@ impl<'a> SetupContext<'a> { ) -> 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) + Self::write_file_impl(absolute_path, &dedent(content)) } fn write_file( @@ -398,6 +399,14 @@ where fn setup(setup_files: F) -> anyhow::Result where F: Setup, +{ + setup_with_system(setup_files, |_| {}) +} + +fn setup_with_system(setup_files: F, configure_system: C) -> anyhow::Result +where + F: Setup, + C: FnOnce(&TestSystem), { let temp_dir = tempfile::tempdir()?; @@ -427,6 +436,7 @@ where let user_config_directory_override = os_system.with_user_config_directory(None); let system = TestSystem::new(os_system.clone()); isolate_environment(&system); + configure_system(&system); let mut setup_context = SetupContext { system: &os_system, @@ -550,7 +560,7 @@ fn isolate_environment(system: &TestSystem) { } } -/// Updates the content of a file and ensures that the last modified file time is updated. +/// Dedents and updates a file's content, ensuring that its last modified time changes. fn update_file(path: impl AsRef, content: &str) -> anyhow::Result<()> { let path = path.as_ref().as_std_path(); @@ -562,7 +572,7 @@ fn update_file(path: impl AsRef, content: &str) -> anyhow::Result<() .write(true) .truncate(true) .open(path)?; - file.write_all(content.as_bytes())?; + file.write_all(dedent(content).as_bytes())?; loop { file.sync_all()?; @@ -965,6 +975,138 @@ fn changed_file() -> anyhow::Result<()> { Ok(()) } +#[test] +fn scripts_to_synchronize_after_file_and_directory_changes() -> anyhow::Result<()> { + let script = dedent( + r" + # /// script + # dependencies = [] + # /// + ", + ); + let mut case = setup(|context: &mut SetupContext| { + context.write_project_file("existing.py", &script)?; + context.write_project_file("edited.py", "")?; + context.write_file("new/script.py", &script) + })?; + let edited = case.project_path("edited.py"); + assert_eq!( + case.db().project().script_files(case.db()).iter().count(), + 1 + ); + + update_file(&edited, &script)?; + + let changes = case.take_watch_changes(event_for_file("edited.py")); + let changes = case.apply_changes(&changes); + assert_eq!( + changes.scripts_to_synchronize(case.db()), + vec![case.system_file(&edited)?] + ); + + std::fs::rename( + case.root_path().join("new").as_std_path(), + case.project_path("new").as_std_path(), + )?; + let mut changes = case.take_watch_changes(event_for_file("new")); + update_file(&edited, "")?; + changes.extend(case.stop_watch(event_for_file("edited.py"))); + + // Directory discovery also includes unchanged scripts, but `edited.py` no longer + // contains a PEP 723 script metadata block. + let changes = case.apply_changes(&changes); + assert_eq!( + changes + .scripts_to_synchronize(case.db()) + .into_iter() + .collect::>(), + HashSet::from([ + case.system_file(case.project_path("existing.py"))?, + case.system_file(case.project_path("new/script.py"))?, + ]) + ); + + Ok(()) +} + +#[test] +fn script_exclusion_tracks_file_creation_and_metadata_edits() -> anyhow::Result<()> { + let mut case = setup([( + "ty.toml", + r" + [src] + exclude-scripts = true + ", + )])?; + let path = case.project_path("script.py"); + let script = r" + # /// script + # dependencies = [] + # /// + missing + "; + assert!(case.db().check().is_empty()); + + std::fs::write(path.as_std_path(), dedent(script).as_ref())?; + let changes = case.take_watch_changes(event_for_file("script.py")); + let changes = case.apply_changes(&changes); + assert!(changes.scripts_to_synchronize(case.db()).is_empty()); + let file = case.system_file(&path)?; + assert!(case.db().check().is_empty()); + assert!(case.db().check_file(file).is_empty()); + + update_file(&path, "missing\n")?; + let changes = case.take_watch_changes(event_for_file("script.py")); + case.apply_changes(&changes); + assert_eq!(case.db().check().len(), 1); + assert_eq!(case.db().check_file(file).len(), 1); + + update_file(&path, script)?; + let changes = case.take_watch_changes(event_for_file("script.py")); + case.apply_changes(&changes); + assert!(case.db().check().is_empty()); + assert!(case.db().check_file(file).is_empty()); + + Ok(()) +} + +#[test] +fn explicitly_included_file_remains_checked_when_becoming_a_script() -> anyhow::Result<()> { + let mut case = setup([ + ( + "ty.toml", + r" + [src] + exclude-scripts = true + ", + ), + ("script.py", "missing\n"), + ])?; + let path = case.project_path("script.py"); + let file = case.system_file(&path)?; + case.db + .project() + .set_included_paths(&mut case.db, vec![path.clone()]); + assert_eq!(case.db().check().len(), 1); + assert_eq!(case.db().check_file(file).len(), 1); + + update_file( + &path, + r" + # /// script + # dependencies = [] + # /// + missing + ", + )?; + let changes = case.take_watch_changes(event_for_file("script.py")); + case.apply_changes(&changes); + assert_eq!(case.db().check().len(), 1); + assert_eq!(case.db().check_file(file).len(), 1); + + Ok(()) +} + #[test] fn deleted_file() -> anyhow::Result<()> { let foo_source = "print('Hello, world!')"; @@ -2490,3 +2632,361 @@ fn submodule_cache_invalidation_after_pyproject_created() -> anyhow::Result<()> Ok(()) } + +#[cfg(feature = "test-uv")] +mod uv_metadata { + use std::process::Command; + use std::time::Duration; + + use anyhow::Context; + use ruff_db::diagnostic::DiagnosticId; + use ruff_db::files::File; + use ruff_db::system::{OsSystem, System as _}; + use ty_project::{Db, ScriptEnvironmentAvailability, UseUv, UvSyncChanges}; + use ty_static::EnvVars; + + use super::{SetupContext, TestCase, event_for_file, setup_with_system, update_file}; + + const MANIFEST: &str = r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + "#; + + #[test] + fn project_refresh_applies_settings_despite_uv_errors() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ("pyproject.toml", MANIFEST), + ("main.py", "value: int = 'wrong'\n"), + ], + )?; + let project = case.db().project(); + let program_settings = project.program_settings(case.db()).clone(); + assert_eq!(case.db().check()[0].id().as_str(), "invalid-assignment"); + + // uv rejects this setting, but ty must still apply its rule configuration. + update_and_synchronize_project( + &mut case, + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [tool.uv] + package = "invalid" + + [tool.ty.rules] + invalid-assignment = "ignore" + "#, + )?; + let diagnostics = case.db().check(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id(), DiagnosticId::UvMetadata); + assert_eq!(project.program_settings(case.db()), &program_settings); + + // If ordinary discovery also fails, keep the last applied settings and warning. + update_and_synchronize_project(&mut case, "[project\n")?; + assert_eq!(case.db().check(), diagnostics); + + update_and_synchronize_project( + &mut case, + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [tool.ty.rules] + invalid-assignment = "ignore" + "#, + )?; + assert!(case.db().check().is_empty()); + Ok(()) + } + + #[test] + fn project_refresh_uses_the_returned_workspace_root() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "../pyproject.toml", + r#" + [tool.uv.workspace] + members = ["project"] + "#, + ), + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [tool.ty] + "#, + ), + ], + )?; + let project = case.db().project(); + assert_eq!( + project.root(case.db()), + case.root_path().join("project").as_path() + ); + + // Without its own ty configuration, the member belongs to the enclosing workspace. + update_and_synchronize_project(&mut case, MANIFEST)?; + assert_eq!(case.db().project(), project); + assert_eq!(project.root(case.db()), case.root_path()); + Ok(()) + } + + #[test] + fn unchanged_script_environment_is_reused_after_source_edits() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::Scripts, + &[( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + value = 1 + "#, + )], + )?; + + assert!(case.db().check().is_empty()); + + assert!(!update_and_synchronize_script( + &mut case, + r#" + + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + value = 2 + "#, + )?); + + assert!(case.db().check().is_empty()); + + Ok(()) + } + + #[test] + fn metadata_changes_resynchronize_the_script_environment() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::Scripts, + &[( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + from attrs import define + "#, + )], + )?; + + let diagnostics = case.db().check(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id().as_str(), "unresolved-import"); + + let synchronized = update_and_synchronize_script( + &mut case, + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#, + )?; + assert!(synchronized); + + assert!(case.db().check().is_empty()); + + Ok(()) + } + + #[test] + fn ordinary_files_becoming_scripts_initialize_their_environments() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::Scripts, + &[("script.py", "from attrs import define\n")], + )?; + + let ordinary = case.db().check(); + assert!( + ordinary + .iter() + .any(|diagnostic| diagnostic.id().as_str() == "unresolved-import") + ); + + update_and_synchronize_script( + &mut case, + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#, + )?; + let diagnostics = case.db().check(); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + + Ok(()) + } + + #[test] + fn corrected_script_dependencies_replace_initialization_errors() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::Scripts, + &[( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["not a valid requirement ???"] + # /// + value = 1 + "#, + )], + )?; + + let initial = case.db().check(); + assert!( + initial + .iter() + .any(|diagnostic| diagnostic.id() == DiagnosticId::UvMetadata) + ); + + let synchronized = update_and_synchronize_script( + &mut case, + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#, + )?; + assert!(synchronized); + + let corrected = case.db().check(); + assert!( + corrected.is_empty(), + "unexpected diagnostics: {corrected:?}" + ); + + Ok(()) + } + + fn setup_uv(use_uv: UseUv, files: &[(&str, &str)]) -> anyhow::Result { + let uv = OsSystem::default().which("uv")?; + let mut case = setup_with_system( + |context: &mut SetupContext| { + for (path, content) in files { + context.write_project_file(path, content)?; + } + if use_uv == UseUv::On { + let output = Command::new(uv.as_std_path()) + .current_dir(context.project_path()) + .args(["sync", "--offline"]) + .output()?; + anyhow::ensure!( + output.status.success(), + "uv sync failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) + }, + |system| { + system.set_env_var( + EnvVars::TY_UV, + match use_uv { + UseUv::Off => "0", + UseUv::Scripts => "scripts", + UseUv::On => "1", + }, + ); + system.set_env_var(EnvVars::UV, uv.as_str()); + }, + )?; + let scripts: Vec<_> = case.db().project().script_files(case.db()).iter().collect(); + synchronize_scripts(&mut case, &scripts)?; + Ok(case) + } + + fn update_and_synchronize_project(case: &mut TestCase, source: &str) -> anyhow::Result<()> { + update_file(case.project_path("pyproject.toml"), source)?; + let changes = case.take_watch_changes(event_for_file("pyproject.toml")); + let changes = case.apply_changes(&changes); + + if let Some(project_path) = changes.project_sync_path() { + case.db() + .uv_environments() + .request_project_sync(case.db(), project_path, &|_, _| None); + } + + wait_for_synchronizations(case)?; + Ok(()) + } + + fn update_and_synchronize_script(case: &mut TestCase, source: &str) -> anyhow::Result { + update_file(case.project_path("script.py"), source)?; + let changes = case.take_watch_changes(event_for_file("script.py")); + let changes = case.apply_changes(&changes); + let scripts = changes.scripts_to_synchronize(case.db()); + let changes = synchronize_scripts(case, &scripts)?; + Ok(!changes.scripts.is_empty()) + } + + fn synchronize_scripts(case: &mut TestCase, scripts: &[File]) -> anyhow::Result { + let environments = case.db().uv_environments().clone(); + for &file in scripts { + environments.request_sync( + &mut case.db, + file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + } + + wait_for_synchronizations(case) + } + + fn wait_for_synchronizations(case: &mut TestCase) -> anyhow::Result { + let environments = case.db().uv_environments().clone(); + let wakeups = environments.sync_wakeups(); + let mut changes = UvSyncChanges::default(); + while environments.has_pending_synchronizations() { + wakeups + .recv_timeout(Duration::from_secs(30)) + .context("uv synchronization did not finish")?; + let completed = environments.poll_sync(&mut case.db); + changes.scripts.extend(completed.scripts); + changes.project = completed.project.or(changes.project); + } + + if changes.project.is_some() + && let Some(watcher) = &mut case.watcher + { + watcher.update(&case.db); + assert!(!watcher.has_errored_paths()); + } + + Ok(changes) + } +} diff --git a/crates/ty_combine/Cargo.toml b/crates/ty_combine/Cargo.toml index 99241b4673..910891201e 100644 --- a/crates/ty_combine/Cargo.toml +++ b/crates/ty_combine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_combine" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ty_combine/README.md b/crates/ty_combine/README.md index de9f6647aa..9338274dd6 100644 --- a/crates/ty_combine/README.md +++ b/crates/ty_combine/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_combine). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_combine). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_completion_eval/truth/auto-import-skips-third-party-tests/uv.lock b/crates/ty_completion_eval/truth/auto-import-skips-third-party-tests/uv.lock index a4937d10d3..abee84453b 100644 --- a/crates/ty_completion_eval/truth/auto-import-skips-third-party-tests/uv.lock +++ b/crates/ty_completion_eval/truth/auto-import-skips-third-party-tests/uv.lock @@ -1,8 +1,151 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] [[package]] name = "test" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "pandas" }, +] + +[package.metadata] +requires-dist = [{ name = "pandas", specifier = ">=2.3.3" }] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] diff --git a/crates/ty_completion_eval/truth/modules-over-other-symbols/uv.lock b/crates/ty_completion_eval/truth/modules-over-other-symbols/uv.lock index 68710137ab..a4937d10d3 100644 --- a/crates/ty_completion_eval/truth/modules-over-other-symbols/uv.lock +++ b/crates/ty_completion_eval/truth/modules-over-other-symbols/uv.lock @@ -2,77 +2,7 @@ version = 1 revision = 3 requires-python = ">=3.13" -[[package]] -name = "regex" -version = "2025.11.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, - { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, - { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, - { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, - { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, - { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, - { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, - { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, - { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, - { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, - { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, - { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, - { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, - { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, - { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, - { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, - { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, - { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, - { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, -] - [[package]] name = "test" version = "0.1.0" source = { virtual = "." } -dependencies = [ - { name = "regex" }, -] - -[package.metadata] -requires-dist = [{ name = "regex", specifier = ">=2025.11.3" }] diff --git a/crates/ty_ide/Cargo.toml b/crates/ty_ide/Cargo.toml index 044d19df7b..619e585be4 100644 --- a/crates/ty_ide/Cargo.toml +++ b/crates/ty_ide/Cargo.toml @@ -46,6 +46,7 @@ salsa = { workspace = true, features = ["compact_str"] } smallvec = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } +toml_parser = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/crates/ty_ide/src/add_dependency.rs b/crates/ty_ide/src/add_dependency.rs index 95da8b3b8e..f3a88ccb4d 100644 --- a/crates/ty_ide/src/add_dependency.rs +++ b/crates/ty_ide/src/add_dependency.rs @@ -109,7 +109,7 @@ fn uv_manages(db: &dyn Db, root: &SystemPath) -> bool { root.ancestors() .any(|directory| system.is_file(&directory.join("uv.lock"))) - && ty_project::metadata::uv::executable(system).is_ok() + && ty_project::uv::executable(system).is_ok() } /// A dependency to declare by running `uv add`. diff --git a/crates/ty_ide/src/alignment.rs b/crates/ty_ide/src/alignment.rs index 9e58223e89..9e54dcb053 100644 --- a/crates/ty_ide/src/alignment.rs +++ b/crates/ty_ide/src/alignment.rs @@ -55,7 +55,7 @@ pub struct AlignmentMember { impl AlignmentMember { /// the spaces the author left between the target and the `=`, which is the room a hint has to /// spend before the line has to grow - pub(crate) fn gap(self) -> TextSize { + fn gap(self) -> TextSize { self.gap_end - self.gap_start } } diff --git a/crates/ty_ide/src/call_hierarchy/incoming_calls.rs b/crates/ty_ide/src/call_hierarchy/incoming_calls.rs index e674db8b42..5b839f995a 100644 --- a/crates/ty_ide/src/call_hierarchy/incoming_calls.rs +++ b/crates/ty_ide/src/call_hierarchy/incoming_calls.rs @@ -78,11 +78,7 @@ pub fn incoming_calls(db: &dyn Db, file: ProgramFile<'_>, offset: TextSize) -> V if is_externally_visible { let program = model.program(); let files = db.project().files(db); - let files: Vec<_> = files - .iter() - .copied() - .filter(|other| *other != source_file) - .collect(); + let files: Vec<_> = files.iter().filter(|other| *other != source_file).collect(); let minimum_job_len = minimum_parallel_job_len(files.len(), MAX_MIN_FILES_PER_PARALLEL_JOB); // The byte-level text prefilter still pays off as a coarse gate: // files that don't contain the target name (or an import of it) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 94ce59b147..5e83ecdcb6 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -5307,6 +5307,7 @@ fn completion_kind_from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option __name__ :: str __ne__() :: def __ne__(self, value: object, /) -> bool __new__() :: def __new__[Self](cls) -> Self - __or__() :: bound method .__or__[Self](value: Any, /) -> UnionType | Self + __or__() :: bound method .__or__(value: Any, /) -> UnionType | __prepare__() :: bound method .__prepare__(name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object] __qualname__ :: str __reduce__() :: def __reduce__(self) -> str | tuple[Any, ...] __reduce_ex__() :: def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...] __repr__() :: def __repr__(self) -> str - __ror__() :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self + __ror__() :: bound method .__ror__(value: Any, /) -> UnionType | __setattr__() :: def __setattr__(self, name: str, value: Any, /) __sizeof__() :: def __sizeof__(self) -> int __str__() :: def __str__(self) -> str __subclasscheck__() :: bound method .__subclasscheck__(subclass: type, /) -> bool - __subclasses__() :: bound method .__subclasses__[Self]() -> list[Self] + __subclasses__() :: bound method .__subclasses__[Element]() -> list[type[Element]] __subclasshook__() :: bound method .__subclasshook__(subclass: type, /) -> bool __text_signature__ :: str | None __type_params__ :: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] @@ -7145,7 +7146,7 @@ Meta. __sizeof__() :: def __sizeof__(self) -> int __str__() :: def __str__(self) -> str __subclasscheck__() :: def __subclasscheck__(self, subclass: type, /) -> bool - __subclasses__() :: def __subclasses__[Self](self: Self) -> list[Self] + __subclasses__() :: def __subclasses__[Element](self: type[Element]) -> list[type[Element]] __subclasshook__() :: bound method .__subclasshook__(subclass: type, /) -> bool __text_signature__ :: str | None __type_params__ :: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] @@ -7292,18 +7293,18 @@ Quux. __name__ :: str __ne__() :: def __ne__(self, value: object, /) -> bool __new__() :: def __new__[Self](cls) -> Self - __or__() :: bound method .__or__[Self](value: Any, /) -> UnionType | Self + __or__() :: bound method .__or__(value: Any, /) -> UnionType | __prepare__() :: bound method .__prepare__(name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object] __qualname__ :: str __reduce__() :: def __reduce__(self) -> str | tuple[Any, ...] __reduce_ex__() :: def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...] __repr__() :: def __repr__(self) -> str - __ror__() :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self + __ror__() :: bound method .__ror__(value: Any, /) -> UnionType | __setattr__() :: def __setattr__(self, name: str, value: Any, /) __sizeof__() :: def __sizeof__(self) -> int __str__() :: def __str__(self) -> str __subclasscheck__() :: bound method .__subclasscheck__(subclass: type, /) -> bool - __subclasses__() :: bound method .__subclasses__[Self]() -> list[Self] + __subclasses__() :: bound method .__subclasses__[Element]() -> list[type[Element]] __subclasshook__() :: bound method .__subclasshook__(subclass: type, /) -> bool __text_signature__ :: str | None __type_params__ :: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] @@ -7369,14 +7370,14 @@ Answer. __flags__ :: int __format__() :: def __format__(self, format_spec: str) -> str __getattribute__() :: def __getattribute__(self, name: str, /) -> Any - __getitem__() :: bound method .__getitem__[EnumMemberT](name: str) -> EnumMemberT + __getitem__() :: bound method .__getitem__(name: str) -> Answer __getstate__() :: def __getstate__(self) -> object __hash__() :: def __hash__(self) -> int __init__() :: def __init__(self) __init_subclass__() :: bound method .__init_subclass__() __instancecheck__() :: bound method .__instancecheck__(instance: Any, /) -> bool __itemsize__ :: int - __iter__() :: bound method .__iter__[EnumMemberT]() -> Iterator[EnumMemberT] + __iter__() :: bound method .__iter__() -> Iterator[Answer] __len__() :: bound method .__len__() -> int __members__ :: MappingProxyType[str, Answer] __module__ :: str @@ -7384,19 +7385,19 @@ Answer. __name__ :: str __ne__() :: def __ne__(self, value: object, /) -> bool __new__() :: def __new__[Self](cls, value: object) -> Self - __or__() :: bound method .__or__[Self](value: Any, /) -> UnionType | Self + __or__() :: bound method .__or__(value: Any, /) -> UnionType | __order__ :: str __prepare__() :: bound method .__prepare__(cls: str, bases: tuple[type, ...], **kwds: Any) -> _EnumDict __qualname__ :: str __reduce__() :: def __reduce__(self) -> str | tuple[Any, ...] __repr__() :: def __repr__(self) -> str - __reversed__() :: bound method .__reversed__[EnumMemberT]() -> Iterator[EnumMemberT] - __ror__() :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self + __reversed__() :: bound method .__reversed__() -> Iterator[Answer] + __ror__() :: bound method .__ror__(value: Any, /) -> UnionType | __setattr__() :: def __setattr__(self, name: str, value: Any, /) __sizeof__() :: def __sizeof__(self) -> int __str__() :: def __str__(self) -> str __subclasscheck__() :: bound method .__subclasscheck__(subclass: type, /) -> bool - __subclasses__() :: bound method .__subclasses__[Self]() -> list[Self] + __subclasses__() :: bound method .__subclasses__[Element]() -> list[type[Element]] __subclasshook__() :: bound method .__subclasshook__(subclass: type, /) -> bool __text_signature__ :: str | None __type_params__ :: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] @@ -8818,6 +8819,49 @@ from sys import ( builder.build().contains("getsizeof"); } + #[test] + fn from_import_with_bare_annotation() { + let builder = CursorTest::builder() + .source("module.py", "declared: int\nvalue = 1") + .source("main.py", "from module import val") + .completion_test_builder(); + + builder.build().contains("value"); + } + + #[test] + fn from_import_with_separate_annotation_and_assignment() { + let builder = CursorTest::builder() + .source("module.py", "value: int\nvalue = 1") + .source("main.py", "from module import val") + .completion_test_builder(); + + let test = builder.build(); + assert!( + test.completions() + .iter() + .any(|completion| completion.name == "value" && !completion.is_type_check_only) + ); + } + + #[test] + fn from_import_with_type_checking_annotation() { + let builder = CursorTest::builder() + .source( + "module.py", + "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n value: int", + ) + .source("main.py", "from module import val") + .completion_test_builder(); + + let test = builder.build(); + assert!( + test.completions() + .iter() + .any(|completion| completion.name == "value" && completion.is_type_check_only) + ); + } + #[test] fn from_import_unknown_in_module() { // `$` is a token the lexer cannot recognise. (`?` is no longer suitable diff --git a/crates/ty_ide/src/django_template.rs b/crates/ty_ide/src/django_template.rs index d502e94f21..28db0356e0 100644 --- a/crates/ty_ide/src/django_template.rs +++ b/crates/ty_ide/src/django_template.rs @@ -43,7 +43,8 @@ pub(crate) use python::{ }; pub use rename::{PreparedTemplateRename, TemplateRename, TemplateRenameOutcome}; pub use signature_help::TemplateSignature; -pub use symbols::{DjangoSymbol, TemplateSymbol}; +pub(crate) use symbols::DjangoSymbol; +pub use symbols::TemplateSymbol; use ruff_db::diagnostic::Diagnostic; use ruff_db::files::{File, system_path_to_file}; @@ -224,7 +225,7 @@ pub fn django_template_diagnostics(db: &dyn Db, file: File) -> Vec { /// the file's suppression comments are deliberately *not* applied: these are /// folded into the type checker's own diagnostics, which is where a `ty: ignore` /// is honoured and counted used — see [`ty_python_semantic::check_file_with`]. -pub fn django_python_diagnostics( +pub(crate) fn django_python_diagnostics( db: &dyn Db, env: &ProgramEnvironment<'_>, file: File, @@ -646,7 +647,7 @@ pub(crate) mod tests { python_platform: PythonPlatform::default(), search_paths, }; - Program::from_settings(&db, settings.clone()); + Program::from_settings(&db, &settings); // a project-level query (`has_django`) resolves against the project's own // settings rather than a file's, so they have to carry the search paths too ty_project::Db::project(&db).update_program(&mut db, settings); diff --git a/crates/ty_ide/src/django_template/diagnostics.rs b/crates/ty_ide/src/django_template/diagnostics.rs index 3de37e8e29..97a5f60370 100644 --- a/crates/ty_ide/src/django_template/diagnostics.rs +++ b/crates/ty_ide/src/django_template/diagnostics.rs @@ -52,7 +52,7 @@ const STATIC_TAG: &str = "static"; /// /// the indexes are read once here rather than once per check, since every one of /// them is a project-wide query. -pub(crate) struct Checker<'a> { +struct Checker<'a> { db: &'a dyn Db, file: File, index: &'a TemplateIndex, diff --git a/crates/ty_ide/src/django_template/lexer.rs b/crates/ty_ide/src/django_template/lexer.rs index 1d4fe0c6b1..11b1166214 100644 --- a/crates/ty_ide/src/django_template/lexer.rs +++ b/crates/ty_ide/src/django_template/lexer.rs @@ -88,7 +88,7 @@ pub(crate) struct Construct { impl Construct { /// the tag name, or `""` for a construct that has none #[cfg(test)] - pub(crate) fn name<'src>(&self, source: &'src str) -> &'src str { + fn name<'src>(&self, source: &'src str) -> &'src str { self.name.map_or("", |range| &source[range]) } } diff --git a/crates/ty_ide/src/django_template/project.rs b/crates/ty_ide/src/django_template/project.rs index 1d862409a8..2c471f40cc 100644 --- a/crates/ty_ide/src/django_template/project.rs +++ b/crates/ty_ide/src/django_template/project.rs @@ -3695,8 +3695,8 @@ pub(crate) enum DjangoClassKind { /// class name's own range is unique within the file that declares it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] pub(crate) struct ClassRef { - pub(crate) file: File, - pub(crate) range: TextRange, + file: File, + range: TextRange, } /// a class django gives a role to @@ -3793,7 +3793,7 @@ fn project_uses_django(db: &dyn Db, project: Project) -> bool { project .files(db) .iter() - .any(|file| file_names_django(db, *file)) + .any(|file| file_names_django(db, file)) } #[salsa::tracked(returns(copy))] @@ -3884,13 +3884,13 @@ fn is_djangos_own(db: &dyn Db, file: File) -> bool { #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub(crate) struct AdminRegistrations { /// the admin classes registered - pub(crate) registered: Box<[ClassRef]>, + registered: Box<[ClassRef]>, /// whether every registration was read in full /// /// a registration whose model or whose admin class could not be worked out /// leaves a class registered that nothing here will ever match, which is /// exactly the state in which a "nothing registers this" would be wrong. - pub(crate) complete: bool, + complete: bool, } impl Default for AdminRegistrations { diff --git a/crates/ty_ide/src/django_template/resolve.rs b/crates/ty_ide/src/django_template/resolve.rs index eaee1a03dc..52fee78332 100644 --- a/crates/ty_ide/src/django_template/resolve.rs +++ b/crates/ty_ide/src/django_template/resolve.rs @@ -187,7 +187,7 @@ fn root_type<'db>( } /// the names making up the dotted path covering `range` of the template -pub(crate) fn path_segments<'src>( +fn path_segments<'src>( index: &TemplateIndex, source: &'src str, range: TextRange, @@ -213,7 +213,7 @@ pub(crate) fn path_segments<'src>( /// method. /// /// [resolved]: https://docs.djangoproject.com/en/stable/ref/templates/language/#variables -pub(crate) fn member_type<'db>( +fn member_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>, diff --git a/crates/ty_ide/src/django_template/symbols.rs b/crates/ty_ide/src/django_template/symbols.rs index 622be47058..11df118d70 100644 --- a/crates/ty_ide/src/django_template/symbols.rs +++ b/crates/ty_ide/src/django_template/symbols.rs @@ -100,15 +100,15 @@ pub(crate) fn document_symbols(index: &TemplateIndex) -> Vec { /// one django thing a workspace symbol search can find #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DjangoSymbol { - pub symbol: SymbolInfo<'static>, - pub file: File, +pub(crate) struct DjangoSymbol { + pub(crate) symbol: SymbolInfo<'static>, + pub(crate) file: File, /// what django calls this kind of thing /// /// a model and an admin class are both classes python has already offered /// under the same name, and this is the whole of what tells them apart in a /// list of results. - pub container: &'static str, + pub(crate) container: &'static str, } impl DjangoSymbol { diff --git a/crates/ty_ide/src/django_template/uses.rs b/crates/ty_ide/src/django_template/uses.rs index 9dc9cc7910..1c5b2a122c 100644 --- a/crates/ty_ide/src/django_template/uses.rs +++ b/crates/ty_ide/src/django_template/uses.rs @@ -40,7 +40,7 @@ use super::{MAX_INHERITANCE_DEPTH, template_index}; pub(super) const URL_TAG: &str = "url"; /// the tags that name another template -pub(super) const TEMPLATE_TAGS: &[&str] = &["extends", "include"]; +const TEMPLATE_TAGS: &[&str] = &["extends", "include"]; /// the tag that closes a `{% block %}` const END_BLOCK_TAG: &str = "endblock"; diff --git a/crates/ty_ide/src/docstring/markdown/general.rs b/crates/ty_ide/src/docstring/markdown/general.rs index f7eaba5e11..38734b76b7 100644 --- a/crates/ty_ide/src/docstring/markdown/general.rs +++ b/crates/ty_ide/src/docstring/markdown/general.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::debug_assert_matches; use ruff_text_size::TextSize; @@ -315,7 +316,7 @@ impl<'source, 'output> Renderer<'source, 'output> { } fn finish_rest_literal(&mut self) { - debug_assert!(matches!(self.block_state, BlockState::RestLiteral { .. })); + debug_assert_matches!(self.block_state, BlockState::RestLiteral { .. }); self.flush_pending_line(); self.block_state = BlockState::Prose; self.output.push_str(FENCE); @@ -344,7 +345,7 @@ impl<'source, 'output> Renderer<'source, 'output> { } fn finish_doctest(&mut self) { - debug_assert!(matches!(self.block_state, BlockState::Doctest)); + debug_assert_matches!(self.block_state, BlockState::Doctest); self.flush_pending_line(); self.block_state = BlockState::Prose; self.output.push_str(FENCE); @@ -357,7 +358,7 @@ impl<'source, 'output> Renderer<'source, 'output> { } fn finish_markdown_fence(&mut self, line: &str) { - debug_assert!(matches!(self.block_state, BlockState::MarkdownFence(_))); + debug_assert_matches!(self.block_state, BlockState::MarkdownFence(_)); self.flush_pending_line(); self.block_state = BlockState::Prose; self.output.push_str(line); @@ -461,7 +462,7 @@ impl LinePrefix { } } -#[derive(Default)] +#[derive(Debug, Default)] enum BlockState<'a> { #[default] Prose, diff --git a/crates/ty_ide/src/find_references.rs b/crates/ty_ide/src/find_references.rs index f0b999175f..ef3ba082de 100644 --- a/crates/ty_ide/src/find_references.rs +++ b/crates/ty_ide/src/find_references.rs @@ -1,9 +1,13 @@ use crate::goto::find_goto_target; -use crate::references::{ReferencesMode, references}; +use crate::references::{FixtureReferenceTarget, ReferencesMode, references}; use crate::{Db, ReferenceTarget}; -use ruff_text_size::TextSize; +use ruff_db::parsed::ParsedModuleRef; +use ruff_python_ast::AnyNodeRef; +use ruff_python_ast::find_node::covering_node; +use ruff_python_ast::token::TokenKind; +use ruff_text_size::{Ranged, TextSize}; use ty_python_core::ProgramFile; -use ty_python_semantic::SemanticModel; +use ty_python_semantic::{FixtureNameSource, SemanticModel, fixture_exposures_for_definition}; /// Find all references to a symbol at the given position. /// Search for references across all files in the project. @@ -17,24 +21,108 @@ pub fn find_references( let module = parsed.load(db); let model = SemanticModel::new(db, file); - // Get the definitions for the symbol at the cursor position - let goto_target = find_goto_target(&model, &module, offset)?; - let mode = if include_declaration { ReferencesMode::References } else { ReferencesMode::ReferencesSkipDeclaration }; + // A decorator's `name="..."` literal names a fixture without defining a Python symbol. + // Start a fixture-only search before the ordinary symbol lookup below. + if let Some(target) = explicit_fixture_name_at_offset(&model, &module, offset) { + return target.references(db, file, mode); + } + + // Get the definitions for the symbol at the cursor position + let goto_target = find_goto_target(&model, &module, offset)?; references(db, file, &goto_target, mode) } +/// Returns a target for the explicit fixture name at `offset`. +/// Quotes, prefixes, and token boundaries select the name's contents. +/// +/// This makes it so that an offset within `"public_name"` in the decorator +/// below will target the test parameter: +/// +/// ```python +/// import pytest +/// +/// @pytest.fixture(name="public_name") +/// def implementation(): ... +/// +/// def test_use(public_name): ... +/// ``` +fn explicit_fixture_name_at_offset<'db>( + model: &SemanticModel<'db>, + module: &ParsedModuleRef, + offset: TextSize, +) -> Option> { + let token = module + .tokens() + .at_offset(offset) + .find(|token| token.kind() == TokenKind::String)?; + let covering = covering_node(module.syntax().into(), token.range()); + let AnyNodeRef::StringLiteral(literal) = covering.node() else { + return None; + }; + + // Match a string literal in a function decorator's `name` argument: + // + // @pytest.fixture(name="resource") + // def implementation(): ... + // + // The semantic lookup below verifies that the decorator declares a fixture. + let mut ancestors = covering.ancestors(); + let mut in_name_argument = false; + let function = loop { + match ancestors.next()? { + // The literal must be the value of `name`, not another keyword. + AnyNodeRef::Keyword(keyword) + if keyword.arg.as_deref() == Some("name") + && keyword.value.is_string_literal_expr() => + { + in_name_argument = true; + } + // The decorator must belong directly to a function, not a class. + AnyNodeRef::Decorator(_) if in_name_argument => { + let AnyNodeRef::StmtFunctionDef(function) = ancestors.next()? else { + return None; + }; + break function; + } + // Skip over intermediate nodes that connect the literal, keyword, and decorator. + AnyNodeRef::StringLiteral(_) + | AnyNodeRef::ExprStringLiteral(_) + | AnyNodeRef::Arguments(_) + | AnyNodeRef::ExprCall(_) => {} + // Reject unrelated syntax, such as return annotations, and failed guards. + _ => return None, + } + }; + let definition = ty_python_core::semantic_index(model.db(), model.program_file()) + .expect_single_definition(function); + let exposures = fixture_exposures_for_definition(model.db(), definition); + + let exposure = exposures.iter().find(|exposure| { + matches!( + exposure.name_source(model.db()), + FixtureNameSource::Explicit { + declaration: Some(declaration), .. + } if declaration.file() == model.file() + && declaration.range() == literal.content_range() + ) + })?; + + Some(FixtureReferenceTarget::from_exposure(model.db(), exposure)) +} + #[cfg(test)] mod tests { use super::*; - use crate::tests::{CursorTest, IntoDiagnostic, cursor_test}; + use crate::tests::{CursorTest, IntoDiagnostic, SitePackagesCursorTestBuilder, cursor_test}; use insta::assert_snapshot; use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span}; + use ruff_db::source::source_text; impl CursorTest { fn references(&self) -> String { @@ -2239,4 +2327,824 @@ class C: | - "); } + + #[test] + fn references_pytest_fixture_relationships_from_default_name() { + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture + def resource(): ... + + copy = resource + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + let definition_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture + def resource(): ... + + copy = resource + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + + let fixture_references = request_test.references(); + assert_eq!(fixture_references, definition_test.references()); + assert_snapshot!(fixture_references, @" + info[references]: Found 6 references + --> src/test_example.py:5:5 + | + 5 | def resource(): ... + | -------- + 6 | + 7 | copy = resource + | -------- + 8 | + 9 | @pytest.fixture + 10 | def dependent(resource): + | -------- + 11 | print(resource) + | -------- + 12 | + 13 | def test_use(resource): + | -------- + 14 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_relationships_from_explicit_name() { + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + + copy = implementation + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + let decorator_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + + copy = implementation + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + + let fixture_references = request_test.references(); + assert_eq!(fixture_references, decorator_test.references()); + assert_snapshot!(fixture_references, @r#" + info[references]: Found 5 references + --> src/test_example.py:4:23 + | + 4 | @pytest.fixture(name="resource") + | -------- + | + ::: src/test_example.py:10:15 + | + 10 | def dependent(resource): + | -------- + 11 | print(resource) + | -------- + 12 | + 13 | def test_use(resource): + | -------- + 14 | print(resource) + | -------- + "#); + } + + #[test] + fn references_explicit_fixture_name_from_string_token() { + let mut test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name=r'''resource''') + def implementation(): ... + + def test_use(resource): + print(resource) + "#, + ); + let source = source_text(&test.db, test.cursor.file); + let end = source + .find(')') + .expect("the fixture decorator should have a closing parenthesis"); + let expected = test.references(); + assert_snapshot!(expected, @" + info[references]: Found 3 references + --> src/test_example.py:4:26 + | + 4 | @pytest.fixture(name=r'''resource''') + | -------- + 5 | def implementation(): ... + 6 | + 7 | def test_use(resource): + | -------- + 8 | print(resource) + | -------- + "); + + // Every position in the prefix, quotes, and contents selects the same name. + for offset in usize::from(test.cursor.offset)..=end { + test.cursor.offset = TextSize::try_from(offset).expect("the test offset should fit"); + assert_eq!(test.references(), expected, "cursor offset {offset}"); + } + } + + #[test] + fn explicit_fixture_name_matching_python_name_keeps_reference_families_separate() { + let definition_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def resource(): ... + + copy = resource + + def test_use(resource): + print(resource) + "#, + ); + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def resource(): ... + + copy = resource + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(definition_test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:5:5 + | + 5 | def resource(): ... + | -------- + 6 | + 7 | copy = resource + | -------- + "); + assert_snapshot!(request_test.references(), @r#" + info[references]: Found 3 references + --> src/test_example.py:4:23 + | + 4 | @pytest.fixture(name="resource") + | -------- + | + ::: src/test_example.py:9:14 + | + 9 | def test_use(resource): + | -------- + 10 | print(resource) + | -------- + "#); + } + + #[test] + fn references_pytest_fixture_respect_conftest_shadowing() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "conftest.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "tests/test_outer.py", + r#" + def test_outer(resource): + print(resource) + "#, + ) + .source( + "tests/nested/conftest.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "tests/nested/test_inner.py", + r#" + def test_inner(resource): + print(resource) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/conftest.py:5:5 + | + 5 | def resource(): ... + | -------- + | + ::: src/tests/test_outer.py:2:16 + | + 2 | def test_outer(resource): + | -------- + 3 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_imported_fixture_exposure() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "test_example.py", + r#" + from fixtures import resource as alias + + def test_use(alias): + print(alias) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:2:34 + | + 2 | from fixtures import resource as alias + | ----- + 3 | + 4 | def test_use(alias): + | ----- + 5 | print(alias) + | ----- + "); + assert_snapshot!(test.references_without_declaration(), @" + info[references]: Found 2 references + --> src/test_example.py:4:14 + | + 4 | def test_use(alias): + | ----- + 5 | print(alias) + | ----- + "); + } + + #[test] + fn references_pytest_fixture_through_reexport() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "reexports.py", + r#" + from fixtures import resource as middle + "#, + ) + .source( + "test_example.py", + r#" + from reexports import middle + + def test_use(middle): + print(middle) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> src/reexports.py:2:34 + | + 2 | from fixtures import resource as middle + | ------ + | + ::: src/test_example.py:2:23 + | + 2 | from reexports import middle + | ------ + 3 | + 4 | def test_use(middle): + | ------ + 5 | print(middle) + | ------ + "); + } + + #[test] + fn references_function_local_fixture_import_as_ordinary_alias() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "test_example.py", + r#" + def helper(): + from fixtures import resource as local + print(local) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:3:38 + | + 3 | from fixtures import resource as local + | ----- + 4 | print(local) + | ----- + "); + } + + #[test] + fn references_pytest_fixture_does_not_expand_through_ambiguous_request() { + let test = ambiguous_pytest_fixture_cursor_test( + r#" + import pytest + + @pytest.fixture + def first(): ... + "#, + r#" + flag: bool + if flag: + from first import first as resource + else: + from second import second as resource + + def test_ambiguous(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/first.py:5:5 + | + 5 | def first(): ... + | ----- + | + ::: src/test_ambiguous.py:4:23 + | + 4 | from first import first as resource + | ----- + "); + } + + #[test] + fn references_ambiguous_pytest_fixture_request_includes_all_targets() { + let test = ambiguous_pytest_fixture_cursor_test( + r#" + import pytest + + @pytest.fixture + def first(): ... + "#, + r#" + flag: bool + if flag: + from first import first as resource + else: + from second import second as resource + + def test_ambiguous(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> src/test_ambiguous.py:4:32 + | + 4 | from first import first as resource + | -------- + 5 | else: + 6 | from second import second as resource + | -------- + 7 | + 8 | def test_ambiguous(resource): + | -------- + 9 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_preserves_non_fixture_ambiguous_target() { + let test = pytest_cursor_test( + r#" + import pytest + + flag: bool + if flag: + @pytest.fixture + def resource(): ... + else: + def resource(): ... + + resource() + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:7:9 + | + 7 | def resource(): ... + | -------- + 8 | else: + 9 | def resource(): ... + | -------- + 10 | + 11 | resource() + | -------- + "); + } + + #[test] + fn references_pytest_installed_core_fixture() { + let test = pytest_cursor_test( + r#" + def test_use(tmp_path): + print(tmp_path) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> site-packages/_pytest/tmpdir.py:5:5 + | + 5 | def tmp_path(): ... + | -------- + | + ::: src/test_example.py:2:14 + | + 2 | def test_use(tmp_path): + | -------- + 3 | print(tmp_path) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_declaration_through_external_stub() { + let definition_test = external_stub_fixture_definition_cursor_test(); + let import_test = external_stub_fixture_cursor_test( + r#" + from third_party_plugin import external_resource as resource + + def test_use(resource): + print(resource) + "#, + ); + let parameter_test = external_stub_fixture_cursor_test( + r#" + from third_party_plugin import external_resource as resource + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(definition_test.references(), @" + info[references]: Found 1 references + --> src/third_party_plugin.py:5:5 + | + 5 | def external_resource(): ... + | ----------------- + "); + assert_snapshot!(import_test.references(), @" + info[references]: Found 2 references + --> site-packages/third_party_plugin.pyi:2:5 + | + 2 | def external_resource() -> object: ... + | ----------------- + | + ::: src/test_example.py:2:32 + | + 2 | from third_party_plugin import external_resource as resource + | ----------------- + "); + assert_snapshot!(parameter_test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:2:53 + | + 2 | from third_party_plugin import external_resource as resource + | -------- + 3 | + 4 | def test_use(resource): + | -------- + 5 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_through_annotated_stub() { + let test = external_stub_fixture_cursor_test_with_stub( + r#" + from third_party_plugin import external_resource + + def test_use(external_resource): + print(external_resource) + "#, + r#" + external_resource: object + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> site-packages/third_party_plugin.pyi:2:1 + | + 2 | external_resource: object + | ----------------- + | + ::: src/test_example.py:2:32 + | + 2 | from third_party_plugin import external_resource + | ----------------- + 3 | + 4 | def test_use(external_resource): + | ----------------- + 5 | print(external_resource) + | ----------------- + "); + } + + #[test] + fn references_explicit_pytest_fixture_stops_at_external_stub() { + let test = explicit_external_stub_fixture_cursor_test( + r#" + from third_party_plugin import implementation + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:4:14 + | + 4 | def test_use(resource): + | -------- + 5 | print(resource) + | -------- + "); + } + + fn external_stub_fixture_definition_cursor_test() -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture + def external_resource(): ... + "#, + ) + .source( + "third_party_plugin.pyi", + r#" + def external_resource() -> object: ... + "#, + ) + .source( + "test_example.py", + r#" + from third_party_plugin import external_resource + "#, + ) + .build() + } + + fn external_stub_fixture_cursor_test(test_source: &str) -> CursorTest { + external_stub_fixture_cursor_test_with_stub( + test_source, + r#" + def external_resource() -> object: ... + "#, + ) + } + + fn external_stub_fixture_cursor_test_with_stub( + test_source: &str, + stub_source: &str, + ) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .site_packages( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture + def external_resource(): ... + "#, + ) + .site_packages("third_party_plugin.pyi", stub_source) + .source("test_example.py", test_source) + .build() + } + + fn explicit_external_stub_fixture_cursor_test(test_source: &str) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + "#, + ) + .source( + "third_party_plugin.pyi", + r#" + def implementation() -> object: ... + "#, + ) + .source("test_example.py", test_source) + .build() + } + + fn ambiguous_pytest_fixture_cursor_test( + first_fixture: &str, + ambiguous_test: &str, + ) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source("first.py", first_fixture) + .source( + "second.py", + r#" + import pytest + + @pytest.fixture + def second(): ... + "#, + ) + .source("test_ambiguous.py", ambiguous_test) + .source( + "test_second.py", + r#" + from second import second as resource + + def test_second(resource): + print(resource) + "#, + ) + .build() + } + + fn pytest_cursor_test(source: &str) -> CursorTest { + pytest_cursor_test_builder() + .source("test_example.py", source) + .build() + } + + fn pytest_cursor_test_builder() -> SitePackagesCursorTestBuilder { + let mut builder = CursorTest::builder().with_site_packages(); + builder + .site_packages( + "_pytest/__init__.py", + r#" + "#, + ) + .site_packages( + "_pytest/__init__.pyi", + r#" + "#, + ) + .site_packages( + "_pytest/config/__init__.py", + r#" + default_plugins = ("tmpdir",) + "#, + ) + .site_packages( + "_pytest/mark/__init__.pyi", + r#" + "#, + ) + .site_packages( + "_pytest/mark/structures.pyi", + r#" + class MarkDecorator: + def __call__(self, *args: object, **kwargs: object) -> object: ... + + class _ParametrizeMarkDecorator(MarkDecorator): ... + + class MarkGenerator: + parametrize: _ParametrizeMarkDecorator + "#, + ) + .site_packages( + "_pytest/fixtures.pyi", + r#" + from typing import Any, Callable + + def fixture( + function: Callable[..., Any] | None = ..., + *, + name: str | None = ..., + ) -> Any: ... + "#, + ) + .site_packages( + "_pytest/tmpdir.py", + r#" + from _pytest.fixtures import fixture + + @fixture + def tmp_path(): ... + "#, + ) + .site_packages( + "pytest/__init__.pyi", + r#" + from _pytest.fixtures import fixture as fixture + from _pytest.mark.structures import MarkGenerator + + mark: MarkGenerator + "#, + ); + builder + } } diff --git a/crates/ty_ide/src/goto.rs b/crates/ty_ide/src/goto.rs index 6aeddd4e90..f291e8d7d2 100644 --- a/crates/ty_ide/src/goto.rs +++ b/crates/ty_ide/src/goto.rs @@ -26,6 +26,7 @@ use ty_python_semantic::{Db as SemanticDb, ResolvedDefinition}; use ty_python_semantic::{ HasDefinition, HasType, ImportAliasResolution, ProgramEnvironment, SemanticModel, TypeQualifiers, definitions_for_imported_symbol, definitions_for_name, + fixture_bindings_for_parameter, }; #[derive(Clone, Debug)] @@ -344,8 +345,27 @@ impl<'db> Definitions<'db> { model: &SemanticModel<'db>, goto_target: &GotoTarget<'_>, ) -> Option> { - let definitions = self.goto_declaration(model, goto_target)?; - Some(definitions.map_stubs(model.db())) + let mut definitions = self; + + if let GotoTarget::Parameter(parameter) = goto_target { + let fixture_bindings = + fixture_bindings_for_parameter(model.db(), parameter.definition(model)); + + if !fixture_bindings.is_empty() { + definitions = Self::new( + fixture_bindings + .iter() + .map(|binding| ResolvedDefinition::Definition(binding.fixture())) + .collect(), + ); + } + } + + Some( + definitions + .goto_declaration(model, goto_target)? + .map_stubs(model.db()), + ) } /// Map definitions from stub files to corresponding source implementations. diff --git a/crates/ty_ide/src/goto_definition.rs b/crates/ty_ide/src/goto_definition.rs index f9e13461e5..b044cf672f 100644 --- a/crates/ty_ide/src/goto_definition.rs +++ b/crates/ty_ide/src/goto_definition.rs @@ -144,6 +144,82 @@ def f(items): "); } + #[test] + fn goto_definition_uses_pytest_fixture_binding() { + let mut builder = CursorTest::builder().with_site_packages(); + let test = builder + .site_packages( + "_pytest/__init__.pyi", + r#" + "#, + ) + .site_packages( + "_pytest/fixtures.pyi", + r#" + from typing import Any, Callable + + def fixture( + function: Callable[..., Any] | None = ..., + *, + name: str | None = ..., + ) -> Any: ... + "#, + ) + .site_packages( + "pytest/__init__.pyi", + r#" + from _pytest.fixtures import fixture as fixture + "#, + ) + .source( + "test_example.py", + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + + def test_use(resource): ... + "#, + ) + .build(); + + assert_snapshot!(test.goto_definition(), @" + info[goto-definition]: Go to definition + --> src/test_example.py:7:14 + | + 7 | def test_use(resource): ... + | ^^^^^^^^ Clicking here + info: Found 1 definition + --> src/test_example.py:5:5 + | + 5 | def implementation(): ... + | -------------- + "); + } + + #[test] + fn goto_definition_parameter_declaration() { + let test = cursor_test( + r#" + def function(parameter): ... + "#, + ); + + assert_snapshot!(test.goto_definition(), @" + info[goto-definition]: Go to definition + --> main.py:2:14 + | + 2 | def function(parameter): ... + | ^^^^^^^^^ Clicking here + info: Found 1 definition + --> main.py:2:14 + | + 2 | def function(parameter): ... + | --------- + "); + } + #[test] fn goto_definition_imported_comprehension_walrus() { let test = CursorTest::builder() diff --git a/crates/ty_ide/src/goto_implementation.rs b/crates/ty_ide/src/goto_implementation.rs index 64f441f249..460f135c81 100644 --- a/crates/ty_ide/src/goto_implementation.rs +++ b/crates/ty_ide/src/goto_implementation.rs @@ -87,7 +87,6 @@ pub fn goto_implementation( .project() .files(db) .iter() - .copied() .filter(|candidate| *candidate != source_file) .collect(); candidate_files.push(source_file); diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index 710c3697da..322c01a518 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -58,6 +58,31 @@ mod tests { "); } + #[test] + fn goto_type_of_slot_descriptor() { + let test = cursor_test( + r#" + class Slotted: + __slots__ = ("value",) + + descriptor = Slotted.value + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @" + info[goto-type definition]: Go to type definition + --> main.py:LL:22 + | + LL | descriptor = Slotted.value + | ^^^^^ Clicking here + info: Found 1 type definition + --> stdlib/types.byi:LL:13 + | + LL | final class MemberDescriptorType: + | -------------------- + "); + } + #[test] fn goto_type_of_typing_dot_literal() { let test = cursor_test( diff --git a/crates/ty_ide/src/hints.rs b/crates/ty_ide/src/hints.rs index 1581bc3382..6ffa4ab173 100644 --- a/crates/ty_ide/src/hints.rs +++ b/crates/ty_ide/src/hints.rs @@ -1,6 +1,6 @@ +use ruff_db::files::File; use ruff_python_ast::name::Name; use ruff_text_size::TextRange; -use ty_python_core::ProgramFile; use ty_python_semantic::types::ide_support::{ UnreachableKind, unreachable_ranges, unused_bindings, }; @@ -40,12 +40,12 @@ impl HintKind { } } -pub fn hints(db: &dyn Db, file: ProgramFile<'_>) -> Vec { - let source_file = file.file(db); - if !db.should_check_file(source_file) { +pub fn hints(db: &dyn Db, file: File) -> Vec { + if !ty_project::should_check_semantics(db, file) { return Vec::new(); } + let file = db.program_file(file); let unreachable = unreachable_ranges(db, file); let mut hints = unused_bindings(db, file) diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index a7571bda2b..10c1b128cc 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -9,8 +9,7 @@ use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_python_literal::format::FormatSpec; use ruff_python_literal::strftime; use ruff_text_size::{Ranged, TextRange, TextSize}; -use std::fmt; -use std::fmt::Formatter; +use std::fmt::{self, Display, Formatter}; use ty_python_core::ProgramFile; use ty_python_semantic::ProgramEnvironment; use ty_python_semantic::types::ide_support::{resolved_call_signature, typed_dict_key_hover}; @@ -372,12 +371,27 @@ pub struct Hover<'db> { impl<'db> Hover<'db> { /// Renders the hover to a string using the specified markup kind. - pub const fn display<'a>(&'a self, db: &'db dyn Db, kind: MarkupKind) -> DisplayHover<'db, 'a> { - DisplayHover { - db, - hover: self, - kind, - } + pub const fn display<'a>(&'a self, db: &'db dyn Db, kind: MarkupKind) -> impl Display { + std::fmt::from_fn(move |f| { + // a hover shows the type as the reader would write it in this file: + // `1` in a basedpython file, `Literal[1]` in a python one. the contents + // render here rather than where the hover was built, so the file's own + // spelling has to be in force at this point too + with_display_for_file(db, self.program_file.file(db), || { + let mut first = true; + let env = ProgramEnvironment::from_file(self.program_file); + for content in &self.contents { + if !first { + kind.horizontal_line().fmt(f)?; + } + + content.display(db, &env, kind).fmt(f)?; + first = false; + } + + Ok(()) + }) + }) } fn iter(&self) -> std::slice::Iter<'_, HoverContent<'db>> { @@ -403,35 +417,6 @@ impl<'a, 'db> IntoIterator for &'a Hover<'db> { } } -pub struct DisplayHover<'db, 'a> { - db: &'db dyn Db, - hover: &'a Hover<'db>, - kind: MarkupKind, -} - -impl fmt::Display for DisplayHover<'_, '_> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let db = self.db; - let file = self.hover.program_file; - // a hover shows the type as the reader would write it in this file: - // `1` in a basedpython file, `Literal[1]` in a python one - with_display_for_file(db, file.file(db), || { - let mut first = true; - let env = ProgramEnvironment::from_file(file); - for content in &self.hover.contents { - if !first { - self.kind.horizontal_line().fmt(f)?; - } - - content.display(db, &env, self.kind).fmt(f)?; - first = false; - } - - Ok(()) - }) - } -} - #[derive(Debug, Clone)] pub enum HoverContent<'db> { Signature(String), diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 4c71c6dced..7128a8659e 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -901,7 +901,8 @@ pub struct InlayHintSettings { impl InlayHintSettings { /// Every hint disabled — a base for enabling one kind at a time. - pub fn none() -> Self { + #[cfg(test)] + pub(crate) fn none() -> Self { Self { variable_types: false, call_argument_names: false, @@ -2298,19 +2299,19 @@ mod tests { use ruff_db::system::{DbWithWritableSystem, SystemPathBuf}; use ty_project::ProjectMetadata; - pub(super) fn inlay_hint_test(source: &str) -> InlayHintTest { + fn inlay_hint_test(source: &str) -> InlayHintTest { inlay_hint_test_in("main.py", source, false) } /// Like [`inlay_hint_test`], but for a `.by` source, so basedpython-only /// hints are produced. - pub(super) fn basedpython_inlay_hint_test(source: &str) -> InlayHintTest { + fn basedpython_inlay_hint_test(source: &str) -> InlayHintTest { inlay_hint_test_in("main.by", source, false) } /// An inlay-hint test with `analysis.sound-types` enabled, for the signatures ty /// recovers rather than reads. - pub(super) fn sound_types_inlay_hint_test(source: &str) -> InlayHintTest { + fn sound_types_inlay_hint_test(source: &str) -> InlayHintTest { inlay_hint_test_in("main.by", source, true) } @@ -2366,7 +2367,7 @@ mod tests { } } - pub(super) struct InlayHintTest { + struct InlayHintTest { db: ty_project::TestDb, file: File, range: TextRange, @@ -8143,7 +8144,7 @@ Source with applied edits: def foo(x: int, *y: bool, z: str | int | list[str]): ... - a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo + a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo --------------------------------------------- info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 @@ -8153,7 +8154,7 @@ Source with applied edits: info: Source --> main2.py:LL:16 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo | ^^^ info[inlay-hint-location]: Inlay Hint Target @@ -8164,7 +8165,7 @@ Source with applied edits: info: Source --> main2.py:LL:25 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo | ^^^^ info[inlay-hint-location]: Inlay Hint Target @@ -8173,10 +8174,10 @@ Source with applied edits: LL | class str(Sequence[str]): | ^^^ info: Source - --> main2.py:LL:37 + --> main2.py:LL:34 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo - | ^^^ + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo + | ^^^ info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 @@ -8184,10 +8185,10 @@ Source with applied edits: LL | class int: | ^^^ info: Source - --> main2.py:LL:43 + --> main2.py:LL:40 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo - | ^^^ + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo + | ^^^ info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 @@ -8195,10 +8196,10 @@ Source with applied edits: LL | class list[in out Element](MutableSequence[Element]): | ^^^^ info: Source - --> main2.py:LL:49 + --> main2.py:LL:46 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo - | ^^^^ + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo + | ^^^^ info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 @@ -8206,10 +8207,10 @@ Source with applied edits: LL | class str(Sequence[str]): | ^^^ info: Source - --> main2.py:LL:54 + --> main2.py:LL:51 | - LL | a[: def foo(x: int, *y: bool, *, z: str | int | list[str])] = foo - | ^^^ + LL | a[: def foo(x: int, *y: bool, z: str | int | list[str])] = foo + | ^^^ "); } diff --git a/crates/ty_ide/src/lib.rs b/crates/ty_ide/src/lib.rs index 93fcfdcd5f..624c6abd12 100644 --- a/crates/ty_ide/src/lib.rs +++ b/crates/ty_ide/src/lib.rs @@ -52,12 +52,11 @@ pub use completion::{ pub use data_flow::{Finding, FindingKind, data_flow_at}; pub use django_template::{ DisplayTemplateHover, DjangoChecker, DjangoCodeLens, DjangoLensAction, DjangoLensTarget, - DjangoSymbol, PreparedTemplateRename, TemplateCompletion, TemplateEdit, TemplateHover, - TemplateInlayHint, TemplateInlayHintKind, TemplateRename, TemplateRenameOutcome, - TemplateSignature, TemplateSymbol, django_manage_script, django_prepare_rename, - django_python_code_lenses, django_python_diagnostics, django_references, django_rename, - django_template_code_lenses, django_template_completions, django_template_diagnostics, - django_template_document_symbols, django_template_folding_ranges, + PreparedTemplateRename, TemplateCompletion, TemplateEdit, TemplateHover, TemplateInlayHint, + TemplateInlayHintKind, TemplateRename, TemplateRenameOutcome, TemplateSignature, + TemplateSymbol, django_manage_script, django_prepare_rename, django_python_code_lenses, + django_references, django_rename, django_template_code_lenses, django_template_completions, + django_template_diagnostics, django_template_document_symbols, django_template_folding_ranges, django_template_goto_definition, django_template_hover, django_template_inlay_hints, django_template_semantic_tokens, django_template_signature_help, is_django_template_path, }; @@ -295,7 +294,7 @@ impl NavigationTargets { self.0.iter() } - pub fn is_empty(&self) -> bool { + fn is_empty(&self) -> bool { self.0.is_empty() } diff --git a/crates/ty_ide/src/module_rename.rs b/crates/ty_ide/src/module_rename.rs index 36933b9693..b711257e58 100644 --- a/crates/ty_ide/src/module_rename.rs +++ b/crates/ty_ide/src/module_rename.rs @@ -123,7 +123,7 @@ pub fn module_rename_edits(db: &dyn Db, moves: &[FileMove]) -> ModuleRenameEdits // deliberate gesture, and it has to produce a stable order — the client applies these as one // edit and a set of edits that arrives in a different order on every run is one nobody can // review or test. - let mut files: Vec = db.project().files(db).iter().copied().collect(); + let mut files: Vec = db.project().files(db).iter().collect(); files.sort_by_key(|file| file.path(db).as_str().to_string()); for file in files { diff --git a/crates/ty_ide/src/references.rs b/crates/ty_ide/src/references.rs index 0bbadfd39a..adb401d344 100644 --- a/crates/ty_ide/src/references.rs +++ b/crates/ty_ide/src/references.rs @@ -13,20 +13,25 @@ use crate::goto::{Definitions, GotoTarget}; use crate::{Db, ReferenceKind, ReferenceTarget}; use rayon::prelude::*; +use ruff_db::files::File; use ruff_db::parsed::parsed_module; use ruff_python_ast::find_node::{CoveringNode, covering_node}; use ruff_python_ast::token::Tokens; use ruff_python_ast::{ self as ast, AnyNodeRef, + name::Name, visitor::source_order::{SourceOrderVisitor, TraversalSignal}, }; use ruff_text_size::Ranged; +use rustc_hash::{FxHashMap, FxHashSet}; use ty_project::parallel::{ParallelIteratorExt, minimum_parallel_job_len}; use ty_python_core::ProgramFile; use ty_python_core::definition::{Definition, DefinitionKind, DefinitionState}; use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, ScopeKind}; use ty_python_semantic::{ - ImportAliasResolution, ResolvedDefinition, SemanticModel, contains_identifier, + Db as SemanticDb, FixtureExposure, FixtureNameSource, ImportAliasResolution, + ResolvedDefinition, SemanticModel, contains_identifier, fixture_bindings_for_parameter, + fixture_exposures_for_definition, pytest_global_plugin_files, }; /// Salsa snapshots coordinate clone and drop through shared state. For cached files that don't @@ -91,20 +96,55 @@ pub(crate) fn references( goto_target: &GotoTarget, mode: ReferencesMode, ) -> Option> { - let source_file = file.file(db); let model = SemanticModel::new(db, file); - let target_definitions = goto_target.definitions(&model, mode.to_import_alias_resolution())?; + let target_text = goto_target.to_string()?.into_owned(); + + let target_definitions = goto_target + .definitions(&model, mode.to_import_alias_resolution())? + .goto_declaration(&model, goto_target)?; + let import_alias_resolution = mode.to_import_alias_resolution(); + // An identifier can have both ordinary Python references and pytest fixture references. + // Keep its Python definitions alongside any fixture roots used for the same search. + let fixture_target = matches!( + mode, + ReferencesMode::References | ReferencesMode::ReferencesSkipDeclaration + ) + .then(|| FixtureReferenceTarget::from_goto_target(&model, goto_target, &target_text)) + .flatten(); + let fixture_resolution = fixture_target.map(|target| target.resolution); + let is_externally_visible_symbol = has_any_external_visible_definitions(db, &target_definitions); - let target_definitions = target_definitions.goto_declaration(&model, goto_target)?; - // Extract the target text from the goto target for fast comparison - let target_text = goto_target.to_string()?; + let is_parameter = parameter_owner_is_externally_visible(db, &target_definitions); - // Find all of the references to the symbol within this file - let mut references = references_for_file(db, file, &target_definitions, &target_text, mode); + let search = LocalReferenceSearch { + target_text, + target_definitions, + import_alias_resolution, + fixture_resolution, + }; + references_for_search( + db, + file, + &search, + mode, + is_externally_visible_symbol, + is_parameter, + ) +} - // Check if we should search across files based on the mode +fn references_for_search( + db: &dyn Db, + file: ProgramFile<'_>, + search: &LocalReferenceSearch<'_>, + mode: ReferencesMode, + is_externally_visible_symbol: bool, + is_parameter: bool, +) -> Option> { + let source_file = file.file(db); + let mut references = references_for_file(db, file, search, mode); + let has_fixture_target = search.fixture_resolution.is_some(); let search_across_files = matches!( mode, ReferencesMode::References @@ -112,49 +152,71 @@ pub(crate) fn references( | ReferencesMode::RenameMultiFile ); - // Parameters are local by scope, but they can have cross-file references via keyword - // argument labels (e.g. `f(param=...)`). Handle this case with a narrow scan that only - // considers keyword arguments. - let is_parameter = parameter_owner_is_externally_visible(db, &target_definitions); - - if search_across_files && (is_parameter || is_externally_visible_symbol) { - let program = model.program(); - let files = db.project().files(db); - let files: Vec<_> = files - .iter() - .copied() - .filter(|other| *other != source_file) - .collect(); + if search_across_files && (has_fixture_target || is_parameter || is_externally_visible_symbol) { + let program = file.program(db); + let files: Vec<_> = if let Some(fixture_resolution) = &search.fixture_resolution { + let mut files: FxHashSet = db.project().files(db).iter().collect(); + files.extend(fixture_resolution.files.iter().copied()); + files.extend( + pytest_global_plugin_files(db, program) + .iter() + .map(|file| file.file(db)), + ); + files.remove(&source_file); + files.into_iter().collect() + } else { + db.project() + .files(db) + .iter() + .filter(|other| *other != source_file) + .collect() + }; let minimum_job_len = minimum_parallel_job_len(files.len(), MAX_MIN_FILES_PER_PARALLEL_JOB); let other_references = files .into_par_iter() .with_min_len(minimum_job_len) .map_with_db(db, |db, other_file| { let source = ruff_db::source::source_text(db, other_file); - if !contains_identifier(&source, &target_text) { + if !contains_identifier(&source, &search.target_text) { return Vec::new(); } let other_file = ProgramFile::new(db, other_file, program); - - if is_externally_visible_symbol { - references_for_file(db, other_file, &target_definitions, &target_text, mode) + if has_fixture_target || is_externally_visible_symbol { + references_for_file(db, other_file, search, mode) } else { - references_for_keyword_arguments_in_file( - db, - other_file, - &target_definitions, - &target_text, - mode, - ) + // Parameters are local by scope, but they can have cross-file references via keyword + // argument labels (e.g. `f(param=...)`). Handle this case with a narrow scan that only + // considers keyword arguments. + references_for_keyword_arguments_in_file(db, other_file, search, mode) } }) .flat_map_iter(|references| references) .collect::>(); - references.extend(other_references); } + if matches!(mode, ReferencesMode::References) + && let Some(fixture_resolution) = &search.fixture_resolution + { + let declarations: FxHashSet<_> = fixture_resolution + .roots + .iter() + .filter_map(|root| match root { + FixtureNameSource::Binding(_) => None, + FixtureNameSource::Explicit { declaration, .. } => *declaration, + }) + .collect(); + + references.extend(declarations.into_iter().map(|declaration| { + ReferenceTarget::new( + declaration.file(), + declaration.range(), + ReferenceKind::Other, + ) + })); + } + if references.is_empty() { None } else { @@ -165,8 +227,7 @@ pub(crate) fn references( fn references_for_keyword_arguments_in_file( db: &dyn Db, file: ProgramFile<'_>, - target_definitions: &Definitions<'_>, - target_text: &str, + search: &LocalReferenceSearch<'_>, mode: ReferencesMode, ) -> Vec { // This path is used for cross-file parameter keyword-label references. @@ -184,10 +245,10 @@ fn references_for_keyword_arguments_in_file( let mut finder = KeywordArgumentReferencesFinder(LocalReferencesFinder { model: &model, tokens: module.tokens(), - target_definitions, + search, references: &mut references, mode, - target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }); @@ -226,8 +287,7 @@ fn is_slots_assignment(node: AnyNodeRef<'_>, value: AnyNodeRef<'_>) -> bool { fn references_for_file( db: &dyn Db, file: ProgramFile<'_>, - target_definitions: &Definitions<'_>, - target_text: &str, + search: &LocalReferenceSearch<'_>, mode: ReferencesMode, ) -> Vec { let parsed = parsed_module(db, file.python_file(db)); @@ -237,11 +297,11 @@ fn references_for_file( let mut finder = LocalReferencesFinder { model: &model, - target_definitions, + search, references: &mut references, mode, tokens: module.tokens(), - target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }; @@ -372,14 +432,207 @@ impl From for OccurrenceKind { } } +/// A name used to request a pytest fixture, selected as the starting point for find-references. +pub(crate) struct FixtureReferenceTarget<'db> { + name: Name, + resolution: FixtureReferenceResolution<'db>, +} + +impl<'db> FixtureReferenceTarget<'db> { + /// Finds references starting from an explicit fixture-name declaration. + pub(crate) fn references( + self, + db: &dyn Db, + file: ProgramFile<'_>, + mode: ReferencesMode, + ) -> Option> { + let search = LocalReferenceSearch { + target_text: self.name.to_string(), + target_definitions: Definitions::new(Vec::new()), + import_alias_resolution: ImportAliasResolution::PreserveAliases, + fixture_resolution: Some(self.resolution), + }; + references_for_search(db, file, &search, mode, false, false) + } + + fn from_goto_target( + model: &SemanticModel<'db>, + goto_target: &GotoTarget<'_>, + name: &str, + ) -> Option { + let definitions = goto_target.definitions(model, ImportAliasResolution::PreserveAliases)?; + let mut resolution = FixtureReferenceResolution::default(); + + for resolved in &definitions { + let Some(definition) = resolved.definition() else { + continue; + }; + resolution.extend(fixture_reference_resolution_for_definition( + model.db(), + definition, + name, + )); + } + + (!resolution.roots.is_empty()).then(|| Self { + name: Name::new(name), + resolution, + }) + } + + /// Creates a reference target from a fixture exposure. + pub(crate) fn from_exposure(db: &'db dyn SemanticDb, exposure: &FixtureExposure<'db>) -> Self { + let mut resolution = FixtureReferenceResolution::default(); + collect_fixture_reference_roots(db, exposure, &mut FxHashSet::default(), &mut resolution); + + Self { + name: exposure.name().clone(), + resolution, + } + } +} + +struct LocalReferenceSearch<'db> { + target_text: String, + target_definitions: Definitions<'db>, + import_alias_resolution: ImportAliasResolution, + fixture_resolution: Option>, +} + +/// The name sources used to match fixture references and the files visited to find them. +#[derive(Default)] +struct FixtureReferenceResolution<'db> { + /// Name sources at the roots of import chains that preserve the fixture and its exposed name. + roots: FxHashSet>, + /// Includes intermediate imports, which may be outside the project's files. + files: FxHashSet, +} + +impl FixtureReferenceResolution<'_> { + fn extend(&mut self, other: Self) { + self.roots.extend(other.roots); + self.files.extend(other.files); + } +} + +fn fixture_reference_resolution_for_definition<'db>( + db: &'db dyn SemanticDb, + definition: Definition<'db>, + name: &str, +) -> FixtureReferenceResolution<'db> { + let mut resolution = FixtureReferenceResolution::default(); + + let mut collect = |exposure: &FixtureExposure<'db>| { + collect_fixture_reference_roots(db, exposure, &mut FxHashSet::default(), &mut resolution); + }; + + match definition.kind(db) { + DefinitionKind::Parameter(_) => { + // Fixture requests can use either a binding name or an explicit decorator name. + for binding in fixture_bindings_for_parameter(db, definition) { + binding + .exposures() + .iter() + .filter(|exposure| exposure.name() == name) + .for_each(&mut collect); + } + } + // Avoid fixture lookup for unrelated source bindings, but allow stubs to describe fixtures + // as variables rather than functions. + kind if matches!( + kind, + DefinitionKind::Function(_) + | DefinitionKind::ImportFrom(_) + | DefinitionKind::StarImport(_) + ) || (matches!( + kind, + DefinitionKind::Assignment(_) | DefinitionKind::AnnotatedAssignment(_) + ) && definition.file(db).is_stub(db)) => + { + // These definitions refer to Python bindings. Only `Binding` name sources use + // that Python name as the fixture name; `Explicit` sources instead use the decorator: + // + // @pytest.fixture(name="public_name") + // def implementation(): ... + // copy = implementation # Python function reference + // def test_use(public_name): # Fixture request + // ... + // + // Omit the explicit fixture-name group for these Python bindings. + fixture_exposures_for_definition(db, definition) + .iter() + .filter(|exposure| { + exposure.name() == name + && matches!(exposure.name_source(db), FixtureNameSource::Binding(_)) + }) + .for_each(collect); + } + _ => {} + } + + resolution +} + +/// Follows imports that preserve the fixture name, collecting the name sources at their roots. +/// Returns whether this branch supplied a root; a cycle back to the current path returns false. +fn collect_fixture_reference_roots<'db>( + db: &'db dyn SemanticDb, + exposure: &FixtureExposure<'db>, + path: &mut FxHashSet>, + resolution: &mut FixtureReferenceResolution<'db>, +) -> bool { + // Stop cycles in the current import path. + if !path.insert(exposure.clone()) { + return false; + } + // Intermediate imports must be searched too, including files outside the project. + resolution.files.insert(exposure.local_binding().file(db)); + + // Re-exports share a root only while both the fixture and its exposed name stay the same. + // Renaming a default-named fixture (`from fixtures import resource as local`) starts a new group. + let mut found_source_root = false; + if let Some(source) = exposure.source_binding() { + for source_exposure in + fixture_exposures_for_definition(db, source) + .iter() + .filter(|source_exposure| { + source_exposure.fixture() == exposure.fixture() + && source_exposure.name() == exposure.name() + }) + { + found_source_root |= + collect_fixture_reference_roots(db, source_exposure, path, resolution); + } + } + + // With no root from a same-name source, this exposure starts the reference group. This + // includes direct declarations and renamed imports, as well as chains stopped at a stub + // or cycle. + if !found_source_root { + // Keep references resolved through a stub separate from the explicit-name declaration in + // its runtime implementation, matching reference behavior for ordinary Python symbols. + let root = if exposure.local_binding().file(db).is_stub(db) { + FixtureNameSource::Binding(exposure.local_binding()) + } else { + exposure.name_source(db) + }; + resolution.roots.insert(root); + } + + // Allow other import branches to resolve through this exposure. + path.remove(exposure); + + true +} + /// AST visitor to find all references to a specific symbol by comparing semantic definitions struct LocalReferencesFinder<'a> { model: &'a SemanticModel<'a>, tokens: &'a Tokens, - target_definitions: &'a Definitions<'a>, + search: &'a LocalReferenceSearch<'a>, references: &'a mut Vec, mode: ReferencesMode, - target_text: &'a str, + fixture_match_cache: FxHashMap, bool>, ancestors: Vec>, } @@ -390,7 +643,7 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { match node { AnyNodeRef::ExprName(name_expr) => { // If the name doesn't match our target text, this isn't a match - if name_expr.id.as_str() != self.target_text { + if name_expr.id.as_str() != self.search.target_text { return TraversalSignal::Traverse; } @@ -466,11 +719,11 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { { let mut sub_finder = LocalReferencesFinder { model: &sub_model, - target_definitions: self.target_definitions, + search: self.search, references: self.references, mode: self.mode, tokens: sub_ast.tokens(), - target_text: self.target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }; sub_finder.visit_expr(sub_ast.expr()); @@ -483,7 +736,7 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { } // Only check the original name if it matches our target text // This is for cases where we're renaming the imported symbol name itself - if alias.name.id == self.target_text { + if alias.name.id == self.search.target_text { self.check_declaration_identifier(&alias.name); } } @@ -539,7 +792,7 @@ impl<'a> LocalReferencesFinder<'a> { fn check_identifier(&mut self, identifier: &ast::Identifier, kind: OccurrenceKind) { // Quick text-based check first - if identifier.id != self.target_text { + if identifier.id != self.search.target_text { return; } @@ -549,37 +802,62 @@ impl<'a> LocalReferencesFinder<'a> { self.check_covering_node(&covering_node, kind); } - /// Returns the covering node's resolved definitions. - fn definitions_for_covering_node( + fn goto_target_for_covering_node<'node>( &self, - covering_node: &CoveringNode<'_>, - ) -> Option> { + covering_node: &CoveringNode<'node>, + ) -> Option> { // Use the start of the covering node as the offset. Any offset within // the node is fine here. Offsets matter only for import statements // where the identifier might be a multi-part module name. let offset = covering_node.node().start(); - let goto_target = - GotoTarget::from_covering_node(self.model, covering_node, offset, self.tokens)?; - - let definitions = goto_target - .definitions(self.model, self.mode.to_import_alias_resolution())? - .goto_declaration(self.model, &goto_target)?; - - Some(definitions) + GotoTarget::from_covering_node(self.model, covering_node, offset, self.tokens) } fn check_covering_node(&mut self, covering_node: &CoveringNode<'_>, kind: OccurrenceKind) { - let Some(current_definitions) = self.definitions_for_covering_node(covering_node) else { + let Some(goto_target) = self.goto_target_for_covering_node(covering_node) else { return; }; - // Check if any of the current definitions match our target definitions - if !self.target_definitions.intersects(¤t_definitions) { + // Fixture references match by exposure roots rather than Python definitions. + let mut fixture_match = false; + let mut fixture_request_match = false; + if let Some(fixture_resolution) = &self.search.fixture_resolution + && let Some(definitions) = + // Preserve import aliases so an imported fixture requested under a new name keeps its own root. + goto_target.definitions(self.model, ImportAliasResolution::PreserveAliases) + { + for definition in definitions + .iter() + .filter_map(ResolvedDefinition::definition) + { + if self.definition_matches_fixture_target(definition, fixture_resolution) { + fixture_match = true; + fixture_request_match |= matches!( + definition.kind(self.model.db()), + DefinitionKind::Parameter(_) + ); + } + } + } + + // Fall back to Python definitions if fixture matching did not identify this occurrence. + // A search starting from an explicit fixture-name literal has no Python definitions. + let ordinary_match = !fixture_match + && self.search.target_definitions.iter().next().is_some() + && goto_target + .definitions(self.model, self.search.import_alias_resolution) + .and_then(|definitions| definitions.goto_declaration(self.model, &goto_target)) + .is_some_and(|definitions| self.search.target_definitions.intersects(&definitions)); + + if !fixture_match && !ordinary_match { return; } if matches!(self.mode, ReferencesMode::ReferencesSkipDeclaration) { let is_declaration = match kind { + // A parameter declares a Python local but references the fixture it requests. + // Keep fixture requests even when the fixture declaration is excluded. + OccurrenceKind::Declaration if fixture_request_match => false, OccurrenceKind::Declaration => true, OccurrenceKind::Reference => false, OccurrenceKind::Binding => self.is_declaration(covering_node), @@ -598,6 +876,32 @@ impl<'a> LocalReferencesFinder<'a> { self.references.push(target); } + fn definition_matches_fixture_target( + &mut self, + definition: Definition<'a>, + fixture_resolution: &FixtureReferenceResolution<'a>, + ) -> bool { + // A parameter and its uses share one definition: + // + // def test_use(resource): + // print(resource) + // print(resource) + // + // The search target is fixed for this visitor, so cache the match result instead of + // resolving and comparing fixture roots for each occurrence. + *self + .fixture_match_cache + .entry(definition) + .or_insert_with(|| { + let resolution = fixture_reference_resolution_for_definition( + self.model.db(), + definition, + &self.search.target_text, + ); + !resolution.roots.is_disjoint(&fixture_resolution.roots) + }) + } + /// Checks a string literal that may be an entry in a class's `__slots__`. /// /// `__slots__` entries are plain strings, but they name instance @@ -616,7 +920,7 @@ impl<'a> LocalReferencesFinder<'a> { let [part] = string_expr.value.as_slice() else { return; }; - if part.value.as_ref() != self.target_text { + if part.value.as_ref() != self.search.target_text { return; } @@ -720,7 +1024,7 @@ impl<'a> LocalReferencesFinder<'a> { scope = node.parent()?; }; - self.target_definitions.iter().any(|resolved| { + self.search.target_definitions.iter().any(|resolved| { let Some(definition) = resolved.definition() else { return false; }; diff --git a/crates/ty_ide/src/rename.rs b/crates/ty_ide/src/rename.rs index a86ff6e4a5..d3923482cf 100644 --- a/crates/ty_ide/src/rename.rs +++ b/crates/ty_ide/src/rename.rs @@ -91,7 +91,7 @@ pub fn rename( /// Helper function to check if a file is included in the project. fn is_file_in_project(db: &dyn Db, file: File) -> bool { - file.path(db).is_system_virtual_path() || db.project().files(db).contains(&file) + file.path(db).is_system_virtual_path() || db.project().files(db).contains(file) } #[cfg(test)] diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 61598941fe..8605dba713 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -35,6 +35,7 @@ use ruff_python_ast::helpers::{ consumed_keywords, if_let_keyword_range, parameter_modifiers, raises_clause_spans, return_guards, word_token_ranges, }; +use ruff_python_ast::script::ScriptTag; use ruff_python_ast::visitor::source_order::{ SourceOrderVisitor, TraversalSignal, walk_arguments, walk_elif_else_clause, walk_expr, walk_stmt, @@ -48,6 +49,9 @@ use ruff_python_literal::mini_language::FormatSpecComponent; use ruff_python_literal::strftime; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use std::ops::Deref; +use toml_parser::decoder::ScalarKind; +use toml_parser::parser::{Event, EventKind}; +use ty_project::script_tag; use ty_python_core::ProgramFile; use ty_python_core::definition::{Definition, DefinitionKind, ParameterDefinitionNodeKind}; use ty_python_semantic::{ @@ -88,11 +92,12 @@ pub enum SemanticTokenType { /// Only produced for django templates, whose `|`, `:` and delimiters have no /// python counterpart worth colouring. Operator, + Regexp, } impl SemanticTokenType { /// Returns all supported semantic token types as enum variants. - pub const fn all() -> [SemanticTokenType; 17] { + pub const fn all() -> [SemanticTokenType; 18] { [ SemanticTokenType::Namespace, SemanticTokenType::Class, @@ -111,6 +116,7 @@ impl SemanticTokenType { SemanticTokenType::TypeParameter, SemanticTokenType::Comment, SemanticTokenType::Operator, + SemanticTokenType::Regexp, ] } @@ -140,6 +146,7 @@ impl SemanticTokenType { SemanticTokenType::TypeParameter => "typeParameter", SemanticTokenType::Comment => "comment", SemanticTokenType::Operator => "operator", + SemanticTokenType::Regexp => "regexp", } } } @@ -258,9 +265,104 @@ pub fn semantic_tokens( visitor.expecting_docstring = true; visitor.visit_body(parsed.suite()); + if let Some(tag) = script_tag(db, file.file(db)) { + let insertion = visitor + .tokens + .partition_point(|token| token.start() < tag.start()); + visitor + .tokens + .splice(insertion..insertion, script_metadata_tokens(tag, range)); + } + SemanticTokens::new(visitor.tokens) } +/// Highlights embedded TOML without treating its Python comment prefixes as TOML content. +fn script_metadata_tokens(tag: &ScriptTag, range: Option) -> Vec { + let metadata = tag.metadata(); + let tokens = toml_parser::Source::new(metadata).lex().collect::>(); + let mut semantic_tokens = Vec::new(); + let mut in_table_header = false; + + toml_parser::parser::parse_document( + &tokens, + &mut |event: Event| { + let span = event.span(); + let Some(text) = metadata.get(span.start()..span.end()) else { + return; + }; + + let token_type = match event.kind() { + EventKind::StdTableOpen | EventKind::ArrayTableOpen => { + in_table_header = true; + SemanticTokenType::Operator + } + EventKind::StdTableClose | EventKind::ArrayTableClose => { + in_table_header = false; + SemanticTokenType::Operator + } + EventKind::InlineTableOpen + | EventKind::InlineTableClose + | EventKind::ArrayOpen + | EventKind::ArrayClose + | EventKind::KeySep + | EventKind::KeyValSep + | EventKind::ValueSep => SemanticTokenType::Operator, + EventKind::SimpleKey if in_table_header => SemanticTokenType::Namespace, + EventKind::SimpleKey => SemanticTokenType::Variable, + EventKind::Scalar => { + let scalar = toml_parser::Raw::new_unchecked(text, event.encoding(), span); + + match scalar.decode_scalar(&mut (), &mut ()) { + ScalarKind::String => SemanticTokenType::String, + ScalarKind::Boolean(_) => SemanticTokenType::BuiltinConstant, + ScalarKind::DateTime => SemanticTokenType::Regexp, + ScalarKind::Float | ScalarKind::Integer(_) => SemanticTokenType::Number, + } + } + EventKind::Newline => { + in_table_header = false; + return; + } + _ => return, + }; + + let Ok(mut offset) = TextSize::try_from(span.start()) else { + return; + }; + + // Multiline TOML values are interrupted by Python comment prefixes in the source. + // Highlight each metadata line separately so those prefixes remain comments. + for line in text.split_inclusive('\n') { + let content = line.strip_suffix('\n').unwrap_or(line); + let token_range = tag + .source_map() + .map_range(TextRange::at(offset, content.text_len())); + offset += line.text_len(); + + if token_range.is_empty() + || range.is_some_and(|requested| { + token_range + .intersect(requested) + .is_none_or(TextRange::is_empty) + }) + { + continue; + } + + semantic_tokens.push(SemanticToken { + range: token_range, + token_type, + modifiers: SemanticTokenModifier::empty(), + }); + } + }, + &mut (), + ); + + semantic_tokens +} + /// basedpython lifetime modifiers, written ahead of the callable-type parameter /// they borrow. const LIFETIME_MODIFIERS: &[&str] = &["local", "once"]; @@ -2515,6 +2617,209 @@ mod tests { assert_snapshot!(test.to_snapshot(&tokens), @r#""foo" @ 4..7: Function [definition]"#); } + #[test] + fn script_metadata_highlights_toml_in_source_order() { + let source = r#"before = 1 +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx", "attrs"] +# [tool.uv] +# enabled = true +# disabled = false +# retries = 2 +# "quoted-key" = { nested = "value" } +# [[tool.packages]] +# name = "example" +# /// +after = 2 +"#; + let test = SemanticTokenTest::new(source); + let tokens = test.highlight_file(); + let source = ruff_db::source::source_text(&test.db, test.file); + let highlighted: Vec<_> = tokens + .iter() + .map(|token| (&source[token.range()], token.token_type)) + .collect(); + + assert_eq!( + highlighted, + vec![ + ("before", SemanticTokenType::Variable), + ("1", SemanticTokenType::Number), + ("requires-python", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("\">=3.12\"", SemanticTokenType::String), + ("dependencies", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("[", SemanticTokenType::Operator), + ("\"httpx\"", SemanticTokenType::String), + (",", SemanticTokenType::Operator), + ("\"attrs\"", SemanticTokenType::String), + ("]", SemanticTokenType::Operator), + ("[", SemanticTokenType::Operator), + ("tool", SemanticTokenType::Namespace), + (".", SemanticTokenType::Operator), + ("uv", SemanticTokenType::Namespace), + ("]", SemanticTokenType::Operator), + ("enabled", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("true", SemanticTokenType::BuiltinConstant), + ("disabled", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("false", SemanticTokenType::BuiltinConstant), + ("retries", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("2", SemanticTokenType::Number), + ("\"quoted-key\"", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("{", SemanticTokenType::Operator), + ("nested", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("\"value\"", SemanticTokenType::String), + ("}", SemanticTokenType::Operator), + ("[[", SemanticTokenType::Operator), + ("tool", SemanticTokenType::Namespace), + (".", SemanticTokenType::Operator), + ("packages", SemanticTokenType::Namespace), + ("]]", SemanticTokenType::Operator), + ("name", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("\"example\"", SemanticTokenType::String), + ("after", SemanticTokenType::Variable), + ("2", SemanticTokenType::Number), + ] + ); + } + + #[test] + fn script_metadata_distinguishes_numeric_and_datetime_values() { + let source = r#"# /// script +# integer = 42 +# hexadecimal = 0xff +# float = 1.5 +# infinity = +inf +# not-a-number = nan +# date = 2026-08-14 +# time = 08:15:30 +# datetime = 2026-08-14T08:15:30 +# offset-datetime = 2026-08-14T08:15:30Z +# /// +"#; + let test = SemanticTokenTest::new(source); + let tokens = test.highlight_file(); + let source = ruff_db::source::source_text(&test.db, test.file); + let values: Vec<_> = tokens + .iter() + .filter(|token| { + matches!( + token.token_type, + SemanticTokenType::Number | SemanticTokenType::Regexp + ) + }) + .map(|token| (&source[token.range()], token.token_type)) + .collect(); + + assert_eq!( + values, + vec![ + ("42", SemanticTokenType::Number), + ("0xff", SemanticTokenType::Number), + ("1.5", SemanticTokenType::Number), + ("+inf", SemanticTokenType::Number), + ("nan", SemanticTokenType::Number), + ("2026-08-14", SemanticTokenType::Regexp), + ("08:15:30", SemanticTokenType::Regexp), + ("2026-08-14T08:15:30", SemanticTokenType::Regexp), + ("2026-08-14T08:15:30Z", SemanticTokenType::Regexp), + ] + ); + } + + #[test] + fn script_metadata_multiline_strings_exclude_comment_prefixes() { + let source = r#"# /// script +# description = """ +# first +# +# last +# """ +# /// +"#; + let test = SemanticTokenTest::new(source); + let tokens = test.highlight_file(); + let source = ruff_db::source::source_text(&test.db, test.file); + let highlighted: Vec<_> = tokens + .iter() + .map(|token| (&source[token.range()], token.token_type)) + .collect(); + + assert_eq!( + highlighted, + vec![ + ("description", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("\"\"\"", SemanticTokenType::String), + ("first", SemanticTokenType::String), + ("last", SemanticTokenType::String), + ("\"\"\"", SemanticTokenType::String), + ] + ); + } + + #[test] + fn script_metadata_inside_statement_remains_in_source_order() { + let source = r#"value = ( + 1 +# /// script +# dependencies = ["httpx"] +# /// + + 2 +) +"#; + let test = SemanticTokenTest::new(source); + let tokens = test.highlight_file(); + let source = ruff_db::source::source_text(&test.db, test.file); + let highlighted: Vec<_> = tokens + .iter() + .map(|token| (&source[token.range()], token.token_type)) + .collect(); + + assert_eq!( + highlighted, + vec![ + ("value", SemanticTokenType::Variable), + ("1", SemanticTokenType::Number), + ("dependencies", SemanticTokenType::Variable), + ("=", SemanticTokenType::Operator), + ("[", SemanticTokenType::Operator), + ("\"httpx\"", SemanticTokenType::String), + ("]", SemanticTokenType::Operator), + ("2", SemanticTokenType::Number), + ] + ); + } + + #[test] + fn script_metadata_respects_requested_token_range() { + let source = r#"# /// script +# dependencies = ["httpx"] +# /// +value = 1 +"#; + let test = SemanticTokenTest::new(source); + let range = TextRange::at( + r"# /// script +# dependencies = [" + .text_len(), + "\"httpx\"".text_len(), + ); + let tokens = test.highlight_range(range); + + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].range(), range); + assert_eq!(tokens[0].token_type, SemanticTokenType::String); + } + #[test] fn semantic_tokens_class() { let test = SemanticTokenTest::new("class MyClass: pass"); diff --git a/crates/ty_ide/src/signature_help.rs b/crates/ty_ide/src/signature_help.rs index 323dcc593d..b40f08d407 100644 --- a/crates/ty_ide/src/signature_help.rs +++ b/crates/ty_ide/src/signature_help.rs @@ -11,9 +11,12 @@ use crate::FxIndexMap; use crate::docstring::Docstring; use crate::goto::docstring_for_call_definition; use ruff_db::parsed::parsed_module; +use ruff_db::source::source_text; use ruff_python_ast::find_node::covering_node; use ruff_python_ast::token::TokenKind; use ruff_python_ast::{self as ast, AnyNodeRef}; +use ruff_python_trivia::PythonWhitespace; +use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextSize}; use ty_python_core::ProgramFile; use ty_python_semantic::types::Type; @@ -92,7 +95,7 @@ fn signature_help_inner<'db>( let parsed = parsed_module(db, file.python_file(db)).load(db); // Get the call expression at the given position. - let (call_expr, current_arg_index) = get_call_expr(&parsed, offset)?; + let (call_expr, current_arg_index) = get_call_expr(db, &parsed, offset)?; let model = SemanticModel::new(db, file); @@ -123,16 +126,22 @@ fn signature_help_inner<'db>( /// Returns the innermost call expression that contains the specified offset /// and the index of the argument that the offset maps to. -fn get_call_expr( - parsed: &ruff_db::parsed::ParsedModuleRef, +fn get_call_expr<'ast>( + db: &dyn Db, + parsed: &'ast ruff_db::parsed::ParsedModuleRef, offset: TextSize, -) -> Option<(&ast::ExprCall, usize)> { +) -> Option<(&'ast ast::ExprCall, usize)> { let root_node: AnyNodeRef = parsed.syntax().into(); + let source = source_text(db, parsed.module().file()); + let line_range = source.line_range(offset); + let line = &source[line_range]; + let line_end = line_range.start() + TextSize::of(line.trim_whitespace_end()); + let token_offset = offset.min(line_end); // Find the token under the cursor and use its offset to find the node let token = parsed .tokens() - .at_offset(offset) + .at_offset(token_offset) .max_by_key(|token| match token.kind() { TokenKind::Name | TokenKind::String @@ -157,7 +166,9 @@ fn get_call_expr( } // Close the signature help if the cursor is at the closing parenthesis - if token.kind() == TokenKind::Rpar && node.end() == token.end() && offset == token.end() + if token.kind() == TokenKind::Rpar + && node.end() == token.end() + && token_offset == token.end() { return false; } @@ -391,6 +402,35 @@ mod tests { "); } + #[test] + fn signature_help_paramspec_classmethod_docstring() { + let test = cursor_test( + r#" + from typing import Callable + + class Factory: + def __init__(self, value: int) -> None: + """Constructor documentation.""" + + @classmethod + def make[**P](cls: Callable[P, "Factory"], *args: P.args, **kwargs: P.kwargs) -> "Factory": + """Factory method documentation.""" + return cls(*args, **kwargs) + + Factory.make() + "#, + ); + + let documentation = test + .signature_help() + .and_then(|result| result.signatures.into_iter().next()) + .and_then(|signature| signature.documentation); + assert_eq!( + documentation.as_ref().map(Docstring::render_plaintext), + Some("Factory method documentation.\n".to_string()) + ); + } + #[test] fn signature_help_nested_function_calls() { let test = cursor_test( @@ -1294,6 +1334,29 @@ def ab(a: int, *, c: int): assert_eq!(result.signatures[0].active_parameter, Some(1)); } + #[test] + fn signature_help_in_trailing_whitespace() { + for whitespace in [" ", "\t", "\u{000c}"] { + let source = format!( + "def func(first: int, second: str) -> None: ...\n\nfunc(1,{whitespace}" + ); + let test = cursor_test(&source); + + let result = test.signature_help().expect("Should have signature help"); + assert_eq!(result.signatures[0].active_parameter, Some(1)); + } + } + + #[test] + fn signature_help_in_trailing_whitespace_before_newline() { + let test = cursor_test( + "def func(first: int, second: str) -> None: ...\n\nfunc(1, \n \"value\")", + ); + + let result = test.signature_help().expect("Should have signature help"); + assert_eq!(result.signatures[0].active_parameter, Some(1)); + } + #[test] fn signature_help_after_closing_paren_at_end_of_file() { let test = cursor_test( diff --git a/crates/ty_ide/src/type_hierarchy.rs b/crates/ty_ide/src/type_hierarchy.rs index 616682f143..8a9dbe62c2 100644 --- a/crates/ty_ide/src/type_hierarchy.rs +++ b/crates/ty_ide/src/type_hierarchy.rs @@ -346,9 +346,9 @@ mod tests { let subtypes = test.subtypes(); insta::assert_snapshot!(snapshot(&test.db, &subtypes), @" vendored://stdlib/email/headerregistry.byi:698:708 BaseHeader :: email.headerregistry - vendored://stdlib/enum.byi:17905:17912 StrEnum :: enum + vendored://stdlib/enum.byi:17901:17908 StrEnum :: enum vendored://stdlib/pdb.byi:37873:37878 _rstr :: pdb - vendored://stdlib/ty_extensions/__init__.pyi:9222:9231 Character :: ty_extensions + vendored://stdlib/ty_extensions/__init__.pyi:9260:9269 Character :: ty_extensions vendored://stdlib/xxlimited.byi:98:101 Str :: xxlimited "); } @@ -379,10 +379,10 @@ mod tests { let subtypes = test.subtypes(); insta::assert_snapshot!(snapshot(&test.db, &subtypes), @" vendored://stdlib/email/headerregistry.byi:698:708 BaseHeader :: email.headerregistry - vendored://stdlib/enum.byi:17905:17912 StrEnum :: enum + vendored://stdlib/enum.byi:17901:17908 StrEnum :: enum /main.py:77:89 MyEventTypeA :: main vendored://stdlib/pdb.byi:37873:37878 _rstr :: pdb - vendored://stdlib/ty_extensions/__init__.pyi:9222:9231 Character :: ty_extensions + vendored://stdlib/ty_extensions/__init__.pyi:9260:9269 Character :: ty_extensions vendored://stdlib/xxlimited.byi:98:101 Str :: xxlimited "); } @@ -505,7 +505,7 @@ mod tests { let supertypes = test.supertypes(); insta::assert_snapshot!( snapshot(&test.db, &supertypes), - @"vendored://stdlib/builtins.byi:97123:97128 tuple :: builtins", + @"vendored://stdlib/builtins.byi:97124:97129 tuple :: builtins", ); } diff --git a/crates/ty_ide/src/workspace_symbols.rs b/crates/ty_ide/src/workspace_symbols.rs index bf2b50ba74..eab323d7d6 100644 --- a/crates/ty_ide/src/workspace_symbols.rs +++ b/crates/ty_ide/src/workspace_symbols.rs @@ -18,7 +18,7 @@ pub fn workspace_symbols(db: &dyn Db, query: &str) -> Vec { let project = db.project(); let query = QueryPattern::fuzzy(query); let files = project.files(db); - let files: Vec<_> = files.iter().copied().collect(); + let files: Vec<_> = files.iter().collect(); let mut found: Vec = files .into_par_iter() diff --git a/crates/ty_module_resolver/Cargo.toml b/crates/ty_module_resolver/Cargo.toml index ed0b9f93a8..39c16fd5fb 100644 --- a/crates/ty_module_resolver/Cargo.toml +++ b/crates/ty_module_resolver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_module_resolver" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_module_resolver/README.md b/crates/ty_module_resolver/README.md index 1c094d7d90..cf2f8498c9 100644 --- a/crates/ty_module_resolver/README.md +++ b/crates/ty_module_resolver/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_module_resolver). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_module_resolver). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_module_resolver/src/by_typed.rs b/crates/ty_module_resolver/src/by_typed.rs index 1ec75a5068..8449c37a4a 100644 --- a/crates/ty_module_resolver/src/by_typed.rs +++ b/crates/ty_module_resolver/src/by_typed.rs @@ -37,7 +37,7 @@ pub struct Marker { impl Marker { /// Reads what a `by.typed` says. - pub fn parse(text: &str) -> Self { + fn parse(text: &str) -> Self { let Ok(table) = text.parse::() else { return Self::default(); }; @@ -61,7 +61,7 @@ impl Marker { /// The text of a marker that declares `exported` and nothing else. /// - /// This is the other end of [`Self::parse`], and the only thing that writes a + /// This is the other end of `parse`, and the only thing that writes a /// `by.typed`'s contents: what a build stages is what a consumer reads back. pub fn render(exported: &[impl AsRef]) -> String { if exported.is_empty() { @@ -82,12 +82,16 @@ impl Marker { } /// The dependencies this distribution declares part of its interface. - pub fn exported_dependencies(&self) -> &[DistributionName] { + /// + /// Resolution asks [`Self::exports`] about one name at a time; the whole list is + /// what the tests below check `parse` and `render` against. + #[cfg(test)] + fn exported_dependencies(&self) -> &[DistributionName] { &self.exported_dependencies } /// Whether this distribution hands `distribution` out on purpose. - pub fn exports(&self, distribution: &DistributionName) -> bool { + fn exports(&self, distribution: &DistributionName) -> bool { self.exported_dependencies.contains(distribution) } diff --git a/crates/ty_module_resolver/src/distributions.rs b/crates/ty_module_resolver/src/distributions.rs index 3bf85d9184..47d14097b9 100644 --- a/crates/ty_module_resolver/src/distributions.rs +++ b/crates/ty_module_resolver/src/distributions.rs @@ -125,7 +125,7 @@ pub struct DistributionIndex { impl DistributionIndex { /// The distributions that install `top_level`, which is a directory name or /// a module file's stem directly inside `site-packages`. - pub fn owners_of_top_level(&self, top_level: &str) -> &[DistributionName] { + fn owners_of_top_level(&self, top_level: &str) -> &[DistributionName] { self.owners.get(top_level).map_or(&[], |owners| owners) } @@ -217,7 +217,7 @@ pub struct RequirementIndex { impl RequirementIndex { /// The installed distributions `distribution` requires. - pub fn requirements_of(&self, distribution: &DistributionName) -> &[DistributionName] { + fn requirements_of(&self, distribution: &DistributionName) -> &[DistributionName] { self.requirements .get(distribution) .map_or(&[], |requirements| requirements) diff --git a/crates/ty_module_resolver/src/environment.rs b/crates/ty_module_resolver/src/environment.rs index 478a854672..8218ca2587 100644 --- a/crates/ty_module_resolver/src/environment.rs +++ b/crates/ty_module_resolver/src/environment.rs @@ -1,5 +1,3 @@ -use std::fmt; - use ruff_db::files::File; use ruff_python_ast::PythonVersion; @@ -22,34 +20,20 @@ impl<'db> ResolverEnvironment<'db> { self, db: &'db dyn Db, mode: ModuleResolveMode, - ) -> DisplaySearchPaths<'db> { - DisplaySearchPaths { - db, - resolver_environment: self, - mode, - } - } -} - -pub struct DisplaySearchPaths<'db> { - db: &'db dyn Db, - resolver_environment: ResolverEnvironment<'db>, - mode: ModuleResolveMode, -} - -impl fmt::Display for DisplaySearchPaths<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut paths = search_paths(self.db, self.resolver_environment, self.mode).peekable(); + ) -> impl std::fmt::Display { + std::fmt::from_fn(move |f| { + let mut paths = search_paths(db, self, mode).peekable(); - if paths.peek().is_none() { - return f.write_str("[]"); - } + if paths.peek().is_none() { + return f.write_str("[]"); + } - writeln!(f, "[")?; - for path in paths { - writeln!(f, " {path},")?; - } - f.write_str("]") + writeln!(f, "[")?; + for path in paths { + writeln!(f, " {path},")?; + } + f.write_str("]") + }) } } diff --git a/crates/ty_module_resolver/src/lib.rs b/crates/ty_module_resolver/src/lib.rs index 9ba9185b4c..50561f234c 100644 --- a/crates/ty_module_resolver/src/lib.rs +++ b/crates/ty_module_resolver/src/lib.rs @@ -11,8 +11,9 @@ pub use module::Module; pub use module_name::{ImportingFile, ModuleName, ModuleNameResolutionError}; pub use path::{SearchPath, SearchPathError}; pub use resolve::{ - SearchPaths, file_to_module, resolve_module, resolve_module_confident, resolve_real_module, - resolve_real_module_confident, resolve_real_shadowable_module, + SearchPaths, editable_search_paths, file_to_module, resolve_module, resolve_module_confident, + resolve_module_for_import_from, resolve_real_module, resolve_real_module_confident, + resolve_real_shadowable_module, stub_file_to_real_module, }; pub use settings::{SearchPathSettings, SearchPathSettingsError}; pub use strategy::{FallibleStrategy, MisconfigurationStrategy, UseDefaultStrategy}; diff --git a/crates/ty_module_resolver/src/module.rs b/crates/ty_module_resolver/src/module.rs index cec4015ff2..a91ae6a623 100644 --- a/crates/ty_module_resolver/src/module.rs +++ b/crates/ty_module_resolver/src/module.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::debug_assert_matches; use std::fmt::Formatter; use std::str::FromStr; @@ -55,7 +56,7 @@ impl<'db> Module<'db> { } /// The resolver environment used to resolve this module. - pub fn resolver_environment(self, db: &'db dyn Database) -> ResolverEnvironment<'db> { + fn resolver_environment(self, db: &'db dyn Database) -> ResolverEnvironment<'db> { match self { Module::File(module) => module.resolver_environment(db), Module::Namespace(module) => module.resolver_environment(db), @@ -206,13 +207,9 @@ fn all_submodule_names_for_package<'db>( } let path = SystemOrVendoredPathRef::try_from_file(db, module.file(db))?; - debug_assert!( - matches!( - path.file_name(), - Some("__init__.py" | "__init__.pyi" | "__init__.by" | "__init__.byi") - ), - "expected package file `{:?}` to be `__init__.py`, `__init__.pyi`, `__init__.by`, or `__init__.byi`", + debug_assert_matches!( path.file_name(), + Some("__init__.py" | "__init__.pyi" | "__init__.by" | "__init__.byi") ); let resolver_environment = module.resolver_environment(db); @@ -387,6 +384,9 @@ pub enum KnownModule { TyExtensionsPydantic, #[strum(serialize = "importlib")] ImportLib, + /// The standard-library `unittest.case` module. + #[strum(serialize = "unittest.case")] + UnittestCase, #[strum(serialize = "unittest.mock")] UnittestMock, Uuid, @@ -422,14 +422,17 @@ pub enum KnownModule { PydanticSettingsMain, #[strum(serialize = "pydantic.types")] PydanticTypes, - #[strum(serialize = "sqlalchemy.orm.base")] - SqlalchemyOrmBase, - #[strum(serialize = "sqlalchemy.orm.decl_api")] - SqlalchemyOrmDeclApi, + Pytest, + #[strum(serialize = "_pytest.config")] + PytestConfig, #[strum(serialize = "_pytest.fixtures")] PytestFixtures, #[strum(serialize = "_pytest.mark.structures")] PytestMarkStructures, + #[strum(serialize = "sqlalchemy.orm.base")] + SqlalchemyOrmBase, + #[strum(serialize = "sqlalchemy.orm.decl_api")] + SqlalchemyOrmDeclApi, } impl KnownModule { @@ -462,6 +465,7 @@ impl KnownModule { Self::TyExtensionsPydantic => "ty_extensions.pydantic", Self::ImportLib => "importlib", Self::Warnings => "warnings", + Self::UnittestCase => "unittest.case", Self::Weakref => "weakref", Self::UnittestMock => "unittest.mock", Self::Uuid => "uuid", @@ -483,10 +487,12 @@ impl KnownModule { Self::PydanticRootModel => "pydantic.root_model", Self::PydanticSettingsMain => "pydantic_settings.main", Self::PydanticTypes => "pydantic.types", - Self::SqlalchemyOrmBase => "sqlalchemy.orm.base", - Self::SqlalchemyOrmDeclApi => "sqlalchemy.orm.decl_api", + Self::Pytest => "pytest", + Self::PytestConfig => "_pytest.config", Self::PytestFixtures => "_pytest.fixtures", Self::PytestMarkStructures => "_pytest.mark.structures", + Self::SqlalchemyOrmBase => "sqlalchemy.orm.base", + Self::SqlalchemyOrmDeclApi => "sqlalchemy.orm.decl_api", } } @@ -523,10 +529,12 @@ impl KnownModule { | Self::PydanticRootModel | Self::PydanticSettingsMain | Self::PydanticTypes - | Self::SqlalchemyOrmBase - | Self::SqlalchemyOrmDeclApi + | Self::Pytest + | Self::PytestConfig | Self::PytestFixtures - | Self::PytestMarkStructures => true, + | Self::PytestMarkStructures + | Self::SqlalchemyOrmBase + | Self::SqlalchemyOrmDeclApi => true, Self::Builtins | Self::Enum | Self::Types @@ -554,6 +562,7 @@ impl KnownModule { | Self::TyExtensionsInternal | Self::TyExtensionsPydantic | Self::ImportLib + | Self::UnittestCase | Self::UnittestMock | Self::Uuid | Self::Warnings diff --git a/crates/ty_module_resolver/src/module_glob.rs b/crates/ty_module_resolver/src/module_glob.rs index 9a75f3a0dd..016dcf23f8 100644 --- a/crates/ty_module_resolver/src/module_glob.rs +++ b/crates/ty_module_resolver/src/module_glob.rs @@ -354,6 +354,8 @@ fn glob_to_regex(pattern: &str) -> Result, ModuleGlobError> { #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; #[track_caller] @@ -527,52 +529,43 @@ mod tests { #[test] fn test_invalid_empty_pattern() { let result = ModuleGlobSet::from_patterns([""]); - assert!(matches!(result, Err(ModuleGlobError::EmptyPattern))); + assert_matches!(result, Err(ModuleGlobError::EmptyPattern)); } #[test] fn test_invalid_just_negation() { let result = ModuleGlobSet::from_patterns(["!"]); - assert!(matches!(result, Err(ModuleGlobError::EmptyPattern))); + assert_matches!(result, Err(ModuleGlobError::EmptyPattern)); } #[test] fn test_invalid_double_star_combined() { let result = ModuleGlobSet::from_patterns(["foo**"]); - assert!(matches!( - result, - Err(ModuleGlobError::InvalidDoubleStarUsage(_)) - )); + assert_matches!(result, Err(ModuleGlobError::InvalidDoubleStarUsage(_))); let result = ModuleGlobSet::from_patterns(["**foo"]); - assert!(matches!( - result, - Err(ModuleGlobError::InvalidDoubleStarUsage(_)) - )); + assert_matches!(result, Err(ModuleGlobError::InvalidDoubleStarUsage(_))); let result = ModuleGlobSet::from_patterns(["foo.bar**"]); - assert!(matches!( - result, - Err(ModuleGlobError::InvalidDoubleStarUsage(_)) - )); + assert_matches!(result, Err(ModuleGlobError::InvalidDoubleStarUsage(_))); } #[test] fn test_invalid_consecutive_dots() { let result = ModuleGlobSet::from_patterns(["foo..bar"]); - assert!(matches!(result, Err(ModuleGlobError::ConsecutiveDots))); + assert_matches!(result, Err(ModuleGlobError::ConsecutiveDots)); } #[test] fn test_invalid_leading_dot() { let result = ModuleGlobSet::from_patterns([".foo"]); - assert!(matches!(result, Err(ModuleGlobError::LeadingDot))); + assert_matches!(result, Err(ModuleGlobError::LeadingDot)); } #[test] fn test_invalid_trailing_dot() { let result = ModuleGlobSet::from_patterns(["foo."]); - assert!(matches!(result, Err(ModuleGlobError::TrailingDot))); + assert_matches!(result, Err(ModuleGlobError::TrailingDot)); } #[test] diff --git a/crates/ty_module_resolver/src/module_name.rs b/crates/ty_module_resolver/src/module_name.rs index 642b7512e7..1ca9b8258c 100644 --- a/crates/ty_module_resolver/src/module_name.rs +++ b/crates/ty_module_resolver/src/module_name.rs @@ -312,7 +312,7 @@ impl ModuleName { pub fn from_import_statement<'db>( db: &'db dyn Db, importing_file: ImportingFile<'db>, - node: &'db ast::StmtImportFrom, + node: &ast::StmtImportFrom, ) -> Result { let ast::StmtImportFrom { module, @@ -508,7 +508,7 @@ impl<'db> ImportingFile<'db> { } } - pub fn resolver_environment(self, db: &'db dyn Db) -> ResolverEnvironment<'db> { + pub(crate) fn resolver_environment(self, db: &'db dyn Db) -> ResolverEnvironment<'db> { match self { Self::ResolverFile(file) => file.environment(db), Self::File(_, resolver_environment) => resolver_environment, diff --git a/crates/ty_module_resolver/src/path.rs b/crates/ty_module_resolver/src/path.rs index ee18a3dc35..e7316a91aa 100644 --- a/crates/ty_module_resolver/src/path.rs +++ b/crates/ty_module_resolver/src/path.rs @@ -1,5 +1,6 @@ //! Internal abstractions for differentiating between different kinds of search paths. +use std::assert_matches; use std::fmt; use std::sync::Arc; @@ -7,6 +8,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use ruff_db::files::{ File, FilePath, directory_listing, system_path_to_file, vendored_path_to_file, }; +use ruff_db::source::source_text; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_db::vendored::{VendoredPath, VendoredPathBuf}; @@ -70,8 +72,9 @@ impl ModulePath { "Extension must be `pyi` or `byi`; got `{component_extension}`" ); } else { - assert!( - matches!(component_extension, "pyi" | "py" | "byi" | "by"), + assert_matches!( + component_extension, + "pyi" | "py" | "byi" | "by", "Extension must be `py`, `pyi`, `by`, or `byi`; got `{component_extension}`" ); } @@ -208,18 +211,25 @@ impl ModulePath { /// Get the `py.typed` info for this package (not considering parent packages) pub(super) fn py_typed(&self, resolver: &ResolverContext) -> PyTyped { - let Some(py_typed_contents) = self.to_system_path().and_then(|path| { + let Some(py_typed_file) = self.to_system_path().and_then(|path| { if !directory_contains_file(resolver.db, &path, &["py.typed"]) { return None; } let py_typed_path = path.join("py.typed"); - let py_typed_file = system_path_to_file(resolver.db, py_typed_path).ok()?; - // If we fail to read it let's say that's like it doesn't exist - // (right now the difference between Untyped and Full is academic) - py_typed_file.read_to_string(resolver.db).ok() + system_path_to_file(resolver.db, py_typed_path).ok() }) else { return PyTyped::Untyped; }; + + // Different module names revisit the same package. Share the tracked contents instead of + // reading its marker from disk again for every module resolution. + let py_typed_contents = source_text(resolver.db, py_typed_file); + // If we fail to read it let's say that's like it doesn't exist + // (right now the difference between Untyped and Full is academic) + if py_typed_contents.read_error().is_some() { + return PyTyped::Untyped; + } + // The python typing spec says to look for "partial\n" but in the wild we've seen: // // * PARTIAL\n @@ -693,8 +703,13 @@ impl SearchPath { matches!(&*self.0, SearchPathInner::SitePackages(_)) } + /// Is this search path provided by an editable installation? + pub fn is_editable(&self) -> bool { + matches!(&*self.0, SearchPathInner::Editable(_)) + } + /// Is it plausible that this search path contains third-party code? - pub fn can_contain_third_party_code(&self) -> bool { + pub(crate) fn can_contain_third_party_code(&self) -> bool { match &*self.0 { SearchPathInner::SitePackages(_) | SearchPathInner::Editable(_) @@ -708,7 +723,7 @@ impl SearchPath { /// basedpython: did this search path come from *installing* a distribution? /// - /// This is the narrow half of [`Self::can_contain_third_party_code`]. An extra search path + /// This is the narrow half of `can_contain_third_party_code`. An extra search path /// can hold either an installed package or code the project simply keeps elsewhere, so a /// diagnostic that talks about what a user has installed — telling them to `pip install` a /// stubs distribution, say — must not fire on one. diff --git a/crates/ty_module_resolver/src/resolve.rs b/crates/ty_module_resolver/src/resolve.rs index a859db5a0a..93661afc20 100644 --- a/crates/ty_module_resolver/src/resolve.rs +++ b/crates/ty_module_resolver/src/resolve.rs @@ -3,6 +3,7 @@ This module principally provides several routines for resolving a particular mod name to a `Module`: * [`file_to_module`][]: resolves the module `.` (often as the first step in resolving `.`) +* [`stub_file_to_real_module`][]: resolves the runtime module corresponding to a stub file * [`resolve_module`][]: resolves an absolute module name You may notice that we actually provide `resolve_(real)_(shadowable)_module_(confident)`. @@ -32,6 +33,7 @@ specifies ty's implementation of Python's import resolution algorithm. */ use std::borrow::Cow; +use std::fmt; use std::iter::FusedIterator; use rustc_hash::{FxBuildHasher, FxHashSet}; @@ -72,6 +74,18 @@ pub fn resolve_module<'db>( .or_else(|| desperately_resolve_module(db, importing_file.file(db), interned_name)) } +/// Resolves the module referenced by a `from` import statement. +/// +/// Returns `None` if the statement does not name a valid module or the module cannot be resolved. +pub fn resolve_module_for_import_from<'db>( + db: &'db dyn Db, + importing_file: ImportingFile<'db>, + import: &ast::StmtImportFrom, +) -> Option> { + let module_name = ModuleName::from_import_statement(db, importing_file, import).ok()?; + resolve_module(db, importing_file, &module_name) +} + /// Resolves a module name to a module, without desperate resolution available. /// /// This is appropriate for resolving a `KnownModule`, or cases where for whatever reason @@ -353,6 +367,31 @@ pub fn file_to_module<'db>( }) } +/// Resolves the runtime module corresponding to a stub file. +/// +/// Modules that are only available as stubs, including built-in modules, return `None`. +pub fn stub_file_to_real_module<'db>( + db: &'db dyn Db, + resolver_file: ResolverFile<'db>, +) -> Option> { + debug_assert!(resolver_file.file(db).is_stub(db)); + + let module = file_to_module(db, resolver_file)?; + // Built-in modules have no source file to find. Checking here also avoids a failed + // resolution attempt that would emit misleading logs. + if ruff_python_stdlib::sys::is_builtin_module(module.python_version(db).minor, module.name(db)) + { + return None; + } + // This lookup is equivalent to resolving `.` from the stub, so the stub is the correct + // importing file. + resolve_real_module( + db, + ImportingFile::ResolverFile(resolver_file), + module.name(db), + ) +} + fn file_to_module_impl<'db, 'a>( db: &'db dyn Db, resolver_file: ResolverFile<'db>, @@ -639,7 +678,7 @@ fn relative_desperate_search_paths( None } -#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] +#[derive(Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub struct SearchPaths { /// Search paths that have been statically determined purely from reading /// ty's configuration settings. These shouldn't ever change unless the @@ -666,6 +705,27 @@ pub struct SearchPaths { typeshed_versions: TypeshedVersions, } +impl fmt::Debug for SearchPaths { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + static_paths, + stdlib_path, + real_stdlib_path, + site_packages, + // Omit `typeshed_versions` because its debug representation spans thousands of lines, + // making even simple `Type` debug representations impractically large. + typeshed_versions: _, + } = self; + + f.debug_struct("SearchPaths") + .field("static_paths", static_paths) + .field("stdlib_path", stdlib_path) + .field("real_stdlib_path", real_stdlib_path) + .field("site_packages", site_packages) + .finish_non_exhaustive() + } +} + impl SearchPaths { /// Validate and normalize the raw settings given by the user /// into settings we can use for module resolution @@ -859,6 +919,14 @@ impl SearchPaths { .filter_map(|path| path.as_system_path()) } + /// Returns the configured roots for first-party modules. + pub fn first_party_roots(&self) -> impl Iterator { + self.static_paths + .iter() + .filter(|path| path.is_first_party()) + .filter_map(SearchPath::as_system_path) + } + /// Registers file roots for all non-dynamically discovered search paths. pub fn try_register_static_roots(&self, db: &dyn Db) { let files = db.files(); @@ -898,66 +966,41 @@ impl SearchPaths { } } -/// Collect all dynamic search paths. For each `site-packages` path: -/// - Collect that `site-packages` path -/// - Collect any search paths listed in `.pth` files in that `site-packages` directory -/// due to editable installations of third-party packages. +/// Returns the validated roots listed in the environment's `.pth` files. /// -/// The editable-install search paths for the first `site-packages` directory -/// should come between the two `site-packages` directories when it comes to -/// module-resolution priority. -#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] -pub(crate) fn dynamic_resolution_paths<'db>( +/// Unlike [`search_paths`], this includes editable roots that are also first-party search paths. +/// Those `.pth` entries still identify installed source trees, even though adding their paths to +/// module resolution a second time would be redundant. +pub fn editable_search_paths<'db>( db: &'db dyn Db, - mode: ModuleResolveModeIngredient<'db>, -) -> Vec { - tracing::debug!("Resolving dynamic module resolution paths"); - - let SearchPaths { - static_paths, - stdlib_path, - site_packages, - typeshed_versions: _, - real_stdlib_path, - } = mode.resolver_environment(db).search_paths(db); - - let mut dynamic_paths = Vec::new(); - - if site_packages.is_empty() { - return dynamic_paths; - } - - let mut existing_paths: FxHashSet<_> = static_paths + environment: ResolverEnvironment<'db>, +) -> impl Iterator { + site_packages_editables(db, environment) .iter() - .filter_map(|path| path.as_system_path()) - .map(Cow::Borrowed) - .collect(); + .flat_map(|paths| paths.editables.iter()) + .filter_map(SearchPath::as_system_path) +} - // Use the `ModuleResolveMode` to determine which stdlib (if any) to mark as existing - let stdlib = match mode.mode(db) { - ModuleResolveMode::Typing => stdlib_path, - ModuleResolveMode::Runtime | ModuleResolveMode::RuntimeSomeShadowingAllowed => { - real_stdlib_path - } - }; - if let Some(path) = stdlib.as_ref().and_then(SearchPath::as_system_path) { - existing_paths.insert(Cow::Borrowed(path)); - } +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +struct SitePackagesEditables { + site_packages: SearchPath, + editables: Box<[SearchPath]>, +} - let files = db.files(); +/// Discover editable roots without discarding entries that overlap static search paths. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +fn site_packages_editables<'db>( + db: &'db dyn Db, + environment: ResolverEnvironment<'db>, +) -> Box<[SitePackagesEditables]> { + let mut paths = Vec::new(); let system = db.system(); - for site_packages_search_path in site_packages { - let site_packages_dir = site_packages_search_path + for site_packages in &environment.search_paths(db).site_packages { + let site_packages_dir = site_packages .as_system_path() .expect("Expected site package path to be a system path"); - if !existing_paths.insert(Cow::Borrowed(site_packages_dir)) { - continue; - } - - dynamic_paths.push(site_packages_search_path.clone()); - // As well as modules installed directly into `site-packages`, // the directory may also contain `.pth` files. // Each `.pth` file in `site-packages` may contain one or more lines @@ -970,10 +1013,16 @@ pub(crate) fn dynamic_resolution_paths<'db>( tracing::warn!( "Failed to search for editable installation in {site_packages_dir}: {error}" ); + paths.push(SitePackagesEditables { + site_packages: site_packages.clone(), + editables: Box::default(), + }); continue; } }; + let mut editables = Vec::new(); + // The Python documentation specifies that `.pth` files in `site-packages` // are processed in alphabetical order. `DirectoryListing` is already sorted. // https://docs.python.org/3/library/site.html#module-site @@ -1013,39 +1062,95 @@ pub(crate) fn dynamic_resolution_paths<'db>( .canonicalize_path(&installation) .unwrap_or(installation); - if existing_paths.insert(Cow::Owned(installation.clone())) { - match SearchPath::editable(system, installation.clone()) { - Ok(search_path) => { - tracing::debug!( - "Adding editable installation to module resolution path {path}", - path = installation - ); - - // Register a file root for editable installs that are outside any other root - // (Most importantly, don't register a root for editable installations from the project - // directory as that would change the durability of files within those folders). - // Not having an exact file root for editable installs just means that - // some queries (like `list_modules_in`) will run slightly more frequently - // than they would otherwise. - if let Some(dynamic_path) = search_path.as_system_path() { - if files.root(db, dynamic_path).is_none() { - files.try_add_root(db, dynamic_path, FileRootKind::SearchPath); - } - } - - dynamic_paths.push(search_path); - } - - Err(error) => { - tracing::debug!("Skipping editable installation: {error}"); - } + match SearchPath::editable(system, installation) { + Ok(search_path) => editables.push(search_path), + Err(error) => { + tracing::debug!("Skipping editable installation: {error}"); } } } } + + paths.push(SitePackagesEditables { + site_packages: site_packages.clone(), + editables: editables.into_boxed_slice(), + }); } - dynamic_paths + paths.into_boxed_slice() +} + +/// Collect all dynamic search paths. For each `site-packages` path: +/// - Collect that `site-packages` path +/// - Collect any search paths listed in `.pth` files in that `site-packages` directory +/// due to editable installations of third-party packages. +/// +/// The editable-install search paths for the first `site-packages` directory +/// should come between the two `site-packages` directories when it comes to +/// module-resolution priority. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn dynamic_resolution_paths<'db>( + db: &'db dyn Db, + mode: ModuleResolveModeIngredient<'db>, +) -> Box<[SearchPath]> { + tracing::debug!("Resolving dynamic module resolution paths"); + + let environment = mode.resolver_environment(db); + let site_packages = site_packages_editables(db, environment); + if site_packages.is_empty() { + return Box::default(); + } + + let search_paths = environment.search_paths(db); + let mut existing_paths: FxHashSet<_> = search_paths + .static_paths + .iter() + .filter_map(SearchPath::as_system_path) + .collect(); + + if let Some(path) = search_paths + .stdlib(mode.mode(db)) + .and_then(SearchPath::as_system_path) + { + existing_paths.insert(path); + } + + let mut dynamic_paths = Vec::new(); + let files = db.files(); + + for paths in site_packages { + let site_packages_dir = paths + .site_packages + .as_system_path() + .expect("Expected site package path to be a system path"); + if !existing_paths.insert(site_packages_dir) { + continue; + } + dynamic_paths.push(paths.site_packages.clone()); + + for search_path in &paths.editables { + let Some(path) = search_path.as_system_path() else { + continue; + }; + if !existing_paths.insert(path) { + continue; + } + tracing::debug!("Adding editable installation to module resolution path {path}"); + + // Register a file root for editable installs that are outside any other root + // (Most importantly, don't register a root for editable installations from the project + // directory as that would change the durability of files within those folders). + // Not having an exact file root for editable installs just means that + // some queries (like `list_modules_in`) will run slightly more frequently + // than they would otherwise. + if files.root(db, path).is_none() { + files.try_add_root(db, path, FileRootKind::SearchPath); + } + dynamic_paths.push(search_path.clone()); + } + } + + dynamic_paths.into_boxed_slice() } /// Iterate over the available module-resolution search paths, @@ -2076,6 +2181,8 @@ mod tests { clippy::disallowed_methods, reason = "These are tests, so it's fine to do I/O by-passing System." )] + use std::assert_matches; + use ruff_db::Db; use ruff_db::files::{File, FilePath, system_path_to_file}; use ruff_db::system::{DbWithTestSystem as _, DbWithWritableSystem as _}; @@ -2424,8 +2531,8 @@ mod tests { resolve_module(&db, ImportingFile::File(importing_file, py311), &namespace).unwrap(); let py312_namespace = resolve_module(&db, ImportingFile::File(importing_file, py312), &namespace).unwrap(); - assert!(matches!(py311_namespace, Module::Namespace(_))); - assert!(matches!(py312_namespace, Module::Namespace(_))); + assert_matches!(py311_namespace, Module::Namespace(_)); + assert_matches!(py312_namespace, Module::Namespace(_)); assert_eq!(py311_namespace.python_version(&db), PythonVersion::PY311); assert_eq!(py312_namespace.python_version(&db), PythonVersion::PY312); assert_ne!(py311_namespace, py312_namespace); @@ -3346,6 +3453,10 @@ not_a_directory .unwrap(); assert!(resolve_module_confident(&db, &ModuleName::new_static("foo").unwrap()).is_some()); + assert_eq!( + editable_search_paths(&db, db.resolver_environment()).collect::>(), + [SystemPath::new("/x/src")] + ); let pth_path = site_packages.join("_editable.pth"); db.memory_file_system() @@ -3353,6 +3464,10 @@ not_a_directory .unwrap(); File::sync_path_only(&mut db, &pth_path); + assert_eq!( + editable_search_paths(&db, db.resolver_environment()).collect::>(), + [SystemPath::new("/y/src")] + ); assert!(resolve_module_confident(&db, &ModuleName::new_static("foo").unwrap()).is_none()); assert!(resolve_module_confident(&db, &ModuleName::new_static("bar").unwrap()).is_some()); } @@ -3434,6 +3549,35 @@ not_a_directory &&SearchPath::editable(db.system(), SystemPathBuf::from("/src")).unwrap() ) ); + assert_eq!( + editable_search_paths(&db, db.resolver_environment()).collect::>(), + [SystemPath::new("/src")] + ); + } + + #[test] + fn first_party_roots_exclude_dynamic_search_paths() { + let TestCase { db, src, .. } = TestCaseBuilder::new() + .with_src_files(&[("foo.py", "")]) + .with_site_packages_files(&[("_foo.pth", "/editable")]) + .build(); + db.memory_file_system() + .create_directory_all("/editable") + .expect("valid editable directory"); + + let all_paths: Vec<_> = + search_paths(&db, db.resolver_environment(), ModuleResolveMode::Typing).collect(); + assert!( + all_paths.contains( + &&SearchPath::editable(db.system(), SystemPathBuf::from("/editable")) + .expect("valid editable search path") + ) + ); + + assert_eq!( + db.search_paths().first_party_roots().collect::>(), + [&*src] + ); } #[test] diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 1e2633323a..de3fceeee7 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -62,8 +62,10 @@ tracing = { workspace = true } [dev-dependencies] ruff_db = { workspace = true, features = ["os", "testing"] } +ruff_python_trivia = { workspace = true } insta = { workspace = true, features = ["redactions", "ron"] } +tempfile = { workspace = true } [features] default = ["zstd"] @@ -79,6 +81,7 @@ schemars = [ zstd = ["ty_vendored/zstd"] junit = ["ruff_db/junit"] format = ["ruff_python_formatter"] +test-uv = [] testing = [] [lints] diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 1c0786c4f0..9f1d0d782e 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -6,8 +6,9 @@ use std::{cmp, fmt}; pub use self::changes::ChangeResult; use crate::CollectReporter; use crate::metadata::pyproject::PyProject; -use crate::metadata::script::script_metadata; use crate::metadata::settings::file_settings; +use crate::script::Script; +use crate::uv::UvEnvironments; use crate::{ProgressReporter, Project, ProjectChecker, ProjectMetadata}; use get_size2::StandardTracker; use ruff_db::Db as SourceDb; @@ -18,9 +19,11 @@ use ruff_db::system::System; use ruff_db::vendored::VendoredFileSystem; use ruff_ranged_value::ValueSource; use salsa::{Database, Event, Setter}; +use ty_module_resolver::system_module_search_paths; use ty_python_core::ProgramFile; use ty_python_core::program::{FallibleStrategy, MisconfigurationStrategy, UseDefaultStrategy}; use ty_python_semantic::dependencies::DependencyManifest; +use ty_python_semantic::dependency::DependencyMetadata; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{ AnalysisSettings, Db as SemanticDb, ExperimentalSettings, PythonVersionWithSource, @@ -32,6 +35,8 @@ mod changes; pub trait Db: SemanticDb { fn project(&self) -> Project; + fn uv_environments(&self) -> &UvEnvironments; + fn dyn_clone(&self) -> Box; /// The checker for the part of the project that is not Python, if one is @@ -41,6 +46,42 @@ pub trait Db: SemanticDb { } } +/// Returns the program to use for `file`. +/// +/// Scripts use their own program, and project files use the project program. For third-party files, +/// this chooses the most likely program. +fn program_file(db: &dyn Db, file: File) -> ProgramFile<'_> { + if let Some(script) = Script::for_file(db, file) { + return script.program(db).program_file(db, file); + } + + let project = db.project(); + let project_program = project.program(db); + let Some(path) = file.path(db).as_system_path() else { + return project_program.program_file(db, file); + }; + + if project.is_file_included(db, path).is_included() + || system_module_search_paths(db, project_program.resolver_environment(db)) + .any(|search_path| path.starts_with(search_path)) + { + return project_program.program_file(db, file); + } + + let program = project + .script_files(db) + .iter() + .filter_map(|script| Script::for_file(db, script)) + .map(|script| script.program(db)) + .find(|program| { + system_module_search_paths(db, program.resolver_environment(db)) + .any(|search_path| path.starts_with(search_path)) + }) + .unwrap_or(project_program); + + program.program_file(db, file) +} + /// Tracked so that a change to the open-file set only invalidates queries /// for files whose open state actually changed. #[salsa::tracked(heap_size=ruff_memory_usage::heap_size, returns(copy))] @@ -62,6 +103,7 @@ pub struct ProjectDatabase { // setters instead of swapping in a freshly constructed handle. project: Option, files: Files, + uv_environments: UvEnvironments, // IMPORTANT: Never return clones of `system` outside `ProjectDatabase` (only return references) // or the "trick" to get a mutable `Arc` in `Self::system_mut` is no longer guaranteed to work. @@ -103,6 +145,8 @@ impl ProjectDatabase { /// read immutable [`Project`] inputs, and every field on files created after this call. Existing /// files retain their durability. This must not be used by incremental consumers or checks that /// apply fixes. + /// + /// Initial script synchronization only updates `ScriptEnvironment` inputs, which remain mutable. pub fn freeze(&mut self) { self.project().freeze(self); self.files.freeze(); @@ -122,6 +166,7 @@ impl ProjectDatabase { where S: System + 'static + Send + Sync + RefUnwindSafe, { + let uv_environments = UvEnvironments::new(project_metadata.use_uv()); let mut db = Self { project: None, storage: salsa::Storage::new(if tracing::enabled!(tracing::Level::TRACE) { @@ -138,6 +183,7 @@ impl ProjectDatabase { None }), files: Files::default(), + uv_environments, system: Arc::new(system), checker: None, }; @@ -182,6 +228,9 @@ impl ProjectDatabase { /// Checks the files in the project and its dependencies as per the project's check mode. /// + /// Uses current settings and environments without starting or waiting for uv. Callers that + /// require synchronized environments must request synchronization and apply its results first. + /// /// Use [`set_check_mode`] to update the check mode. /// /// [`set_check_mode`]: ProjectDatabase::set_check_mode @@ -193,6 +242,8 @@ impl ProjectDatabase { /// Checks the files in the project and its dependencies, using the given reporter. /// + /// Uses the same environment synchronization behavior as [`check`](Self::check). + /// /// Use [`set_check_mode`] to update the check mode. /// /// [`set_check_mode`]: ProjectDatabase::set_check_mode @@ -200,6 +251,9 @@ impl ProjectDatabase { self.project().check(self, reporter); } + /// Checks `file` using its current settings and available environment. + /// + /// Uses the same environment synchronization behavior as [`check`](Self::check). #[tracing::instrument(level = "debug", skip(self))] pub fn check_file(&self, file: File) -> Vec { crate::check_file(self, file) @@ -352,137 +406,125 @@ fn bytes_to_mb(total: usize) -> f64 { impl SalsaMemoryDump { /// Returns a short report that provides total memory usage information. - pub fn display_short(&self) -> impl fmt::Display + '_ { - struct DisplayShort<'a>(&'a SalsaMemoryDump); - - impl fmt::Display for DisplayShort<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let SalsaMemoryDump { - total_fields, - total_metadata, - total_memo_fields, - total_memo_metadata, - ref ingredients, - ref memos, - } = *self.0; - - writeln!(f, "=======SALSA SUMMARY=======")?; + pub fn display_short(self) -> impl fmt::Display { + std::fmt::from_fn(move |f| { + let SalsaMemoryDump { + total_fields, + total_metadata, + total_memo_fields, + total_memo_metadata, + ref ingredients, + ref memos, + } = self; + + writeln!(f, "=======SALSA SUMMARY=======")?; + + writeln!( + f, + "TOTAL MEMORY USAGE: {:.2}MB", + bytes_to_mb( + total_metadata + total_fields + total_memo_fields + total_memo_metadata + ) + )?; + + writeln!( + f, + " struct metadata = {:.2}MB", + bytes_to_mb(total_metadata), + )?; + writeln!(f, " struct fields = {:.2}MB", bytes_to_mb(total_fields))?; + writeln!( + f, + " memo metadata = {:.2}MB", + bytes_to_mb(total_memo_metadata), + )?; + writeln!( + f, + " memo fields = {:.2}MB", + bytes_to_mb(total_memo_fields), + )?; + + writeln!(f, "QUERY COUNT: {}", memos.len())?; + writeln!(f, "STRUCT COUNT: {}", ingredients.len())?; - writeln!( - f, - "TOTAL MEMORY USAGE: {:.2}MB", - bytes_to_mb( - total_metadata + total_fields + total_memo_fields + total_memo_metadata - ) - )?; - - writeln!( - f, - " struct metadata = {:.2}MB", - bytes_to_mb(total_metadata), - )?; - writeln!(f, " struct fields = {:.2}MB", bytes_to_mb(total_fields))?; - writeln!( - f, - " memo metadata = {:.2}MB", - bytes_to_mb(total_memo_metadata), - )?; - writeln!( - f, - " memo fields = {:.2}MB", - bytes_to_mb(total_memo_fields), - )?; - - writeln!(f, "QUERY COUNT: {}", memos.len())?; - writeln!(f, "STRUCT COUNT: {}", ingredients.len())?; - - Ok(()) - } - } - - DisplayShort(self) + Ok(()) + }) } /// Returns a short report that provides fine-grained memory usage information per /// Salsa ingredient. - pub fn display_full(&self) -> impl fmt::Display + '_ { - struct DisplayFull<'a>(&'a SalsaMemoryDump); - - impl fmt::Display for DisplayFull<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let SalsaMemoryDump { - total_fields, - total_metadata, - total_memo_fields, - total_memo_metadata, - ref ingredients, - ref memos, - } = *self.0; - - writeln!(f, "=======SALSA STRUCTS=======")?; - - for ingredient in ingredients { - let size_of_fields = - ingredient.size_of_fields() + ingredient.heap_size_of_fields().unwrap_or(0); - - writeln!( - f, - "{:<50} metadata={:<8} fields={:<8} count={}", - format!("`{}`", ingredient.debug_name()), - format!("{:.2}MB", bytes_to_mb(ingredient.size_of_metadata())), - format!("{:.2}MB", bytes_to_mb(size_of_fields)), - ingredient.count() - )?; - } - - writeln!(f, "=======SALSA QUERIES=======")?; + pub fn display_full(self) -> impl fmt::Display { + std::fmt::from_fn(move |f| { + let SalsaMemoryDump { + total_fields, + total_metadata, + total_memo_fields, + total_memo_metadata, + ref ingredients, + ref memos, + } = self; + + writeln!(f, "=======SALSA STRUCTS=======")?; + + for ingredient in ingredients { + let size_of_fields = + ingredient.size_of_fields() + ingredient.heap_size_of_fields().unwrap_or(0); - for (query_fn, memo) in memos { - let size_of_fields = - memo.size_of_fields() + memo.heap_size_of_fields().unwrap_or(0); - - writeln!(f, "`{query_fn} -> {}`", memo.debug_name())?; - - writeln!( - f, - " metadata={:<8} fields={:<8} count={}", - format!("{:.2}MB", bytes_to_mb(memo.size_of_metadata())), - format!("{:.2}MB", bytes_to_mb(size_of_fields)), - memo.count() - )?; - } - - writeln!(f, "=======SALSA SUMMARY=======")?; writeln!( f, - "TOTAL MEMORY USAGE: {:.2}MB", - bytes_to_mb( - total_metadata + total_fields + total_memo_fields + total_memo_metadata - ) + "{:<50} metadata={:<8} fields={:<8} count={}", + format!("`{}`", ingredient.debug_name()), + format!("{:.2}MB", bytes_to_mb(ingredient.size_of_metadata())), + format!("{:.2}MB", bytes_to_mb(size_of_fields)), + ingredient.count() )?; + } + + writeln!(f, "=======SALSA QUERIES=======")?; + + for (query_fn, memo) in memos { + let size_of_fields = + memo.size_of_fields() + memo.heap_size_of_fields().unwrap_or(0); + + writeln!(f, "`{query_fn} -> {}`", memo.debug_name())?; writeln!( f, - " struct metadata = {:.2}MB", - bytes_to_mb(total_metadata), - )?; - writeln!(f, " struct fields = {:.2}MB", bytes_to_mb(total_fields))?; - writeln!( - f, - " memo metadata = {:.2}MB", - bytes_to_mb(total_memo_metadata), - )?; - writeln!( - f, - " memo fields = {:.2}MB", - bytes_to_mb(total_memo_fields), + " metadata={:<8} fields={:<8} count={}", + format!("{:.2}MB", bytes_to_mb(memo.size_of_metadata())), + format!("{:.2}MB", bytes_to_mb(size_of_fields)), + memo.count() )?; - - Ok(()) } - } - DisplayFull(self) + writeln!(f, "=======SALSA SUMMARY=======")?; + writeln!( + f, + "TOTAL MEMORY USAGE: {:.2}MB", + bytes_to_mb( + total_metadata + total_fields + total_memo_fields + total_memo_metadata + ) + )?; + + writeln!( + f, + " struct metadata = {:.2}MB", + bytes_to_mb(total_metadata), + )?; + writeln!(f, " struct fields = {:.2}MB", bytes_to_mb(total_fields))?; + writeln!( + f, + " memo metadata = {:.2}MB", + bytes_to_mb(total_memo_metadata), + )?; + writeln!( + f, + " memo fields = {:.2}MB", + bytes_to_mb(total_memo_fields), + )?; + + Ok(()) + }) } /// Serializes the memory dump to JSON. @@ -566,11 +608,14 @@ impl SemanticDb for ProjectDatabase { } fn program_file(&self, file: File) -> ProgramFile<'_> { - self.project().program(self).program_file(self, file) + program_file(self, file) } - fn python_version_with_source(&self, _file: File) -> &PythonVersionWithSource { - &self.project().program_settings(self).python_version + fn python_version_with_source(&self, file: File) -> &PythonVersionWithSource { + match Script::for_file(self, file) { + None => &self.project().program_settings(self).python_version, + Some(script) => script.python_version_with_source(self), + } } fn rule_selection(&self, file: File) -> &RuleSelection { @@ -591,6 +636,18 @@ impl SemanticDb for ProjectDatabase { self.project().settings(self).experimental() } + fn dependency_metadata(&self, file: File) -> Option<&DependencyMetadata> { + if let Some(script) = Script::for_file(self, file) { + return script.dependency_metadata(self).as_ref().ok()?.as_deref(); + } + + self.project() + .dependency_metadata(self) + .as_ref() + .ok()? + .as_deref() + } + fn verbose(&self) -> bool { self.project().verbose(self) } @@ -663,6 +720,10 @@ impl Db for ProjectDatabase { self.project.unwrap() } + fn uv_environments(&self) -> &UvEnvironments { + &self.uv_environments + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -709,10 +770,14 @@ pub(crate) mod testing { #[cfg(any(test, feature = "testing"))] use ty_python_semantic::ProgramEnvironment; use ty_python_semantic::dependencies::DependencyManifest; + use ty_python_semantic::dependency::DependencyMetadata; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{AnalysisSettings, ExperimentalSettings, PythonVersionWithSource}; use crate::db::Db; + use crate::metadata::settings::file_settings; + use crate::script::Script; + use crate::uv::UvEnvironments; use crate::{Project, ProjectMetadata}; type Events = Arc>>; @@ -723,6 +788,7 @@ pub(crate) mod testing { storage: salsa::Storage, events: Events, files: Files, + uv_environments: UvEnvironments, system: TestSystem, vendored: VendoredFileSystem, project: Option, @@ -734,7 +800,7 @@ pub(crate) mod testing { /// The transpiler builds one of these per file it converts, so this is not /// only a test fixture. Recording every salsa event costs a mutex and a push /// per event and retains them all in an unbounded `Vec`, which is a large - /// price for something only [`Self::with_salsa_events`]'s callers read. + /// price for something only `with_salsa_events`'s callers read. /// Worse, it turns a query that fails to converge into an out-of-memory kill /// rather than a slow one, which hides what actually went wrong. pub fn new(project: ProjectMetadata) -> Self { @@ -743,13 +809,17 @@ pub(crate) mod testing { /// A database that records every salsa event, for tests that assert on which /// queries ran. Read the events back with [`Self::take_salsa_events`]. - #[cfg(any(test, feature = "testing"))] - pub fn with_salsa_events(project: ProjectMetadata) -> Self { + /// + /// Only this crate's own tests assert on the event stream, so unlike the rest of + /// [`TestDb`] this is not part of what the `testing` feature hands to other crates. + #[cfg(test)] + pub(crate) fn with_salsa_events(project: ProjectMetadata) -> Self { Self::build(project, true) } fn build(project: ProjectMetadata, record_events: bool) -> Self { let events = Events::default(); + let uv_environments = UvEnvironments::new(project.use_uv()); let mut db = Self { storage: salsa::Storage::new(record_events.then(|| { let events = events.clone(); @@ -761,6 +831,7 @@ pub(crate) mod testing { system: TestSystem::default(), vendored: ty_vendored::file_system().clone(), files: Files::default(), + uv_environments, events, project: None, }; @@ -853,7 +924,7 @@ pub(crate) mod testing { // the project has to carry these too: `Project::program` rebuilds the program // from the project's own settings, so a query that asks the project rather // than a file would otherwise see no site-packages at all - Program::from_settings(self, settings.clone()); + Program::from_settings(self, &settings); self.project().update_program(self, settings); } @@ -931,11 +1002,14 @@ pub(crate) mod testing { #[salsa::db] impl ty_python_semantic::Db for TestDb { fn program_file(&self, file: File) -> ProgramFile<'_> { - self.project().program(self).program_file(self, file) + super::program_file(self, file) } - fn python_version_with_source(&self, _file: File) -> &PythonVersionWithSource { - &self.project().program_settings(self).python_version + fn python_version_with_source(&self, file: File) -> &PythonVersionWithSource { + match Script::for_file(self, file) { + None => &self.project().program_settings(self).python_version, + Some(script) => script.python_version_with_source(self), + } } #[inline] @@ -943,16 +1017,28 @@ pub(crate) mod testing { crate::check_file(self, file) } - fn rule_selection(&self, _file: ruff_db::files::File) -> &RuleSelection { - self.project().rules(self) + fn rule_selection(&self, file: ruff_db::files::File) -> &RuleSelection { + file_settings(self, file).rules(self) } fn lint_registry(&self) -> &LintRegistry { ty_python_semantic::default_lint_registry() } - fn analysis_settings(&self, _file: ruff_db::files::File) -> &AnalysisSettings { - self.project().settings(self).analysis() + fn analysis_settings(&self, file: ruff_db::files::File) -> &AnalysisSettings { + file_settings(self, file).analysis(self) + } + + fn dependency_metadata(&self, file: File) -> Option<&DependencyMetadata> { + if let Some(script) = Script::for_file(self, file) { + return script.dependency_metadata(self).as_ref().ok()?.as_deref(); + } + + self.project() + .dependency_metadata(self) + .as_ref() + .ok()? + .as_deref() } fn experimental_settings(&self) -> &ExperimentalSettings { @@ -988,6 +1074,10 @@ pub(crate) mod testing { self.project.unwrap() } + fn uv_environments(&self) -> &UvEnvironments { + &self.uv_environments + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -1000,11 +1090,108 @@ pub(crate) mod testing { #[cfg(test)] mod tests { use ruff_db::Db as _; - use ruff_db::files::FileRootKind; - use ruff_db::system::{SystemPathBuf, TestSystem}; + use ruff_db::files::{FileRootKind, system_path_to_file}; + use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf, TestSystem}; + use ruff_db::testing::assert_function_query_was_not_run_by_name; + use ruff_python_trivia::textwrap::dedent; use ty_module_resolver::list_modules; + use ty_python_semantic::Db as _; - use crate::{Db as _, ProjectDatabase, ProjectMetadata}; + use crate::db::testing::TestDb; + use crate::watch::ChangeEvent; + use crate::{Db, ProjectDatabase, ProjectMetadata, UseUv}; + + #[test] + fn checks_use_available_script_environment_without_running_uv() -> anyhow::Result<()> { + let system = TestSystem::default(); + let root = SystemPathBuf::from("/project"); + system.memory_file_system().write_file_all( + root.join("script.py"), + dedent( + r" + # /// script + # dependencies = [] + # /// + import nonexistent_script_dependency + ", + ) + .as_ref(), + )?; + let metadata = ProjectMetadata::discover(&root, &system)?.with_use_uv(UseUv::Scripts); + let db = ProjectDatabase::fallible(metadata, system)?; + let file = system_path_to_file(&db, root.join("script.py"))?; + + // This system cannot run commands. Analysis still reports the missing import using its + // available configuration; preparing the environment is the host's responsibility. + let diagnostics = db.check(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id().as_str(), "unresolved-import"); + let diagnostics = db.check_file(file); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id().as_str(), "unresolved-import"); + + Ok(()) + } + + #[test] + fn changed_script_metadata_updates_import_resolution() -> anyhow::Result<()> { + let system = TestSystem::default(); + let fs = system.memory_file_system().clone(); + let root = SystemPathBuf::from("/project"); + let script = root.join("script.py"); + let ordinary = "import dependency"; + fs.write_files_all([ + (script.clone(), ordinary), + (SystemPathBuf::from("/external/dependency.py"), ""), + ])?; + let metadata = ProjectMetadata::discover(&root, &system)?; + let mut db = ProjectDatabase::fallible(metadata, system)?; + assert_eq!(db.check().len(), 1); + + fs.write_file_all( + &script, + dedent( + r" + # /// script + # [tool.ty.environment] + # extra-paths = ['../external'] + # /// + import dependency + ", + ) + .as_ref(), + )?; + db.apply_changes(&[ChangeEvent::file_content_changed(script.clone())]); + assert!(db.check().is_empty()); + + fs.write_file_all(&script, ordinary)?; + db.apply_changes(&[ChangeEvent::file_content_changed(script)]); + assert_eq!(db.check().len(), 1); + + Ok(()) + } + + // Without uv metadata or an enabled dependency rule, checking settings and imports + // should not query dependency metadata. + #[test] + fn dependency_metadata_isnt_queried_unnecessarily() -> anyhow::Result<()> { + let root = SystemPathBuf::from("/project"); + let mut db = TestDb::new(ProjectMetadata::new("app", root.clone())); + db.write_file( + root.join("main.py"), + "import typing\nfrom typing import Any\n", + )?; + let file = system_path_to_file(&db, root.join("main.py"))?; + + assert!(db.project().check_settings(&db).is_empty()); + assert!(db.check_file(file).is_empty()); + let events = db.take_salsa_events(); + for query in ["missing_direct_dependency", "Project::dependency_metadata_"] { + assert_function_query_was_not_run_by_name(&db, query, None, &events); + } + + Ok(()) + } #[test] fn frozen_inputs_support_a_one_shot_check() -> anyhow::Result<()> { @@ -1092,7 +1279,14 @@ mod tests { /// the project's manifest. #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] fn script_dependency_manifest(db: &dyn Db, file: File) -> Option { - script_metadata(db, file).as_ref()?.dependency_manifest() + let tag = crate::script::script_tag(db, file)?; + let path = file.path(db).as_system_path()?; + PyProject::from_toml_str_without_spans( + tag.metadata(), + ValueSource::File(Arc::new(path.to_path_buf())), + ) + .ok()? + .dependency_manifest() } /// What the project's own `pyproject.toml` declares it depends on. @@ -1125,5 +1319,5 @@ fn project_declares_conformances(db: &dyn Db, project: Project) -> bool { project .files(db) .iter() - .any(|file| *ty_python_semantic::declares_conformances(db, *file)) + .any(|file| *ty_python_semantic::declares_conformances(db, file)) } diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index 325ad0f515..0c1dc7ed33 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -1,5 +1,6 @@ use crate::db::{Db, ProjectDatabase}; use crate::metadata::CONFIG_FILE_NAMES; +use crate::script::script_tag; use crate::watch::{ChangeEvent, CreatedKind, DeletedKind}; use crate::{ProjectMetadata, ProjectReloadResult}; use std::collections::BTreeSet; @@ -15,7 +16,9 @@ use ty_python_core::program::FallibleStrategy; /// Represents the result of applying changes to the project database. pub struct ChangeResult { project_changed: bool, + project_sync_path: Option, custom_stdlib_changed: bool, + changed_files: ChangedFiles, } impl ChangeResult { @@ -24,14 +27,76 @@ impl ChangeResult { self.project_changed } + /// The directory whose uv project metadata needs refreshing, if any. + /// + /// This may be an ancestor of the previous project root if that directory was deleted. + pub fn project_sync_path(&self) -> Option<&SystemPath> { + self.project_sync_path.as_deref() + } + /// Returns `true` if the custom stdlib's VERSIONS file has changed. pub fn custom_stdlib_changed(&self) -> bool { self.custom_stdlib_changed } + + /// Returns the scripts whose environments may need synchronization after these file events. + /// + /// Returns no scripts if the project was unindexed when the changes were applied. + /// Otherwise, only includes scripts in [`crate::Project::files`], reindexing if needed. + /// + /// The result may include scripts with unsaved changes to their PEP 723 metadata. + /// Callers must defer environment synchronization until those changes are saved: + /// uv reads the file from disk, not the editor buffer. + pub fn scripts_to_synchronize(&self, db: &dyn Db) -> Vec { + match &self.changed_files { + ChangedFiles::Unindexed => Vec::new(), + ChangedFiles::Known(changed_files) => { + if changed_files.is_empty() { + return Vec::new(); + } + + let indexed = db.project().files(db); + changed_files + .intersection(indexed.scripts()) + .copied() + .collect() + } + ChangedFiles::Unknown => db.project().files(db).scripts().iter().copied().collect(), + } + } +} + +enum ChangedFiles { + /// The project was unindexed when the changes were applied. + Unindexed, + /// The set of files that were created, opened, or modified. This set may be empty. + /// + /// For example, editing `main.py` includes that file. Files excluded by path or ignore rules + /// are not listed. + /// + /// The project's files are indexed and reflect these changes when + /// [`ProjectDatabase::apply_changes`] returns. + Known(FxHashSet), + /// The project was indexed, but the set of changed files is unknown. + /// + /// For example, a directory event may represent many new files, or editing `.gitignore` + /// or `src.exclude` may change which files belong to the project. + Unknown, +} + +impl ChangedFiles { + fn mark_unknown(&mut self) { + if matches!(self, Self::Known(_)) { + *self = Self::Unknown; + } + } } impl ProjectDatabase { - #[tracing::instrument(level = "debug", skip(self, changes))] + /// Applies file changes to the database. + /// + /// Any required uv synchronization is returned in [`ChangeResult`] for the caller to schedule. + #[tracing::instrument(level = "debug", skip_all)] pub fn apply_changes(&mut self, changes: &[ChangeEvent]) -> ChangeResult { let project = self.project(); let project_root = project.root(self).to_path_buf(); @@ -43,7 +108,13 @@ impl ProjectDatabase { let mut result = ChangeResult { project_changed: false, + project_sync_path: None, custom_stdlib_changed: false, + changed_files: if project.file_set(self).is_lazy() { + ChangedFiles::Unindexed + } else { + ChangedFiles::Known(FxHashSet::default()) + }, }; // Paths whose project files should be discovered incrementally. let mut added_paths = BTreeSet::default(); @@ -106,6 +177,7 @@ impl ProjectDatabase { ); removed_paths.insert(directory.to_path_buf()); + result.changed_files.mark_unknown(); if self.system().path_exists(directory) { added_paths.insert(directory.to_path_buf()); @@ -128,31 +200,45 @@ impl ProjectDatabase { } match change { - ChangeEvent::Changed { path, kind: _ } | ChangeEvent::Opened(path) => { - if synced_files.insert(path.to_path_buf()) { - File::sync_path_only(self, path); + ChangeEvent::Changed { path, .. } + | ChangeEvent::Opened(path) + | ChangeEvent::Created { path, .. } => { + if matches!(change, ChangeEvent::Created { .. }) { + file_system_changed = true; } - } - ChangeEvent::Created { kind, path } => { - file_system_changed = true; - - match kind { - CreatedKind::File => { + match change { + ChangeEvent::Changed { .. } => { + if synced_files.insert(path.to_path_buf()) { + File::sync_path_only(self, path); + } + } + ChangeEvent::Opened(_) + | ChangeEvent::Created { + kind: CreatedKind::File, + .. + } => { if synced_files.insert(path.to_path_buf()) { File::sync_path(self, path); } } - CreatedKind::Directory | CreatedKind::Any => { + _ => { sync_recursively.insert(path.clone()); } } - // A created file can be indexed directly unless project indexing needs the - // walker to apply ignore-file semantics. The ignore check below skips that - // walk when the path is ignored. if !project.file_set(self).is_lazy() { - if self.system().is_file(path) { + // A `Changed` event only updates known files. Opening or creating a file can + // introduce a new one, but only after it passes the filters below. + let is_file = if change.is_changed() { + self.files() + .try_system(self, path) + .is_some_and(|file| file.exists(self)) + } else { + self.system().is_file(path) + }; + + if is_file { if !project .is_file_included(self, path) .should_index_file(self.system(), path) @@ -165,9 +251,26 @@ impl ProjectDatabase { .is_none_or(|ignore_files| !ignore_files.is_ignored(path, false)) && let Ok(file) = system_path_to_file(self, path) { - project.add_file(self, file); + let is_script = script_tag(self, file).is_some(); + // Explicitly included files are checked even when scripts are otherwise excluded. + let exclude_script = is_script + && project.settings(self).src().exclude_scripts + && !project.is_file_explicitly_included(self, file); + + if exclude_script { + project.remove_file(self, file); + } else { + project.add_file(self, file, is_script); + } + + if let ChangedFiles::Known(changed_files) = + &mut result.changed_files + { + changed_files.insert(file); + } } - } else if project.is_directory_included(self, path) + } else if change.is_created() + && project.is_directory_included(self, path) && ignore_files .as_mut() .is_none_or(|ignore_files| !ignore_files.is_ignored(path, true)) @@ -175,6 +278,7 @@ impl ProjectDatabase { // Unlike a new file, a new directory needs walking to discover // project files that exist below it. added_paths.insert(path.clone()); + result.changed_files.mark_unknown(); } } } @@ -253,76 +357,41 @@ impl ProjectDatabase { } if reload_project { - let new_project_metadata = project.metadata(self).rediscover(self.system()); - match new_project_metadata { - Ok(mut metadata) => { - if let Err(error) = metadata.apply_configuration_files(self.system()) { + // The active project root may have been deleted. Start rediscovery from the closest + // existing ancestor so ty can fall back to an enclosing project. + let path = project_root + .ancestors() + .find(|path| self.system().is_directory(path)) + .unwrap_or(&project_root); + let metadata = project.metadata(self); + if metadata.use_uv().workspace_discovery_enabled() + && metadata.config_file_override().is_none() + { + result.project_sync_path = Some(path.to_path_buf()); + } else { + // We're not refreshing uv metadata, so use the existing environment. + let environment = metadata.environment().clone(); + match project.rediscover(self, path, environment) { + Ok(ProjectReloadResult::Unchanged) => {} + Ok(ProjectReloadResult::Changed { files_changed }) => { + result.project_changed = true; + result.changed_files.mark_unknown(); + if files_changed { + // The project file set has been invalidated; continuing would + // run incremental discovery from paths collected before the reload. + return result; + } + } + Err(error) => { let error = anyhow::Error::new(error); tracing::error!( - "Failed to apply configuration files, \ - continuing without applying them: {error:#}" + "Failed to load project, keeping old project configuration: {error:#}" ); - } - - metadata.try_add_project_root(self); - let merged_options = metadata.to_merged_options(); - - let program_settings_diagnostics = match merged_options.to_program_settings( - self.system(), - self.vendored(), - &FallibleStrategy, - ) { - Ok((program_settings, diagnostics)) => { - project.update_program(self, program_settings); - diagnostics - } - Err(error) => { - tracing::error!( - "Failed to convert metadata to program settings, \ - continuing without applying them: {error}" - ); - Vec::new() + if reload_project_files { + project.reload_files(self); + result.changed_files.mark_unknown(); + return result; } - }; - - let (settings, mut settings_diagnostics) = - match merged_options.to_settings(self, &FallibleStrategy) { - Ok((settings, diagnostics)) => (Some(settings), diagnostics), - Err(error) => { - tracing::warn!( - "Keeping old project configuration because loading the new \ - settings failed with: {error}" - ); - (None, vec![error.into_diagnostic()]) - } - }; - settings_diagnostics.extend( - program_settings_diagnostics - .into_iter() - .map(|diagnostic| diagnostic.into_diagnostic(self)), - ); - - tracing::debug!("Reloading project after structural change"); - match project.reload(self, metadata, settings, settings_diagnostics) { - ProjectReloadResult::Unchanged => {} - ProjectReloadResult::Changed { files_changed } => { - result.project_changed = true; - if files_changed { - // The project file set has already been rebuilt; continuing would - // run incremental discovery from paths collected before the reload. - return result; - } - } - } - } - Err(error) => { - let error = anyhow::Error::new(error); - tracing::error!( - "Failed to load project, keeping old project configuration: {error:#}" - ); - if reload_project_files { - project.reload_files(self); - return result; } } } @@ -330,6 +399,7 @@ impl ProjectDatabase { if reload_project_files { project.reload_files(self); + result.changed_files.mark_unknown(); // A full project-file reload supersedes incremental project-file updates. added_paths.clear(); removed_paths.clear(); @@ -371,7 +441,7 @@ impl ProjectDatabase { let (files, diagnostics) = walker.collect_vec(self); for file in files { - project.add_file(self, file); + project.add_file(self, file.file, file.is_script); } diagnostics diff --git a/crates/ty_project/src/files.rs b/crates/ty_project/src/files.rs index b0887130c9..5638ac0450 100644 --- a/crates/ty_project/src/files.rs +++ b/crates/ty_project/src/files.rs @@ -1,9 +1,8 @@ use std::marker::PhantomData; -use std::ops::Deref; use std::sync::Arc; use parking_lot::{Mutex, MutexGuard}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxBuildHasher, FxHashSet}; use salsa::{Durability, Setter}; use ruff_db::diagnostic::Diagnostic; @@ -147,11 +146,24 @@ impl<'db> LazyFiles<'db> { /// Sets the indexed files of a package to `files`. pub(super) fn set( mut self, - files: FxHashSet, + files: Vec, diagnostics: Vec, ) -> Indexed<'db> { + let mut inner = IndexedInner { + files: FxHashSet::with_capacity_and_hasher(files.len(), FxBuildHasher), + scripts: FxHashSet::default(), + diagnostics, + }; + for IndexedFile { file, is_script } in files { + inner.files.insert(file); + if is_script { + inner.scripts.insert(file); + } + } + inner.files.shrink_to_fit(); + inner.scripts.shrink_to_fit(); let files = Indexed { - inner: Arc::new(IndexedInner { files, diagnostics }), + inner: Arc::new(inner), _lifetime: PhantomData, }; *self.files = State::Indexed(Arc::clone(&files.inner)); @@ -159,6 +171,12 @@ impl<'db> LazyFiles<'db> { } } +/// A file classified by the walker without resolving its script settings or environment. +pub(crate) struct IndexedFile { + pub(crate) file: File, + pub(crate) is_script: bool, +} + /// The indexed files of the project. /// /// Note: This type is intentionally non-cloneable. Making it cloneable requires @@ -173,10 +191,23 @@ pub struct Indexed<'db> { #[derive(Debug, get_size2::GetSize)] struct IndexedInner { files: FxHashSet, + scripts: FxHashSet, diagnostics: Vec, } impl Indexed<'_> { + pub fn iter(&self) -> impl Iterator { + self.inner.files.iter().copied() + } + + pub fn contains(&self, file: File) -> bool { + self.inner.files.contains(&file) + } + + pub fn is_empty(&self) -> bool { + self.inner.files.is_empty() + } + pub(super) fn diagnostics(&self) -> &[Diagnostic] { &self.inner.diagnostics } @@ -184,13 +215,9 @@ impl Indexed<'_> { pub(super) fn len(&self) -> usize { self.inner.files.len() } -} -impl Deref for Indexed<'_> { - type Target = FxHashSet; - - fn deref(&self) -> &Self::Target { - &self.inner.files + pub(super) fn scripts(&self) -> &FxHashSet { + &self.inner.scripts } } @@ -208,8 +235,7 @@ impl<'a> IntoIterator for &'a Indexed<'_> { /// A Mutable view of a project's indexed files. /// /// Allows in-place mutation of the files without deep cloning the hash set. -/// The changes are written back when the mutable view is dropped or by calling -/// [`Self::set_diagnostics`] manually. +/// The changes are written back when the mutable view is dropped. pub(super) struct IndexedMut<'db> { db: Option<&'db mut dyn Db>, project: Project, @@ -218,17 +244,20 @@ pub(super) struct IndexedMut<'db> { } impl IndexedMut<'_> { - pub(super) fn insert(&mut self, file: File) -> bool { - if self.inner_mut().files.insert(file) { - self.did_change = true; - true + pub(super) fn insert(&mut self, file: File, is_script: bool) { + let inner = self.inner_mut(); + let file_added = inner.files.insert(file); + let script_changed = if is_script { + inner.scripts.insert(file) } else { - false - } + inner.scripts.remove(&file) + }; + self.did_change |= file_added || script_changed; } pub(super) fn remove(&mut self, file: File) -> bool { if self.inner_mut().files.remove(&file) { + self.inner_mut().scripts.remove(&file); self.did_change = true; true } else { @@ -272,15 +301,14 @@ impl Drop for IndexedMut<'_> { #[cfg(test)] mod tests { + use std::assert_matches; use std::time::{Duration, Instant}; - use rustc_hash::FxHashSet; use salsa::{Database, Durability, EventKind}; use crate::ProjectMetadata; use crate::db::Db; use crate::db::testing::TestDb; - use crate::files::Index; use ruff_db::files::system_path_to_file; use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf}; @@ -289,33 +317,22 @@ mod tests { let metadata = ProjectMetadata::new("test", SystemPathBuf::from("/test")); let mut db = TestDb::new(metadata); - db.write_file("test.py", "")?; + db.write_file("/test/test.py", "")?; let project = db.project(); - 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::Indexed(files) => files, - }; + let file = system_path_to_file(&db, "/test/test.py")?; + let files = project.files(&db); + assert!(files.contains(file)); // Calling files a second time should not dead-lock. // This can e.g. happen when `check_file` iterates over all files and // `should_check_file` queries the open files. - let files_2 = project.file_set(&db).get(); - - match files_2 { - Index::Lazy(_) => { - panic!("Expected indexed files, got lazy files"); - } - Index::Indexed(files_2) => { - assert_eq!( - files_2.iter().collect::>(), - files.iter().collect::>() - ); - } - } + let files_2 = project.files(&db); + assert_eq!( + files_2.iter().collect::>(), + files.iter().collect::>() + ); Ok(()) } @@ -352,11 +369,9 @@ mod tests { } Err(cancelled) => cancelled, }; - assert!( - matches!( - cancelled.downcast_ref::(), - Some(salsa::Cancelled::PendingWrite) - ), + assert_matches!( + cancelled.downcast_ref::(), + Some(salsa::Cancelled::PendingWrite), "file indexing did not propagate the salsa cancellation" ); diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index 5a0c43635e..e8c1992002 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -5,6 +5,7 @@ use crate::glob::{GlobFilterCheckMode, IncludeResult}; use crate::metadata::options::OptionDiagnostic; use crate::parallel::ParallelIteratorExt; +use crate::script::Script; use crate::walk::{ProjectFilesFilter, ProjectFilesWalker}; #[cfg(feature = "testing")] pub use db::testing::TestDb; @@ -15,28 +16,34 @@ use metadata::settings::Settings; pub use metadata::{ProjectMetadata, ProjectMetadataError}; use rayon::prelude::*; use ruff_db::diagnostic::{ - Diagnostic, DiagnosticId, Severity, SubDiagnostic, SubDiagnosticSeverity, + Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, SubDiagnosticSeverity, }; -use ruff_db::files::File; +use ruff_db::files::{File, system_path_to_file}; use ruff_db::parsed::parsed_module; use ruff_db::system::{SystemPath, SystemPathBuf, deduplicate_nested_paths}; use rustc_hash::FxHashSet; use salsa::{Database, Durability, Setter}; +pub use script::script_tag; use std::backtrace::BacktraceStatus; use std::collections::{BTreeSet, hash_set}; use std::iter::FusedIterator; use std::panic::{AssertUnwindSafe, UnwindSafe}; use std::sync::Arc; use ty_python_core::ProgramFile; -use ty_python_core::program::{Program, ProgramSettings}; +use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; pub use ty_python_semantic::Db as SemanticDb; +use ty_python_semantic::dependency::{DependencyMetadata, DependencyProjectKind}; use ty_python_semantic::lint::RuleSelection; +use uv::DependencyMetadataError; +pub use uv::{ScriptEnvironmentAvailability, UseUv, UvEnvironments, UvSyncChanges}; mod db; mod files; pub mod glob; pub mod metadata; pub mod parallel; +mod script; +pub mod uv; mod walk; pub mod watch; @@ -208,6 +215,30 @@ pub trait ProgressReporter: Send + Sync { fn report_diagnostics(&mut self, db: &ProjectDatabase, diagnostics: Vec); } +/// An owned progress reporter for a project or standalone-script uv metadata request. +/// +/// The worker calls [`Self::started`] and [`Self::finished`] around each uv invocation. The reporter +/// stays alive across rescheduled requests. The host calls [`Self::completed`] after handling the +/// final result, or drops the reporter if the request is abandoned. +/// Background synchronization may move the reporter between threads and outlive the operation that +/// scheduled it. Implementations must not retain a database because doing so could keep a cancelled +/// database snapshot alive until synchronization finishes. +pub trait UvSyncProgress: Send { + /// Called immediately before running uv. Cancelled queued requests do not call this method. + fn started(&mut self) {} + + /// Called when uv returns, including when it returns an error. + fn finished(&mut self) {} + + /// Called after the final synchronization result is handled, including errors. + /// Requests that are rescheduled keep their reporter without completing it. + fn completed(self: Box) {} +} + +/// Creates progress reporting when a project metadata refresh is scheduled. +pub type ProjectSyncProgressFactory<'a> = + dyn Fn(&dyn Db, Project) -> Option> + 'a; + /// Reporter that collects all diagnostics into a `Vec`. #[derive(Default)] pub struct CollectReporter(std::sync::Mutex>); @@ -306,7 +337,55 @@ impl Project { #[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] pub fn program(self, db: &dyn Db) -> Program<'_> { - Program::from_settings(db, self.program_settings(db).clone()) + Program::from_settings(db, self.program_settings(db)) + } + + /// Extract dependency information once per metadata update. Unrelated project settings and + /// source ranges do not invalidate import inference when the extracted information is equal. + #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] + pub(crate) fn dependency_metadata( + self, + db: &dyn Db, + ) -> Result>, DependencyMetadataError> { + let metadata = self.metadata(db); + let Some(workspace) = metadata.uv_workspace() else { + tracing::debug!( + "Skipping dependency checks for '{}': no uv workspace metadata is available", + metadata.root(), + ); + return Ok(None); + }; + let environment = workspace + .environment() + .ok_or(DependencyMetadataError::MissingEnvironment)?; + let environment = db + .system() + .canonicalize_path(environment) + .map_err(|error| DependencyMetadataError::InvalidEnvironment { + path: environment.to_path_buf(), + message: error.to_string().into(), + })?; + let selected_environment = metadata + .to_merged_options() + .python_environment(db.system()) + .map_err(|error| { + DependencyMetadataError::EnvironmentResolution(error.to_string().into()) + })? + .ok_or(DependencyMetadataError::MissingSelectedEnvironment)?; + + // An explicit Python environment can override uv's selection. Its installed modules may + // belong to different distributions, so uv's ownership map cannot describe those imports. + if selected_environment.sys_prefix().as_std_path() != environment.as_std_path() { + return Err(DependencyMetadataError::EnvironmentMismatch { + selected: selected_environment.sys_prefix().to_path_buf(), + selected_origin: selected_environment.origin().to_string().into(), + uv: environment, + }); + } + + workspace + .dependency_metadata() + .map(|metadata| Some(Box::new(metadata))) } pub fn update_program(self, db: &mut dyn Db, settings: ProgramSettings) { @@ -320,7 +399,7 @@ impl Project { self.metadata(db).root() } - fn name(self, db: &dyn Db) -> &str { + pub fn name(self, db: &dyn Db) -> &str { self.metadata(db).name() } @@ -353,6 +432,65 @@ impl Project { ) } + /// Rediscovers this project from `path` and applies its metadata and settings. + /// If discovery fails, the project is left unchanged. + fn rediscover( + self, + db: &mut dyn Db, + path: &SystemPath, + environment: uv::ProjectEnvironment, + ) -> Result { + let mut metadata = self + .metadata(db) + .rediscover(db.system(), path, environment)?; + if let Err(error) = metadata.apply_configuration_files(db.system()) { + let error = anyhow::Error::new(error); + tracing::error!( + "Failed to apply configuration files, \ + continuing without applying them: {error:#}" + ); + } + + metadata.try_add_project_root(db); + let merged_options = metadata.to_merged_options(); + + let program_settings_diagnostics = + match merged_options.to_program_settings(db.system(), db.vendored(), &FallibleStrategy) + { + Ok((program_settings, diagnostics)) => { + self.update_program(db, program_settings); + diagnostics + } + Err(error) => { + tracing::error!( + "Failed to convert metadata to program settings, \ + continuing without applying them: {error}" + ); + Vec::new() + } + }; + + let (settings, mut settings_diagnostics) = + match merged_options.to_settings(db, &FallibleStrategy) { + Ok((settings, diagnostics)) => (Some(settings), diagnostics), + Err(error) => { + tracing::warn!( + "Keeping old project configuration because loading the new \ + settings failed with: {error}" + ); + (None, vec![error.into_diagnostic()]) + } + }; + settings_diagnostics.extend( + program_settings_diagnostics + .into_iter() + .map(|diagnostic| diagnostic.into_diagnostic(db)), + ); + + tracing::debug!("Reloading project after structural change"); + Ok(self.reload(db, metadata, settings, settings_diagnostics)) + } + /// Reload the project after its metadata or settings have changed. pub fn reload( self, @@ -422,11 +560,7 @@ impl Project { name = self.name(db) ); - let mut diagnostics: Vec = self - .settings_diagnostics(db) - .iter() - .map(OptionDiagnostic::to_diagnostic) - .collect(); + let mut diagnostics = self.check_settings(db); let files = ProjectFiles::new(db, self); @@ -443,6 +577,7 @@ impl Project { reporter.report_diagnostics(db, diagnostics); + let reporter: &dyn ProgressReporter = reporter; let open_files = self.open_files(db); let check_start = ruff_db::Instant::now(); @@ -464,7 +599,12 @@ impl Project { // This is outside `check_file_impl` to avoid that opening or closing // a file invalidates the `check_file_impl` query of every file! - if !open_files.contains(&file) { + // Scripts with invalid settings are never parsed by `check_file_impl`, so + // they have no AST to clear. + if !open_files.contains(&file) + && Script::for_file(db, file) + .is_none_or(|script| script.has_valid_settings(db)) + { let python_file = program_file.python_file(db); // The module has already been parsed by `check_file_impl`. // We only retrieve it here so that we can call `clear` on it. @@ -639,7 +779,6 @@ impl Project { let files = self.files(db); files .iter() - .copied() .filter(|file| { file.path(db).as_system_path().is_some_and(|file_path| { paths @@ -664,7 +803,7 @@ impl Project { } } - fn add_file(self, db: &mut dyn Db, file: File) { + fn add_file(self, db: &mut dyn Db, file: File, is_script: bool) { tracing::debug!( "Adding file `{}` to project `{}`", file.path(db), @@ -675,7 +814,7 @@ impl Project { return; }; - index.insert(file); + index.insert(file, is_script); } /// Replaces the diagnostics from indexing the project files with `diagnostics`. @@ -689,6 +828,15 @@ impl Project { index.set_diagnostics(diagnostics); } + /// Returns whether `file` itself is an explicit check path. + /// + /// Including a parent directory does not count as explicitly including the file. + fn is_file_explicitly_included(self, db: &dyn Db, file: File) -> bool { + self.included_paths_or_root(db) + .iter() + .any(|path| file.path(db) == path) + } + /// Returns the files belonging to this project. pub fn files(self, db: &dyn Db) -> Indexed<'_> { let files = self.file_set(db); @@ -701,7 +849,7 @@ impl Project { let start = ruff_db::Instant::now(); let walker = ProjectFilesWalker::full(); - let (files, diagnostics) = walker.collect_set(db); + let (files, diagnostics) = walker.collect_vec(db); tracing::info!( "Indexed {} file(s) in {:.3}s", @@ -714,6 +862,18 @@ impl Project { } } + /// Returns all scripts in the project, including explicitly opened scripts. + /// + /// Scripts are identified solely by the presence of a PEP 723 script metadata block. + /// For open files, this includes unsaved changes. + pub fn script_files(self, db: &dyn Db) -> ScriptFiles<'_> { + ScriptFiles { + db, + indexed: self.files(db), + open_files: self.open_files(db), + } + } + fn reload_files(self, db: &mut dyn Db) { tracing::debug!("Reloading files for project `{}`", self.name(db)); @@ -725,13 +885,48 @@ impl Project { /// Check if the project's settings have any issues pub fn check_settings(&self, db: &dyn Db) -> Vec { + let metadata = self.metadata(db); + let uv_diagnostic = metadata.uv_diagnostic(db).or_else(|| { + let workspace = metadata.uv_workspace()?; + let error = self.dependency_metadata(db).as_ref().err()?; + let mut diagnostic = error.to_diagnostic(DependencyProjectKind::Project); + if let Ok(file) = + system_path_to_file(db, workspace.workspace_root().join("pyproject.toml")) + { + let mut annotation = Annotation::primary(Span::from(file)); + annotation.hide_snippet(true); + diagnostic.annotate(annotation); + } + Some(diagnostic) + }); + self.settings_diagnostics(db) .iter() .map(OptionDiagnostic::to_diagnostic) + .chain(uv_diagnostic) .collect() } } +/// An iterable view of a project's scripts. +pub struct ScriptFiles<'db> { + db: &'db dyn Db, + indexed: Indexed<'db>, + open_files: &'db FxHashSet, +} + +impl ScriptFiles<'_> { + /// Iterates over the scripts without duplicates. + pub fn iter(&self) -> impl Iterator + '_ { + let indexed = self.indexed.scripts(); + indexed.iter().copied().chain( + self.open_files.iter().copied().filter(move |file| { + !indexed.contains(file) && script_tag(self.db, *file).is_some() + }), + ) + } +} + fn check_file(db: &dyn Db, file: File) -> Vec { if !db.should_check_file(file) { return Vec::new(); @@ -742,6 +937,34 @@ fn check_file(db: &dyn Db, file: File) -> Vec { .unwrap_or_else(|diagnostic| vec![diagnostic.clone()]) } +/// Returns whether semantic checking and semantic diagnostics should run for `file`. +/// +/// Scripts with invalid configuration still produce configuration diagnostics and retain a program +/// for editor operations, but their semantic diagnostics must not be reported. +pub fn should_check_semantics(db: &dyn Db, file: File) -> bool { + if !db.should_check_file(file) { + return false; + } + + let Some(script) = Script::for_file(db, file) else { + return true; + }; + + script.has_valid_settings(db) +} + +/// Whether this is a first-party file, independently of which files receive diagnostics. +#[salsa::tracked(returns(copy))] +pub(crate) fn is_project_file(db: &dyn Db, file: File) -> bool { + if file.path(db).is_vendored_path() { + return false; + } + + let project = db.project(); + // Indexed files should not depend on changes to the open-file set. + project.files(db).contains(file) || project.open_files(db).contains(&file) +} + /// Returns `true` if the file should be checked. /// /// This depends on the project's check mode: @@ -794,7 +1017,7 @@ pub(crate) fn should_check_file(db: &dyn Db, file: File) -> bool { } let should_check = - project.files(db).contains(&file) || project.open_files(db).contains(&file); + project.files(db).contains(file) || project.open_files(db).contains(&file); if !should_check { tracing::trace!( "Not checking {path} because check mode is `AllFiles` \ @@ -827,6 +1050,13 @@ pub(crate) fn check_file_impl( { let db = AssertUnwindSafe(db); match catch(&**db, source_file, || { + let script = Script::for_file(*db, source_file); + if let Some(script) = script + && !script.has_valid_settings(*db) + { + return Ok(script.settings_diagnostics(*db).to_vec().into_boxed_slice()); + } + // what a registered checker has to say about a python file is folded into // the type checker's own pass rather than reported beside it, so that the // file's suppression comments apply to both alike @@ -835,7 +1065,28 @@ pub(crate) fn check_file_impl( .map(|checker| checker.check_python_file(*db, file)) .unwrap_or_default(); - ty_python_semantic::check_file_with(*db, file, external) + let diagnostics = ty_python_semantic::check_file_with(*db, file, external)?; + let Some(script) = script else { + return Ok(diagnostics); + }; + + let settings_diagnostics = script.settings_diagnostics(*db); + let dependency_diagnostic = + script.dependency_metadata(*db).as_ref().err().map(|error| { + let mut diagnostic = error.to_diagnostic(DependencyProjectKind::Script); + let mut annotation = Annotation::primary(Span::from(source_file)); + annotation.hide_snippet(true); + diagnostic.annotate(annotation); + diagnostic + }); + if settings_diagnostics.is_empty() && dependency_diagnostic.is_none() { + return Ok(diagnostics); + } + + let mut diagnostics = diagnostics.into_vec(); + diagnostics.extend(settings_diagnostics.iter().cloned()); + diagnostics.extend(dependency_diagnostic); + Ok(diagnostics.into_boxed_slice()) }) { Ok(result) => result, Err(diagnostic) => Ok(Box::new([diagnostic])), diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index 338ab33006..8e35d48ffe 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -1,7 +1,9 @@ use compact_str::CompactString; pub(crate) use configuration_file::CONFIG_FILE_NAMES; use configuration_file::{ConfigurationFile, ConfigurationFileError}; +use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span}; use ruff_db::files::FileRootKind; +use ruff_db::files::system_path_to_file; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_db::vendored::VendoredFileSystem; use ruff_ranged_value::ValueSource; @@ -9,25 +11,24 @@ use std::sync::Arc; use thiserror::Error; use ty_combine::Combine; use ty_python_core::program::{FallibleStrategy, MisconfigurationStrategy, ProgramSettings}; -use ty_static::EnvVars; +use ty_python_semantic::PythonEnvironment; use crate::Db; use crate::metadata::options::{ - EnvironmentOptions, OptionDiagnostic, ProgramSettingsDiagnostic, ToSettingsError, + EnvironmentOptions, OptionDiagnostic, OptionsContext, ProgramSettingsDiagnostic, + ToProgramSettingsError, ToSettingsError, }; use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError}; use crate::metadata::settings::Settings; use crate::metadata::value::RelativePathBuf; +use crate::uv::{self, ProjectEnvironment, UseUv}; pub use options::Options; use options::TyTomlError; - mod configuration_file; pub mod options; pub mod pyproject; pub mod python_version; -pub(crate) mod script; pub mod settings; -pub mod uv; pub mod value; #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] @@ -72,7 +73,10 @@ pub struct ProjectMetadata { config_file_override: Option, #[cfg_attr(test, serde(skip))] - uv_workspace: Option, + environment: ProjectEnvironment, + + #[cfg_attr(test, serde(skip))] + use_uv: UseUv, } impl ProjectMetadata { @@ -87,7 +91,8 @@ impl ProjectMetadata { user_configuration: None, fallback_options: None, config_file_override: None, - uv_workspace: None, + environment: ProjectEnvironment::default(), + use_uv: UseUv::Off, } } @@ -95,6 +100,16 @@ impl ProjectMetadata { path: SystemPathBuf, root: &SystemPath, system: &dyn System, + ) -> Result { + Self::from_config_file_with_uv(path, root, system, UseUv::from_system(system)) + } + + /// Loads a project from a configuration file using the explicitly configured uv integrations. + pub fn from_config_file_with_uv( + path: SystemPathBuf, + root: &SystemPath, + system: &dyn System, + use_uv: UseUv, ) -> Result { tracing::debug!("Using overridden configuration file at '{path}'"); @@ -116,7 +131,8 @@ impl ProjectMetadata { user_configuration: None, fallback_options: None, config_file_override: Some(path), - uv_workspace: None, + environment: ProjectEnvironment::default(), + use_uv, }) } @@ -145,26 +161,13 @@ impl ProjectMetadata { .map(|name| ProjectName::new(&**name)) .unwrap_or_else(|| ProjectName::new(root.file_name().unwrap_or("root"))); - // 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 - .environment - .as_ref() - .is_none_or(|env| env.python_version.is_none()) - { - let requires_python = strategy.fallback_opt( - project.resolve_requires_python_lower_bound(), - |err| { - tracing::debug!("skipping invalid requires_python lower bound: {err}"); - }, - )?; - if let Some(requires_python) = requires_python.flatten() { - let mut environment = options.environment.unwrap_or_default(); - environment.python_version = Some(requires_python); - options.environment = Some(environment); - } - } + // If the `options` don't specify a python version but the `project.requires-python` field is set, + // use that as a lower bound instead. + strategy.fallback( + options.apply_requires_python(project.requires_python.as_ref()), + |error| tracing::debug!("skipping invalid requires_python lower bound: {error}"), + )?; } Ok(Self { @@ -176,7 +179,8 @@ impl ProjectMetadata { user_configuration: None, fallback_options: None, config_file_override: None, - uv_workspace: None, + environment: ProjectEnvironment::default(), + use_uv: UseUv::Off, }) } @@ -194,20 +198,37 @@ impl ProjectMetadata { path: &SystemPath, system: &dyn System, ) -> Result { - let uv_workspace = if matches!(system.env_var(EnvVars::TY_UV).as_deref(), Ok("1" | "true")) - { - match uv::UvWorkspace::discover(path, system) { - Ok(workspace) => Some(workspace), - Err(error) => { - tracing::warn!("{error}"); - None - } + Self::discover_with_uv(path, system, UseUv::from_system(system)) + } + + /// Discovers the closest project using the explicitly configured uv integrations. + pub fn discover_with_uv( + path: &SystemPath, + system: &dyn System, + use_uv: UseUv, + ) -> Result { + let environment = if use_uv.workspace_discovery_enabled() { + let metadata = uv::Uv::new(system) + .map_err(uv::uv_executable_error) + .map_err(uv::UvMetadataError::Invocation) + .and_then(|uv| uv.metadata(system, &uv::MetadataTarget::Workspace(path))); + + match metadata { + Ok(metadata) => ProjectEnvironment { + metadata: Some(metadata), + error: None, + }, + Err(error) => ProjectEnvironment { + metadata: None, + error: Some(error.to_string().into_boxed_str()), + }, } } else { - None + ProjectEnvironment::default() }; - Self::discover_with_uv_workspace(path, system, uv_workspace) + Self::discover_with_uv_workspace(path, system, environment) + .map(|metadata| metadata.with_use_uv(use_uv)) } /// Discovers the closest project without considering uv workspace metadata. @@ -215,13 +236,14 @@ impl ProjectMetadata { path: &SystemPath, system: &dyn System, ) -> Result { - Self::discover_with_uv_workspace(path, system, None) + Self::discover_with_uv_workspace(path, system, ProjectEnvironment::default()) + .map(|metadata| metadata.with_use_uv(UseUv::from_system(system))) } fn discover_with_uv_workspace( path: &SystemPath, system: &dyn System, - uv_workspace: Option, + environment: ProjectEnvironment, ) -> Result { tracing::debug!("Searching for a project in '{path}'"); @@ -231,7 +253,10 @@ impl ProjectMetadata { let mut closest_project: Option = None; let mut uv_project: Option = None; - let uv_workspace_root = uv_workspace.as_ref().map(uv::UvWorkspace::root); + let uv_workspace_root = environment + .metadata + .as_ref() + .map(uv::UvMetadata::workspace_root); for project_root in path.ancestors() { let is_uv_workspace_root = uv_workspace_root == Some(project_root); @@ -248,7 +273,7 @@ impl ProjectMetadata { if has_ty_configuration { tracing::debug!("Found project at '{}'", project_root); - return Ok(metadata.with_uv_workspace(uv_workspace)); + return Ok(metadata.with_environment(environment)); } if is_uv_workspace_root { @@ -294,7 +319,7 @@ impl ProjectMetadata { Self::new(path.file_name().unwrap_or("root"), path.to_path_buf()) }; - Ok(metadata.with_uv_workspace(uv_workspace)) + Ok(metadata.with_environment(environment)) } fn discover_in( @@ -395,24 +420,35 @@ impl ProjectMetadata { } #[must_use] - fn with_uv_workspace(mut self, uv_workspace: Option) -> Self { - self.uv_workspace = uv_workspace; + fn with_environment(mut self, environment: ProjectEnvironment) -> Self { + self.environment = environment; self } - /// Rediscovers the project, while preserving applied options. - pub(crate) fn rediscover(&self, system: &dyn System) -> Result { + /// Configures which uv integrations are enabled for this project. + #[must_use] + pub fn with_use_uv(mut self, use_uv: UseUv) -> Self { + self.use_uv = use_uv; + self + } + + /// Rediscovers the project from `path`, while preserving applied options. + pub(crate) fn rediscover( + &self, + system: &dyn System, + path: &SystemPath, + environment: ProjectEnvironment, + ) -> Result { let mut metadata = if let Some(config_file) = self.config_file_override() { - Self::from_config_file(config_file.to_path_buf(), self.root(), system)? + Self::from_config_file_with_uv( + config_file.to_path_buf(), + self.root(), + system, + self.use_uv, + )? + .with_environment(environment) } else { - // The active project root may have been deleted. Start rediscovery from the closest - // existing ancestor so ty can fall back to an enclosing project. - let rediscovery_path = self - .root() - .ancestors() - .find(|path| system.is_directory(path)) - .unwrap_or_else(|| self.root()); - Self::discover(rediscovery_path, system)? + Self::discover_with_uv_workspace(path, system, environment)?.with_use_uv(self.use_uv) }; metadata.override_options.clone_from(&self.override_options); @@ -434,10 +470,18 @@ impl ProjectMetadata { self.name.as_str() } + pub(crate) const fn use_uv(&self) -> UseUv { + self.use_uv + } + pub fn options(&self) -> &Options { &self.options } + pub(crate) fn override_options(&self) -> Option<&Options> { + self.override_options.as_deref() + } + /// Returns the explicit configuration file that replaces normal project discovery, if any. pub(crate) fn config_file_override(&self) -> Option<&SystemPath> { self.config_file_override.as_deref() @@ -473,8 +517,29 @@ impl ProjectMetadata { } } - pub fn has_uv_workspace(&self) -> bool { - self.uv_workspace.is_some() + pub(crate) fn environment(&self) -> &ProjectEnvironment { + &self.environment + } + + pub(crate) fn uv_diagnostic(&self, db: &dyn Db) -> Option { + let error = self.environment.error.as_deref()?; + let path = self + .environment + .metadata + .as_ref() + .map_or(self.root(), uv::UvMetadata::workspace_root) + .join("pyproject.toml"); + let mut diagnostic = Diagnostic::new(DiagnosticId::UvMetadata, Severity::Warning, error); + if let Ok(file) = system_path_to_file(db, &path) { + let mut annotation = Annotation::primary(Span::from(file)); + annotation.hide_snippet(true); + diagnostic.annotate(annotation); + } + Some(diagnostic) + } + + pub(crate) fn uv_workspace(&self) -> Option<&uv::UvMetadata> { + self.environment.metadata.as_ref() } /// Applies lower-precedence options to this project. @@ -490,22 +555,27 @@ impl ProjectMetadata { } } - /// Returns the project's option layers from highest to lowest precedence. + /// Returns project or script option layers from highest to lowest precedence. /// - /// `options` is used as the raw base layer between the uv workspace and user-level options. + /// `options` is the raw project or script configuration, and `uv_options` is its corresponding + /// uv metadata layer. /// Layers can be merged by passing them to [`Options::combine_with`] in iterator order: /// /// ```ignore /// let mut merged = Options::default(); - /// for layer in metadata.options_in_precedence_order(metadata.options()) { + /// for layer in metadata.options_in_precedence_order( + /// metadata.options(), + /// metadata.uv_workspace_options.as_deref(), + /// ) { /// merged.combine_with(layer.clone()); /// } /// ``` fn options_in_precedence_order<'a>( &'a self, options: &'a Options, + uv_options: Option<&'a Options>, ) -> impl Iterator { - self.options_in_precedence_order_with_script(options, None) + self.options_in_precedence_order_with_script(options, None, uv_options) } /// As [`Self::options_in_precedence_order`], but with a PEP 723 script's own @@ -521,11 +591,12 @@ impl ProjectMetadata { &'a self, options: &'a Options, script: Option<&'a Options>, + uv_options: Option<&'a Options>, ) -> impl Iterator { self.override_options .as_deref() .into_iter() - .chain(self.uv_workspace_options.as_deref()) + .chain(uv_options) .chain(script) .chain(std::iter::once(options)) .chain( @@ -556,13 +627,13 @@ impl ProjectMetadata { self.user_configuration = Some(Box::new((user.path().to_owned(), user.into_options()))); } - self.uv_workspace_options = self.uv_workspace.as_ref().map(|uv_workspace| { + self.uv_workspace_options = self.environment.metadata.as_ref().map(|uv_workspace| { Box::new(Options { environment: Some(EnvironmentOptions { python_version: uv_workspace.python_version().cloned(), python: uv_workspace .environment() - .map(|path| RelativePathBuf::new(path, ValueSource::UvWorkspace)), + .map(|path| RelativePathBuf::new(path, ValueSource::UvMetadata)), ..EnvironmentOptions::default() }), ..Options::default() @@ -576,7 +647,9 @@ impl ProjectMetadata { pub fn to_merged_options(&self) -> MergedOptions<'_> { let mut options = Options::default(); - for layer in self.options_in_precedence_order(&self.options) { + for layer in + self.options_in_precedence_order(&self.options, self.uv_workspace_options.as_deref()) + { options.combine_with(layer.clone()); } @@ -604,10 +677,12 @@ impl MergedOptions<'_> { system: &dyn System, vendored: &VendoredFileSystem, strategy: &Strategy, - ) -> Result<(ProgramSettings, Vec), Strategy::Error> - { + ) -> Result< + (ProgramSettings, Vec), + Strategy::Error, + > { self.options.to_program_settings( - self.metadata.root(), + OptionsContext::Project(self.metadata.root()), self.metadata.name(), system, vendored, @@ -615,12 +690,23 @@ impl MergedOptions<'_> { ) } + /// Resolve the configured Python environment. Return `None` if no path was configured. + pub fn python_environment( + &self, + system: &dyn System, + ) -> anyhow::Result> { + self.options + .python_environment(self.metadata.root(), system) + .map_err(anyhow::Error::from) + } + pub fn to_settings( &self, db: &dyn Db, strategy: &Strategy, ) -> Result<(Settings, Vec), Strategy::Error> { - self.options.to_settings(db, self.metadata.root(), strategy) + self.options + .to_settings(db, OptionsContext::Project(self.metadata.root()), strategy) } } @@ -672,15 +758,21 @@ pub enum ProjectMetadataError { mod tests { //! Integration tests for project discovery + use std::assert_matches; + use anyhow::{Context, anyhow}; use insta::assert_ron_snapshot; + use ruff_db::diagnostic::{DiagnosticId, Severity}; use ruff_db::system::{SystemPathBuf, TestSystem}; + use ruff_db::testing::assert_function_query_was_not_run_by_name; use ruff_python_ast::PythonVersion; use ruff_ranged_value::ValueSource; use ty_static::EnvVars; - use crate::metadata::{Options, uv::UvWorkspace, value::RelativePathBuf}; - use crate::{ProjectMetadata, ProjectMetadataError}; + use crate::db::testing::TestDb; + use crate::metadata::{Options, uv::UvMetadata, value::RelativePathBuf}; + use crate::uv::{DependencyMetadataError, ProjectEnvironment}; + use crate::{Db as _, ProjectMetadata, ProjectMetadataError}; #[test] fn project_without_pyproject() -> anyhow::Result<()> { @@ -1047,9 +1139,8 @@ unclosed table, expected `]` ), ])?; - let uv_workspace = uv_workspace(&root, &system)?; - let project = - ProjectMetadata::discover_with_uv_workspace(&member, &system, Some(uv_workspace))?; + let environment = uv_workspace(&root, &system)?; + let project = ProjectMetadata::discover_with_uv_workspace(&member, &system, environment)?; assert_eq!(project.root(), &*root); @@ -1082,9 +1173,8 @@ unclosed table, expected `]` ), ])?; - let uv_workspace = uv_workspace(&root, &system)?; - let project = - ProjectMetadata::discover_with_uv_workspace(&member, &system, Some(uv_workspace))?; + let environment = uv_workspace(&root, &system)?; + let project = ProjectMetadata::discover_with_uv_workspace(&member, &system, environment)?; assert_eq!(project.root(), &*root); @@ -1130,9 +1220,9 @@ unclosed table, expected `]` ), ])?; - let uv_workspace = uv_workspace(&root, &system)?; + let environment = uv_workspace(&root, &system)?; let mut project = - ProjectMetadata::discover_with_uv_workspace(&member, &system, Some(uv_workspace))?; + ProjectMetadata::discover_with_uv_workspace(&member, &system, environment)?; project.apply_configuration_files(&system)?; assert_eq!(project.root(), &*member); @@ -1176,15 +1266,74 @@ unclosed table, expected `]` ), ])?; - let uv_workspace = uv_workspace(&workspace, &system)?; - let project = - ProjectMetadata::discover_with_uv_workspace(&member, &system, Some(uv_workspace))?; + let environment = uv_workspace(&workspace, &system)?; + let project = ProjectMetadata::discover_with_uv_workspace(&member, &system, environment)?; assert_eq!(project.root(), &*root); Ok(()) } + #[test] + fn dependency_metadata_warning_is_reported_by_default() -> anyhow::Result<()> { + let system = TestSystem::default(); + let root = SystemPathBuf::from(if cfg!(windows) { "C:/app" } else { "/app" }); + system.memory_file_system().create_directory_all(&root)?; + let environment = uv_workspace(&root, &system)?; + let metadata = ProjectMetadata::new("app", root).with_environment(environment); + let db = TestDb::new(metadata); + + let diagnostics = db.project().check_settings(&db); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id(), DiagnosticId::UvMetadata); + assert_eq!(diagnostics[0].severity(), Severity::Warning); + assert_eq!( + diagnostics[0].concise_message().to_string(), + "Failed to load uv dependency metadata: uv did not provide a Python environment" + ); + + Ok(()) + } + + #[test] + fn uv_refresh_error_takes_precedence_over_dependency_error() -> anyhow::Result<()> { + let system = TestSystem::default(); + let root = SystemPathBuf::from(if cfg!(windows) { "C:/app" } else { "/app" }); + system.memory_file_system().create_directory_all(&root)?; + let mut environment = uv_workspace(&root, &system)?; + environment.error = Some("uv metadata refresh failed".into()); + let mut metadata = ProjectMetadata::new("app", root).with_environment(environment); + metadata.apply_override_options(Options::from_toml_str( + "[rules]\nmissing-direct-dependency = 'warn'", + ValueSource::Cli, + )?); + let mut db = TestDb::new(metadata); + let project = db.project(); + + assert!(project.metadata(&db).uv_workspace().is_some()); + let diagnostics = project.check_settings(&db); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id(), DiagnosticId::UvMetadata); + assert_eq!(diagnostics[0].severity(), Severity::Warning); + assert_eq!( + diagnostics[0].concise_message().to_string(), + "uv metadata refresh failed" + ); + let events = db.take_salsa_events(); + assert_function_query_was_not_run_by_name( + &db, + "Project::dependency_metadata_", + None, + &events, + ); + assert_matches!( + project.dependency_metadata(&db), + Err(DependencyMetadataError::MissingEnvironment) + ); + + Ok(()) + } + #[test] fn applies_uv_workspace_environment() -> anyhow::Result<()> { let system = TestSystem::default(); @@ -1208,6 +1357,7 @@ unclosed table, expected `]` ])?; let metadata = serde_json::json!({ + "schema": {"version": "preview"}, "workspace_root": root, "environment": { "root": environment, @@ -1216,9 +1366,15 @@ unclosed table, expected `]` }, }, }); - let uv_workspace = UvWorkspace::from_metadata(metadata.to_string().as_bytes(), &system)?; + let uv_environment = ProjectEnvironment { + metadata: Some(UvMetadata::from_metadata( + metadata.to_string().as_bytes(), + &system, + )?), + error: None, + }; let mut project = - ProjectMetadata::discover_with_uv_workspace(&member, &system, Some(uv_workspace))?; + ProjectMetadata::discover_with_uv_workspace(&member, &system, uv_environment)?; project.apply_fallback_options(Options::from_toml_str( r#" [environment] @@ -1244,18 +1400,18 @@ unclosed table, expected `]` .map(RelativePathBuf::path), Some(environment.as_path()) ); - assert!(matches!( + assert_matches!( project_environment .and_then(|environment| environment.python.as_ref()) .map(RelativePathBuf::source), - Some(ValueSource::UvWorkspace) - )); - assert!(matches!( + Some(ValueSource::UvMetadata) + ); + assert_matches!( project_environment .and_then(|environment| environment.python_version.as_ref()) .map(ruff_ranged_value::RangedValue::source), - Some(ValueSource::UvWorkspace) - )); + Some(ValueSource::UvMetadata) + ); let user_config_directory = root.join("config"); system @@ -1810,15 +1966,22 @@ unclosed table, expected `]` assert_eq!(format!("{error:#}").replace('\\', "/"), message); } - fn uv_workspace(root: &SystemPathBuf, system: &TestSystem) -> anyhow::Result { + fn uv_workspace( + root: &SystemPathBuf, + system: &TestSystem, + ) -> anyhow::Result { let metadata = serde_json::json!({ + "schema": {"version": "preview"}, "workspace_root": root, }); - Ok(UvWorkspace::from_metadata( - metadata.to_string().as_bytes(), - system, - )?) + Ok(ProjectEnvironment { + metadata: Some(UvMetadata::from_metadata( + metadata.to_string().as_bytes(), + system, + )?), + error: None, + }) } fn with_escaped_paths(f: impl FnOnce() -> R) -> R { diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index bf19fe707d..bd9d464d42 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -4,25 +4,26 @@ use crate::glob::{ AbsolutePortableGlobPattern, ExcludeFilter, IncludeExcludeFilter, IncludeFilter, PortableGlobKind, }; +use crate::metadata::pyproject::{ResolveRequiresPythonError, resolve_requires_python_lower_bound}; use crate::metadata::python_version::SupportedPythonVersion; use crate::metadata::settings::{BuildSettings, OverrideSettings, SrcSettings}; use super::settings::{EditorSettings, Override, Settings, TerminalSettings}; use crate::metadata::value::{RelativeGlobPattern, RelativePathBuf}; -use anyhow::Context; use ordermap::OrderMap; +use pep440_rs::VersionSpecifiers; use ruff_db::RustDoc; use ruff_db::diagnostic::{ Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, Severity, Span, SubDiagnostic, SubDiagnosticSeverity, }; -use ruff_db::files::system_path_to_file; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_db::vendored::VendoredFileSystem; use ruff_macros::{Combine, OptionsMetadata, RustDoc}; use ruff_options_metadata::{OptionSet, OptionsMetadata, Visit}; use ruff_python_ast::PythonVersion; use ruff_ranged_value::{RangedValue, ValueSource, ValueSourceGuard}; +use ruff_text_size::TextRange; use rustc_hash::FxHasher; use serde::{Deserialize, Serialize}; use std::borrow::Cow; @@ -43,8 +44,8 @@ use ty_python_core::program::{MisconfigurationStrategy, ProgramSettings}; use ty_python_semantic::lint::{Level, LintSource, RuleSelection}; use ty_python_semantic::{ AnalysisSettings, ExperimentalSettings, PythonEnvironment, PythonVersionFileSource, - PythonVersionSource, PythonVersionWithSource, SitePackagesPaths, SysPrefixPathOrigin, - TypeCheckingPreset, inferred_python_version_source_annotation, + PythonVersionSource, PythonVersionWithSource, SitePackagesDiscoveryError, SitePackagesPaths, + SysPrefixPathOrigin, TypeCheckingPreset, inferred_python_version_source_annotation, }; use ty_static::EnvVars; @@ -164,14 +165,6 @@ pub struct Options { } impl Options { - pub(super) fn file_options(&self) -> FileOptions { - FileOptions { - type_checking_preset: self.type_checking_preset.clone(), - rules: self.rules.clone(), - analysis: self.analysis.clone(), - } - } - pub fn from_toml_str(content: &str, source: ValueSource) -> Result { let _guard = ValueSourceGuard::new(source, true); let mut options: Self = toml::from_str(content)?; @@ -179,6 +172,28 @@ impl Options { Ok(options) } + /// Infers the Python version from `requires-python` unless it was configured explicitly. + pub(crate) fn apply_requires_python( + &mut self, + requires_python: Option<&RangedValue>, + ) -> Result<(), ResolveRequiresPythonError> { + if self + .environment + .as_ref() + .is_some_and(|environment| environment.python_version.is_some()) + { + return Ok(()); + } + + if let Some(requires_python) = requires_python + && let Some(python_version) = resolve_requires_python_lower_bound(requires_python)? + { + self.environment.get_or_insert_default().python_version = Some(python_version); + } + + Ok(()) + } + /// Ensures that the `all` selector is applied before per-rule selectors /// in all rule tables (top-level and overrides). /// @@ -218,15 +233,18 @@ impl Options { Self::deserialize(deserializer) } + /// Resolve configured paths and discover defaults according to the project or script context. pub(crate) fn to_program_settings( &self, - project_root: &SystemPath, + context: OptionsContext<'_>, project_name: &str, system: &dyn System, vendored: &VendoredFileSystem, strategy: &Strategy, - ) -> Result<(ProgramSettings, Vec), Strategy::Error> - { + ) -> Result< + (ProgramSettings, Vec), + Strategy::Error, + > { let mut diagnostics = Vec::new(); let environment = self.environment.or_default(); @@ -244,22 +262,11 @@ impl Options { default }); - let python_environment = if let Some(python_path) = environment.python.as_ref() { - let origin = match python_path.source() { - ValueSource::Cli => SysPrefixPathOrigin::PythonCliFlag, - ValueSource::File(path) => { - SysPrefixPathOrigin::ConfigFileSetting(path.clone(), python_path.range()) - } - ValueSource::Editor => SysPrefixPathOrigin::Editor, - ValueSource::UvWorkspace => SysPrefixPathOrigin::UvWorkspace, - }; - - PythonEnvironment::new(python_path.absolute(project_root, system), origin, system) - .map_err(anyhow::Error::from) - .map(Some) - } else { - PythonEnvironment::discover(project_root, system) - .context("Failed to discover local Python environment") + let python_environment = match self.python_environment(context.configuration_root(), system) + { + Ok(None) => PythonEnvironment::discover(context.project_root(), system) + .map_err(ToProgramSettingsError::PythonEnvironmentDiscovery), + configured => configured.map_err(ToProgramSettingsError::PythonEnvironment), }; // If in safe-mode, fallback to None if this fails instead of erroring. @@ -280,7 +287,7 @@ impl Options { let site_packages_paths = if let Some(python_environment) = python_environment.as_ref() { let site_packages_paths = python_environment .site_packages_paths(system) - .context("Failed to discover the site-packages directory"); + .map_err(ToProgramSettingsError::SitePackagesDiscovery); let site_packages_paths = strategy.fallback(site_packages_paths, |_| { tracing::debug!("Default settings failed to discover site-packages directory"); SitePackagesPaths::default() @@ -329,16 +336,18 @@ impl Options { .and_then(|resolution| resolution.into_program_version(&mut diagnostics)) .unwrap_or_default(); - // Safe mode is handled inside this function, so we just assume this can't fail - let search_paths = strategy.to_anyhow(self.to_search_paths( - project_root, - project_name, - site_packages_paths, - real_stdlib_path, - system, - vendored, - strategy, - ))?; + let search_paths = strategy.map_err( + self.to_search_paths( + context, + project_name, + site_packages_paths, + real_stdlib_path, + system, + vendored, + strategy, + ), + ToProgramSettingsError::SearchPaths, + )?; tracing::info!( "Python version: Python {python_version}, platform: {python_platform}", @@ -355,10 +364,39 @@ impl Options { )) } + /// Resolve the configured Python environment. Return `None` if no path was configured. + pub(crate) fn python_environment( + &self, + configuration_root: &SystemPath, + system: &dyn System, + ) -> Result, SitePackagesDiscoveryError> { + let environment = self.environment.or_default(); + let Some(python_path) = environment.python.as_ref() else { + return Ok(None); + }; + + let origin = match python_path.source() { + ValueSource::Cli => SysPrefixPathOrigin::PythonCliFlag, + ValueSource::File(path) => { + SysPrefixPathOrigin::ConfigFileSetting(path.clone(), python_path.range()) + } + ValueSource::ScriptMetadata(_) => SysPrefixPathOrigin::ScriptMetadataSetting, + ValueSource::Editor => SysPrefixPathOrigin::Editor, + ValueSource::UvMetadata => SysPrefixPathOrigin::UvMetadata, + }; + + PythonEnvironment::new( + python_path.absolute(configuration_root, system), + origin, + system, + ) + .map(Some) + } + #[expect(clippy::too_many_arguments)] fn to_search_paths( &self, - project_root: &SystemPath, + context: OptionsContext<'_>, project_name: &str, site_packages_paths: SitePackagesPaths, real_stdlib_path: Option, @@ -371,9 +409,10 @@ impl Options { let environment_roots = if let Some(roots) = environment.root.as_deref() { roots .iter() - .map(|root| root.absolute(project_root, system)) + .map(|root| root.absolute(context.configuration_root(), system)) .collect() } else { + let project_root = context.configuration_root(); let mut roots = vec![]; let is_package = |dir: &SystemPath| { system.is_file(&dir.join("__init__.py")) @@ -431,7 +470,7 @@ impl Options { .as_deref() .unwrap_or_default() .iter() - .map(|path| path.absolute(project_root, system)) + .map(|path| path.absolute(context.configuration_root(), system)) .collect(); // read all the paths off the PYTHONPATH environment variable, check @@ -477,7 +516,7 @@ impl Options { custom_typeshed: environment .typeshed .as_ref() - .map(|path| path.absolute(project_root, system)), + .map(|path| path.absolute(context.configuration_root(), system)), site_packages_paths: site_packages_paths.into_vec(), real_stdlib_path, }; @@ -488,7 +527,7 @@ impl Options { pub(crate) fn to_settings( &self, db: &dyn Db, - project_root: &SystemPath, + context: OptionsContext<'_>, strategy: &Strategy, ) -> Result<(Settings, Vec), Strategy::Error> { let mut diagnostics = Vec::new(); @@ -508,7 +547,7 @@ impl Options { let src_options = self.src.or_default(); let src = src_options - .to_settings(db, project_root, &mut diagnostics) + .to_settings(db, context.configuration_root(), &mut diagnostics) .map_err(|err| ToSettingsError { diagnostic: err, output_format: terminal.output_format, @@ -519,7 +558,7 @@ impl Options { let build = self .build .or_default() - .to_settings(db, project_root, &mut diagnostics) + .to_settings(db, context.configuration_root(), &mut diagnostics) .map_err(|err| ToSettingsError { diagnostic: err, output_format: terminal.output_format, @@ -549,7 +588,7 @@ impl Options { let experimental = self.experimental.or_default().to_settings(); let overrides = self - .to_overrides_settings(db, project_root, preset, &mut diagnostics) + .to_overrides_settings(db, context.configuration_root(), preset, &mut diagnostics) .map_err(|err| ToSettingsError { diagnostic: err, output_format: terminal.output_format, @@ -580,7 +619,7 @@ impl Options { } /// The preset the project's other settings start from. - pub fn type_checking_preset(&self) -> TypeCheckingPreset { + fn type_checking_preset(&self) -> TypeCheckingPreset { self.configured_type_checking_preset().unwrap_or_default() } @@ -631,6 +670,29 @@ impl Options { } } +/// The project or standalone script whose options are being resolved. +#[derive(Clone, Copy, Debug)] +pub(crate) enum OptionsContext<'a> { + Project(&'a SystemPath), + /// The directory containing a standalone script, or the working directory for a virtual script. + Script(&'a SystemPath), +} + +impl<'a> OptionsContext<'a> { + fn configuration_root(self) -> &'a SystemPath { + match self { + Self::Project(root) | Self::Script(root) => root, + } + } + + fn project_root(self) -> Option<&'a SystemPath> { + match self { + Self::Project(root) => Some(root), + Self::Script(_) => None, + } + } +} + fn python_version_from_config( ranged_version: &RangedValue, ) -> PythonVersionWithSource { @@ -641,8 +703,11 @@ fn python_version_from_config( ValueSource::File(path) => PythonVersionSource::ConfigFile( PythonVersionFileSource::new(path.clone(), ranged_version.range()), ), + ValueSource::ScriptMetadata(file) => PythonVersionSource::ScriptMetadata( + Span::from(*file).with_optional_range(ranged_version.range()), + ), ValueSource::Editor => PythonVersionSource::Editor, - ValueSource::UvWorkspace => PythonVersionSource::UvWorkspace, + ValueSource::UvMetadata => PythonVersionSource::UvMetadata, }, } } @@ -734,6 +799,12 @@ fn unsupported_inferred_python_version_diagnostic( SubDiagnosticSeverity::Info, "The version was inferred from a configuration file.", )), + source @ PythonVersionSource::ScriptMetadata(_) => diagnostic + .with_annotation(inferred_python_version_source_annotation(db, source)) + .sub(SubDiagnostic::new( + SubDiagnosticSeverity::Info, + "The version was inferred from script metadata.", + )), source @ PythonVersionSource::PyvenvCfgFile(_) => diagnostic .with_annotation(inferred_python_version_source_annotation(db, source)) .sub(SubDiagnostic::new( @@ -766,9 +837,9 @@ fn unsupported_inferred_python_version_diagnostic( SubDiagnosticSeverity::Info, "The version was inferred from your editor.", )), - PythonVersionSource::UvWorkspace => diagnostic.sub(SubDiagnostic::new( + PythonVersionSource::UvMetadata => diagnostic.sub(SubDiagnostic::new( SubDiagnosticSeverity::Info, - "The version was provided by uv workspace metadata.", + "The version was provided by uv metadata.", )), PythonVersionSource::Default => diagnostic.sub(SubDiagnostic::new( SubDiagnosticSeverity::Info, @@ -848,6 +919,9 @@ pub struct EnvironmentOptions { /// * `./src` /// * `./` (if a `.//` directory exists) /// * `./python` + /// + /// Scripts with inline metadata have no first-party roots by default because they are + /// single-file programs. Set `root = ["."]` to allow importing local modules. #[serde(skip_serializing_if = "Option::is_none")] #[option( default = r#"null"#, @@ -877,6 +951,9 @@ pub struct EnvironmentOptions { /// and attempt to infer the Python version of that environment /// 3. Fall back to the default value (see below) /// + /// Scripts with inline metadata use their `requires-python` field instead of + /// `project.requires-python`. They do not inherit the Python version of the enclosing project. + /// /// For some language features, ty can also understand conditionals based on comparisons /// with `sys.version_info`. These are commonly found in typeshed, for example, /// to reflect the differing contents of the standard library across Python versions. @@ -961,6 +1038,10 @@ pub struct EnvironmentOptions { /// in the project root if none of the above apply. Failing that, ty will look for a `python3` /// or `python` binary available in `PATH`. /// + /// Scripts with inline metadata use their own Python environment. They can use an explicitly + /// configured environment, an activated environment, or an environment selected by the editor. + /// Unlike projects, they do not automatically use a `.venv` directory. + /// /// [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix #[serde(skip_serializing_if = "Option::is_none")] #[option( @@ -1184,9 +1265,10 @@ impl Rules { let source = rule_name.source(); let lint_source = match source { ValueSource::File(_) => LintSource::File, + ValueSource::ScriptMetadata(_) => LintSource::ScriptMetadata, ValueSource::Cli => LintSource::Cli, ValueSource::Editor => LintSource::Editor, - ValueSource::UvWorkspace => LintSource::UvWorkspace, + ValueSource::UvMetadata => LintSource::UvMetadata, }; let mut set_lint_level = |lint| { @@ -1219,12 +1301,9 @@ impl Rules { }; if let Some(message) = unknown { - // `system_path_to_file` can return `Err` if the file was deleted since the configuration - // was read. This should be rare and it should be okay to default to not showing a configuration - // file in that case. - let file = source - .file() - .and_then(|path| system_path_to_file(db, path).ok()); + // The file may have been deleted since its configuration was read. In that + // case, report the diagnostic without a configuration-file annotation. + let file = source.file(db); // TODO: Add a note if the value was configured on the CLI let diagnostic = @@ -1297,14 +1376,12 @@ fn build_include_filter( )); // Add source annotation if we have source information - if let Some(source_file) = include_patterns.source().file() { - if let Ok(file) = system_path_to_file(db, source_file) { - let annotation = Annotation::primary( - Span::from(file).with_optional_range(include_patterns.range()), - ) - .message("This `include` list is empty"); - diagnostic = diagnostic.with_annotation(Some(annotation)); - } + if let Some(file) = include_patterns.source().file(db) { + let annotation = Annotation::primary( + Span::from(file).with_optional_range(include_patterns.range()), + ) + .message("This `include` list is empty"); + diagnostic = diagnostic.with_annotation(Some(annotation)); } diagnostics.push(diagnostic); @@ -1455,9 +1532,7 @@ fn build_exclude_filter( )); } - if let Some(source_file) = exclude.value().source().file() - && let Ok(file) = system_path_to_file(db, source_file) - { + if let Some(file) = exclude.value().source().file(db) { diagnostic = diagnostic.with_annotation(Some( Annotation::primary(Span::from(file).with_optional_range(exclude.value().range())) .message("This pattern can never match"), @@ -1933,7 +2008,7 @@ pub struct CommonAliases { impl CommonAliases { /// The configured aliases, each paired with the module it names. - pub fn iter(&self) -> impl ExactSizeIterator { + fn iter(&self) -> impl ExactSizeIterator { self.inner .iter() .map(|(alias, module)| (alias.as_str(), module.as_str())) @@ -1995,7 +2070,7 @@ pub struct ExperimentalOptions { module-api = true "# )] - pub module_api: Option, + module_api: Option, /// Whether a `build:` block declares build stamps. /// @@ -2014,11 +2089,11 @@ pub struct ExperimentalOptions { build-stamps = true "# )] - pub build_stamps: Option, + build_stamps: Option, } impl ExperimentalOptions { - pub(super) fn to_settings(&self) -> ExperimentalSettings { + fn to_settings(&self) -> ExperimentalSettings { ExperimentalSettings { module_api: self.module_api.unwrap_or_default(), build_stamps: self.build_stamps.unwrap_or_default(), @@ -3037,13 +3112,11 @@ impl ToOverride for RangedValue { )); // Add source annotation if we have source information - if let Some(source_file) = self.source().file() { - if let Ok(file) = system_path_to_file(db, source_file) { - let annotation = - Annotation::primary(Span::from(file).with_optional_range(self.range())) - .message("This overrides section overrides no settings"); - diagnostic = diagnostic.with_annotation(Some(annotation)); - } + if let Some(file) = self.source().file(db) { + let annotation = + Annotation::primary(Span::from(file).with_optional_range(self.range())) + .message("This overrides section overrides no settings"); + diagnostic = diagnostic.with_annotation(Some(annotation)); } diagnostics.push(diagnostic); @@ -3090,13 +3163,11 @@ impl ToOverride for RangedValue { )); // Add source annotation if we have source information - if let Some(source_file) = self.source().file() { - if let Ok(file) = system_path_to_file(db, source_file) { - let annotation = - Annotation::primary(Span::from(file).with_optional_range(self.range())) - .message("This overrides section applies to all files"); - diagnostic = diagnostic.with_annotation(Some(annotation)); - } + if let Some(file) = self.source().file(db) { + let annotation = + Annotation::primary(Span::from(file).with_optional_range(self.range())) + .message("This overrides section applies to all files"); + diagnostic = diagnostic.with_annotation(Some(annotation)); } diagnostics.push(diagnostic); @@ -3159,23 +3230,72 @@ impl ToOverride for RangedValue { /// The options for an override but without the include/exclude patterns. #[derive(Debug, Clone, PartialEq, Eq, Hash, Combine, get_size2::GetSize)] -pub(super) struct InnerOverrideOptions { +pub(crate) struct InnerOverrideOptions { /// Raw rule options as specified in the configuration. /// Used when multiple overrides match a file and need to be merged. - pub(super) rules: Option, + pub(crate) rules: Option, - pub(super) analysis: Option, + pub(crate) analysis: Option, } -/// The settings that can vary between individual files. -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Combine, get_size2::GetSize)] -pub(super) struct FileOptions { - pub(super) type_checking_preset: Option>, +/// A failure to resolve a project's or standalone script's program settings. +#[derive(Debug, Error)] +pub enum ToProgramSettingsError { + /// The explicitly configured Python environment could not be resolved. + #[error(transparent)] + PythonEnvironment(SitePackagesDiscoveryError), + + /// No explicitly configured Python environment was available, and discovery failed. + #[error("Failed to discover local Python environment")] + PythonEnvironmentDiscovery(#[source] SitePackagesDiscoveryError), - /// Raw rule options, preserved so multiple configuration layers can be merged. - pub(super) rules: Option, + /// The resolved Python environment did not contain usable site-packages directories. + #[error("Failed to discover the site-packages directory")] + SitePackagesDiscovery(#[source] SitePackagesDiscoveryError), + + /// One of the configured Python module search paths could not be resolved. + #[error(transparent)] + SearchPaths(#[from] SearchPathSettingsError), +} - pub(super) analysis: Option, +impl ToProgramSettingsError { + /// Returns the program-settings error without its optional diagnostic detail. + pub(crate) fn message(&self) -> String { + self.to_string() + } + + /// Returns details for failures whose message only identifies the failed operation. + pub(crate) fn hint(&self) -> Option { + match self { + Self::PythonEnvironmentDiscovery(error) | Self::SitePackagesDiscovery(error) => { + Some(error.to_string()) + } + Self::PythonEnvironment(_) | Self::SearchPaths(_) => None, + } + } + + pub(crate) fn setting_source<'a>( + &self, + options: &'a Options, + ) -> Option<(&'a ValueSource, Option)> { + let environment = options.environment.as_ref()?; + + match self { + Self::PythonEnvironment(_) | Self::SitePackagesDiscovery(_) => environment + .python + .as_ref() + .map(|setting| (setting.source(), setting.range())), + Self::SearchPaths( + SearchPathSettingsError::FailedToReadVersionsFile { .. } + | SearchPathSettingsError::VersionsParseError(_), + ) => environment + .typeshed + .as_ref() + .map(|setting| (setting.source(), setting.range())), + Self::PythonEnvironmentDiscovery(_) + | Self::SearchPaths(SearchPathSettingsError::InvalidSearchPath(_)) => None, + } + } } /// Error returned when the settings can't be resolved because of a hard error. @@ -3365,8 +3485,8 @@ impl OptionDiagnostic { err: impl Display, ) -> Self { match value.source() { - ValueSource::File(file_path) => { - if let Ok(file) = system_path_to_file(db, &**file_path) { + ValueSource::File(_) | ValueSource::ScriptMetadata(_) => { + if let Some(file) = value.source().file(db) { let concise_message = std::mem::take(&mut self.message); self.with_concise_message(concise_message) .with_message(format_args!("Invalid {value_label}")) @@ -3394,9 +3514,9 @@ impl OptionDiagnostic { SubDiagnosticSeverity::Info, "The {value_label} was specified in the editor settings.", )), - ValueSource::UvWorkspace => self.sub(SubDiagnostic::new( + ValueSource::UvMetadata => self.sub(SubDiagnostic::new( SubDiagnosticSeverity::Info, - format!("The {value_label} was provided by uv workspace metadata."), + format!("The {value_label} was provided by uv metadata."), )), } } diff --git a/crates/ty_project/src/metadata/pyproject.rs b/crates/ty_project/src/metadata/pyproject.rs index 5af6152f59..6b336fe00e 100644 --- a/crates/ty_project/src/metadata/pyproject.rs +++ b/crates/ty_project/src/metadata/pyproject.rs @@ -7,6 +7,7 @@ use ruff_ranged_value::{RangedValue, ValueSource, ValueSourceGuard}; use rustc_hash::FxHashSet; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, Bound}; +use std::fmt; use std::ops::Deref; use std::str::FromStr; use strum::IntoEnumIterator; @@ -225,6 +226,7 @@ impl PyProject { Self::deserialize_toml(content) } + /// Parses without retaining source spans, for a caller that only reads values. pub(crate) fn from_toml_str_without_spans( content: &str, source: ValueSource, @@ -262,7 +264,7 @@ pub struct Project { /// `PackageMetadata::from_pyproject` reports missing names. pub(crate) name: Option>, /// The version of the project - pub(crate) version: Option>, + version: Option>, /// The Python versions this project is compatible with. pub(crate) requires_python: Option>, /// The requirements installed alongside the project. @@ -270,92 +272,127 @@ pub struct Project { /// Kept as written rather than as parsed requirements: one entry ty cannot /// make sense of must not cost it the whole file, and a `[project]` table it /// fails to deserialize is a project it fails to load. - pub dependencies: Option>, + dependencies: Option>, /// The requirements of each extra, installed only when the extra is asked for. - pub optional_dependencies: Option>>, + optional_dependencies: Option>>, } -impl Project { - pub(super) fn resolve_requires_python_lower_bound( - &self, - ) -> Result>, ResolveRequiresPythonError> { - let Some(requires_python) = self.requires_python.as_ref() else { - return Ok(None); - }; - - tracing::debug!("Resolving requires-python constraint: `{requires_python}`"); - - let ranges = release_specifiers_to_ranges((**requires_python).clone()); - let Some((lower, _)) = ranges.bounding_range() else { - return Ok(None); - }; - - let version = match lower { - // Ex) `>=3.10.1` -> `>=3.10` - Bound::Included(version) => version, - - // Ex) `>3.10.1` -> `>=3.10` or `>3.10` -> `>=3.10` - // The second example looks obscure at first but it is required because - // `3.10.1 > 3.10` is true but we only have two digits here. So including 3.10 is the - // right move. Overall, using `>` without a patch release is most likely bogus. - Bound::Excluded(version) => version, - - // Ex) `<3.10` or `` - Bound::Unbounded => { - return Err(ResolveRequiresPythonError::NoLowerBound( - requires_python.to_string(), - )); - } - }; +pub(super) fn resolve_requires_python_lower_bound( + requires_python: &RangedValue, +) -> Result>, ResolveRequiresPythonError> { + tracing::debug!("Resolving requires-python constraint: `{requires_python}`"); + + let ranges = release_specifiers_to_ranges((**requires_python).clone()); + let Some((lower, _)) = ranges.bounding_range() else { + return Ok(None); + }; + + let version = match lower { + // Ex) `>=3.10.1` -> `>=3.10` + Bound::Included(version) => version, + + // Ex) `>3.10.1` -> `>=3.10` or `>3.10` -> `>=3.10` + // The second example looks obscure at first but it is required because + // `3.10.1 > 3.10` is true but we only have two digits here. So including 3.10 is the + // right move. Overall, using `>` without a patch release is most likely bogus. + Bound::Excluded(version) => version, + + // Ex) `<3.10` or `` + Bound::Unbounded => { + return Err(ResolveRequiresPythonError::NoLowerBound( + requires_python.to_string(), + )); + } + }; - // Take the major and minor version - let mut versions = version.release().iter().take(2); + // Take the major and minor version + let mut versions = version.release().iter().take(2); - let Some(major) = versions.next().copied() else { - return Ok(None); - }; + let Some(major) = versions.next().copied() else { + return Ok(None); + }; - let minor = versions.next().copied().unwrap_or_default(); + let minor = versions.next().copied().unwrap_or_default(); - tracing::debug!("Resolved requires-python constraint to: {major}.{minor}"); + tracing::debug!("Resolved requires-python constraint to: {major}.{minor}"); - let major = - u8::try_from(major).map_err(|_| ResolveRequiresPythonError::TooLargeMajor(major))?; - let minor = - u8::try_from(minor).map_err(|_| ResolveRequiresPythonError::TooLargeMinor(minor))?; + let major = + u8::try_from(major).map_err(|_| ResolveRequiresPythonError::TooLargeMajor(major))?; + let minor = + u8::try_from(minor).map_err(|_| ResolveRequiresPythonError::TooLargeMinor(minor))?; - let lower_bound = PythonVersion::from((major, minor)); - let supported_version = SupportedPythonVersion::iter() - .find(|supported_version| supported_version.to_python_version() >= lower_bound); + let lower_bound = PythonVersion::from((major, minor)); + let supported_version = SupportedPythonVersion::iter() + .find(|supported_version| supported_version.to_python_version() >= lower_bound); - let Some(supported_version) = supported_version else { - return Err(ResolveRequiresPythonError::NoSupportedVersion( - requires_python.to_string(), - )); - }; + let Some(supported_version) = supported_version else { + return Err(ResolveRequiresPythonError::NoSupportedVersion( + requires_python.to_string(), + )); + }; - Ok(Some( - requires_python.clone().map_value(|_| supported_version), - )) - } + Ok(Some( + requires_python.clone().map_value(|_| supported_version), + )) } -#[derive(Debug, Error)] +#[derive(Debug)] pub enum ResolveRequiresPythonError { - #[error("The major version `{0}` is larger than the maximum supported value 255")] TooLargeMajor(u64), - #[error("The minor version `{0}` is larger than the maximum supported value 255")] TooLargeMinor(u64), - #[error( - "value `{0}` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`." - )] NoLowerBound(String), - #[error( - "value `{0}` does not include any Python version supported by ty. Adjust `requires-python` to include a supported Python 3 version or specify `environment.python-version` explicitly." - )] NoSupportedVersion(String), } +impl ResolveRequiresPythonError { + /// Returns the error without its optional recovery guidance. + pub(crate) fn message(&self) -> String { + match self { + Self::TooLargeMajor(version) => { + format!( + "The major version `{version}` is larger than the maximum supported value 255" + ) + } + Self::TooLargeMinor(version) => { + format!( + "The minor version `{version}` is larger than the maximum supported value 255" + ) + } + Self::NoLowerBound(version) => { + format!("value `{version}` does not contain a lower bound") + } + Self::NoSupportedVersion(version) => { + format!("value `{version}` does not include any Python version supported by ty") + } + } + } + + /// Returns guidance for fixing the invalid Python requirement, when available. + pub(crate) fn hint(&self) -> Option<&'static str> { + match self { + Self::NoLowerBound(_) => Some( + "Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.", + ), + Self::NoSupportedVersion(_) => Some( + "Adjust `requires-python` to include a supported Python 3 version or specify `environment.python-version` explicitly.", + ), + Self::TooLargeMajor(_) | Self::TooLargeMinor(_) => None, + } + } +} + +impl fmt::Display for ResolveRequiresPythonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message())?; + if let Some(hint) = self.hint() { + write!(f, ". {hint}")?; + } + Ok(()) + } +} + +impl std::error::Error for ResolveRequiresPythonError {} + #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct Tool { @@ -370,7 +407,7 @@ pub struct Tool { pub struct Uv { /// uv's own development dependencies, which predate PEP 735 and are still /// widely written. uv treats them as the `dev` group, and so does this. - pub dev_dependencies: Option>, + dev_dependencies: Option>, } /// One entry of a PEP 735 dependency group: a requirement, or another group. diff --git a/crates/ty_project/src/metadata/script.rs b/crates/ty_project/src/metadata/script.rs deleted file mode 100644 index 1800ddc9b5..0000000000 --- a/crates/ty_project/src/metadata/script.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::sync::Arc; - -use ruff_db::Db; -use ruff_db::files::File; -use ruff_db::source::source_text; -use ruff_db::system::SystemPathBuf; -use ruff_python_ast::script::ScriptTag; -use ruff_ranged_value::ValueSource; - -use crate::metadata::pyproject::PyProject; - -/// Returns the PEP 723 metadata embedded in `file`. -#[salsa::tracked(returns(ref))] -pub(crate) fn script_metadata(db: &dyn Db, file: File) -> Option> { - let path = file.path(db); - if path.is_vendored_path() { - return None; - } - - let source = source_text(db, file); - if source.is_notebook() { - return None; - } - - let tag = ScriptTag::parse(source.as_bytes())?; - let value_source = ValueSource::File(Arc::new(SystemPathBuf::from(path.as_str()))); - - PyProject::from_toml_str_without_spans(tag.metadata(), value_source) - .map(Box::new) - .ok() -} diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index d5b06f64a5..4d11f286d0 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -5,8 +5,8 @@ use ty_combine::Combine; use ty_python_semantic::lint::RuleSelection; use ty_python_semantic::{AnalysisSettings, ExperimentalSettings}; -use crate::metadata::options::{FileOptions, InnerOverrideOptions, Options, OutputFormat}; -use crate::metadata::script::script_metadata; +use crate::metadata::options::{InnerOverrideOptions, Options, OutputFormat}; +use crate::script::Script; use ruff_db::system::SystemPath; use crate::glob::{GlobFilterCheckMode, IncludeResult}; @@ -77,7 +77,7 @@ impl Settings { /// Project-wide, and deliberately not part of [`OverrideSettings`]: an /// experimental feature is a language feature, and a module's meaning cannot /// depend on which file is asking about it. - pub fn experimental(&self) -> &ExperimentalSettings { + pub(crate) fn experimental(&self) -> &ExperimentalSettings { &self.experimental } @@ -252,51 +252,32 @@ impl Override { /// Resolves the settings for a given file. #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] pub(crate) fn file_settings(db: &dyn Db, file: File) -> FileSettings { - let project = db.project(); - - // a PEP 723 script's own `[tool.ty]` block is one more configuration layer for - // that single file — the highest-precedence one — and not a replacement for the - // project's configuration. a script that says nothing about a rule is held to - // whatever the project it sits in says about that rule, the same as any other - // file, so a project can relax a rule for a vendored script or tighten one - // without every site needing its own suppression comment. - // - // an explicit `--config` is the one thing that outranks the block: it is a - // deliberate instruction from the command line about this very run, so the - // block is dropped rather than layered. - // - // ignore script settings for files that aren't checked as part of the project. - // check for metadata first so files without metadata don't depend on the - // low-durability open-file set. - let script_layer = if let Some(script) = script_metadata(db, file) - && crate::should_check_file(db, file) - && project.metadata(db).config_file_override().is_none() - { - script - .options() - .map(|options| { - // a script layer varies `rules` and `analysis`; the preset those start from is - // a project-level decision, resolved in `merge_overrides` - let FileOptions { - rules, - analysis, - type_checking_preset: _, - } = options.file_options(); - Arc::new(InnerOverrideOptions { rules, analysis }) - }) - .filter(|layer| layer.rules.is_some() || layer.analysis.is_some()) - } else { - None + // a script resolves its own settings — its inline block layered over the project's — but it + // is still a file the project's `[[overrides]]` can name, so the two are read the same way: + // the script's settings supply the base and the overrides matching its path apply on top. + let script = Script::for_file(db, file); + let script_settings = script.map(|script| script.settings(db)); + let script_layer = script.and_then(|script| script.override_layer(db).clone()); + let own_settings = || { + script_settings.map_or(FileSettings::Global, |settings| { + FileSettings::File(Arc::new(OverrideSettings { + rules: settings.rules().clone(), + analysis: settings.analysis().clone(), + })) + }) }; - let settings = project.settings(db); + // the overrides to match are always the project's. one written inside a script's own inline + // block names files the script does not speak for, so it is not honoured — a script + // configures itself with its top-level tables. + let settings = db.project().settings(db); let path = match file.path(db) { ruff_db::files::FilePath::System(path) => path, ruff_db::files::FilePath::SystemVirtual(_) | ruff_db::files::FilePath::Vendored(_) => { // a file with no system path matches no `include`/`exclude` glob, but a // script carries its configuration in its own text, so that still applies - return script_settings(db, script_layer); + return own_settings(); } }; @@ -307,12 +288,13 @@ pub(crate) fn file_settings(db: &dyn Db, file: File) -> FileSettings { let Some(first) = matching_overrides.next() else { // If the file matches no override, it uses the global settings. - return script_settings(db, script_layer); + return own_settings(); }; let Some(second) = matching_overrides.next() else { tracing::debug!("Applying override for file `{path}`: {}", first.files); // If the file matches only one override, return that override's settings. + // `first.settings` is resolved without the script, so a script has to be replayed. return match script_layer { Some(layer) => merge_overrides(db, vec![Arc::clone(&first.options)], Some(layer)), None => FileSettings::File(Arc::clone(&first.settings)), @@ -338,16 +320,7 @@ pub(crate) fn file_settings(db: &dyn Db, file: File) -> FileSettings { tracing::debug!("Applying multiple overrides for file `{path}`: {filters}"); } - merge_overrides(db, overrides, None) -} - -/// The settings for a file that matches no override, which for a PEP 723 script -/// still has to account for the script's own `[tool.ty]` block. -fn script_settings(db: &dyn Db, script: Option>) -> FileSettings { - match script { - Some(script) => merge_overrides(db, Vec::new(), Some(script)), - None => FileSettings::Global, - } + merge_overrides(db, overrides, script_layer) } /// Merges multiple override options, caching the result. @@ -355,9 +328,6 @@ fn script_settings(db: &dyn Db, script: Option>) -> Fi /// Overrides often apply to multiple files. This query ensures that we avoid /// resolving the same override combinations multiple times. /// -/// `script` is a PEP 723 script's own `[tool.ty]` block. It applies to exactly one -/// file, so it does not share the caching benefit the override list has, but it -/// takes part in the same merge because it is just one more layer. #[salsa::tracked(returns(clone), heap_size=ruff_memory_usage::heap_size)] fn merge_overrides( db: &dyn Db, @@ -386,15 +356,17 @@ fn merge_overrides( // An override varies `rules` and `analysis`, never the preset those start from. let preset = metadata - .options_in_precedence_order(metadata.options()) - .find_map(Options::configured_type_checking_preset) + .options_in_precedence_order(metadata.options(), metadata.uv_workspace_options.as_deref()) + .find_map(crate::metadata::options::Options::configured_type_checking_preset) .unwrap_or_default(); // Merge with the project level options by replaying the individual options // in the correct precedence order. - for options in - metadata.options_in_precedence_order_with_script(metadata.options(), script.as_ref()) - { + for options in metadata.options_in_precedence_order_with_script( + metadata.options(), + script.as_ref(), + metadata.uv_workspace_options.as_deref(), + ) { merged.rules.combine_with(options.rules.clone()); merged.analysis.combine_with(options.analysis.clone()); } @@ -416,7 +388,7 @@ fn merge_overrides( /// The resolved settings for a file. #[derive(Debug, Eq, PartialEq, Clone, get_size2::GetSize)] -pub enum FileSettings { +pub(crate) enum FileSettings { /// The file uses the global settings. Global, diff --git a/crates/ty_project/src/metadata/uv.rs b/crates/ty_project/src/metadata/uv.rs deleted file mode 100644 index 6d60465dcf..0000000000 --- a/crates/ty_project/src/metadata/uv.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::path::PathBuf; - -use pep440_rs::Version; -use ruff_db::system::{Command, System, SystemPath, SystemPathBuf, WhichError}; -use ruff_ranged_value::{RangedValue, ValueSource}; -use serde::Deserialize; -use thiserror::Error; -use ty_static::EnvVars; - -use super::python_version::SupportedPythonVersion; - -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] -pub(super) struct UvWorkspace { - root: SystemPathBuf, - environment: Option, - python_version: Option>, -} - -/// The uv to run, or why there is none to run. -/// -/// `UV` names one outright; otherwise it is looked up on the path. A caller that -/// is deciding whether to *offer* something uv would do needs the same answer as -/// the caller about to run it, which is why this is asked rather than assumed. -pub fn executable(system: &dyn System) -> Result { - match system.env_var(EnvVars::UV) { - Ok(uv) => Ok(SystemPathBuf::from(uv)), - Err(_) => system.which("uv"), - } -} - -impl UvWorkspace { - pub(super) fn discover( - path: &SystemPath, - system: &dyn System, - ) -> Result { - let uv = executable(system) - .map_err(uv_executable_error) - .map_err(UvWorkspaceError::Invocation)? - .into_string(); - - // `uv check` has already selected and synchronized the environment. Keep this query - // read-only so package selection and `--isolated` aren't overwritten by a second sync. - let mut command = Command::new(uv); - command - .args(["workspace", "metadata", "--frozen", "--active"]) - .current_dir(path); - let output = system - .run_command(command) - .map_err(UvWorkspaceError::Invocation)?; - - if !output.status.success() { - return Err(UvWorkspaceError::CommandFailed { - status: output.status, - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - - Self::from_metadata(&output.stdout, system) - } - - pub(super) fn from_metadata( - metadata: &[u8], - system: &dyn System, - ) -> Result { - let metadata = serde_json::from_slice::(metadata) - .map_err(UvWorkspaceError::InvalidMetadata)?; - - let root = existing_directory(metadata.workspace_root, "workspace root", system)?; - - let (environment, python_version) = match metadata.environment { - Some(environment) => ( - Some(existing_directory( - environment.root, - "environment root", - system, - )?), - Some(resolve_python_version(&environment.python.version)?), - ), - None => (None, None), - }; - - Ok(Self { - root, - environment, - python_version, - }) - } - - pub(super) fn root(&self) -> &SystemPath { - &self.root - } - - pub(super) fn environment(&self) -> Option<&SystemPath> { - self.environment.as_deref() - } - - pub(super) fn python_version(&self) -> Option<&RangedValue> { - self.python_version.as_ref() - } -} - -fn uv_executable_error(error: WhichError) -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("failed to resolve uv executable: {error}"), - ) -} - -fn resolve_python_version( - version: &Version, -) -> Result, UvWorkspaceError> { - let [major, minor, ..] = version.release() else { - return Err(UvWorkspaceError::InvalidPythonVersion(version.clone())); - }; - let version = format!("{major}.{minor}") - .parse::() - .map_err(|_| UvWorkspaceError::InvalidPythonVersion(version.clone()))?; - - Ok(RangedValue::new(version, ValueSource::UvWorkspace)) -} - -fn existing_directory( - path: PathBuf, - description: &'static str, - system: &dyn System, -) -> Result { - let path = match SystemPathBuf::from_path_buf(path) { - Ok(path) => path, - Err(path) => return Err(UvWorkspaceError::NonUnicodePath { description, path }), - }; - - if !system.is_directory(&path) { - return Err(UvWorkspaceError::MissingDirectory { description, path }); - } - - Ok(path) -} - -#[derive(Debug, Error)] -pub(super) enum UvWorkspaceError { - #[error("Failed to invoke `uv workspace metadata`: {0}")] - Invocation(#[source] std::io::Error), - - #[error("`uv workspace metadata` failed with status {status}: {stderr}")] - CommandFailed { - status: std::process::ExitStatus, - stderr: String, - }, - - #[error("invalid `uv workspace metadata` JSON: {0}")] - InvalidMetadata(serde_json::Error), - - #[error("unsupported Python version `{0}` returned by `uv workspace metadata`")] - InvalidPythonVersion(Version), - - #[error("non-Unicode {description} returned by `uv workspace metadata`: `{path}`", path = path.display())] - NonUnicodePath { - description: &'static str, - path: PathBuf, - }, - - #[error("missing {description} returned by `uv workspace metadata`: `{path}`")] - MissingDirectory { - description: &'static str, - path: SystemPathBuf, - }, -} - -#[derive(Deserialize)] -struct WorkspaceMetadata { - workspace_root: PathBuf, - environment: Option, -} - -#[derive(Deserialize)] -struct WorkspaceEnvironment { - root: PathBuf, - python: WorkspacePython, -} - -#[derive(Deserialize)] -struct WorkspacePython { - version: Version, -} - -#[cfg(test)] -mod tests { - use ruff_db::system::{SystemPath, TestSystem}; - use ty_static::EnvVars; - - use super::{UvWorkspace, UvWorkspaceError}; - - #[test] - fn rejects_invalid_metadata() { - let system = TestSystem::default(); - - assert!(matches!( - UvWorkspace::from_metadata(b"{", &system), - Err(UvWorkspaceError::InvalidMetadata(_)) - )); - } - - #[test] - fn explicit_uv_override_skips_path_lookup() { - let system = TestSystem::default(); - system.set_env_var(EnvVars::UV, "/custom/uv"); - - assert!(matches!( - UvWorkspace::discover(SystemPath::new("/app"), &system), - Err(UvWorkspaceError::Invocation(error)) - if error.kind() == std::io::ErrorKind::Unsupported - )); - } - - #[test] - fn environment_can_be_omitted() -> anyhow::Result<()> { - let system = TestSystem::default(); - system - .memory_file_system() - .write_file_all("/app/pyproject.toml", "[tool.uv.workspace]")?; - let metadata = br#"{ - "workspace_root": "/app" - }"#; - - let workspace = UvWorkspace::from_metadata(metadata, &system)?; - - assert!(workspace.environment().is_none()); - assert!(workspace.python_version().is_none()); - - Ok(()) - } - - #[test] - fn uses_environment_python_version() -> anyhow::Result<()> { - let system = TestSystem::default(); - system.memory_file_system().write_files_all([ - ("/app/pyproject.toml", "[tool.uv.workspace]"), - ("/env/marker", ""), - ])?; - let metadata = br#"{ - "workspace_root": "/app", - "environment": { - "root": "/env", - "python": { "version": "3.13.5" } - } - }"#; - - let workspace = UvWorkspace::from_metadata(metadata, &system)?; - - assert_eq!(workspace.environment(), Some(SystemPath::new("/env"))); - assert_eq!( - workspace.python_version().map(ToString::to_string), - Some("3.13".to_string()) - ); - - Ok(()) - } - - #[test] - fn rejects_unsupported_environment_python_version() -> anyhow::Result<()> { - let system = TestSystem::default(); - system.memory_file_system().write_files_all([ - ("/app/pyproject.toml", "[tool.uv.workspace]"), - ("/env/marker", ""), - ])?; - let metadata = br#"{ - "workspace_root": "/app", - "environment": { - "root": "/env", - "python": { "version": "3.16.0" } - } - }"#; - - assert!(matches!( - UvWorkspace::from_metadata(metadata, &system), - Err(UvWorkspaceError::InvalidPythonVersion(_)) - )); - - Ok(()) - } -} diff --git a/crates/ty_project/src/metadata/value.rs b/crates/ty_project/src/metadata/value.rs index 616a118080..e4fca48a02 100644 --- a/crates/ty_project/src/metadata/value.rs +++ b/crates/ty_project/src/metadata/value.rs @@ -17,7 +17,7 @@ use crate::glob::{ /// require different anchoring: /// /// * CLI: The path is relative to the current working directory -/// * Configuration file: The path is relative to the project's root. +/// * Configuration file: The path is relative to the project's or script's configuration root. #[derive( Debug, Clone, @@ -62,10 +62,10 @@ impl RelativePathBuf { } /// Resolves the absolute path for `self` based on its origin. - pub fn absolute(&self, project_root: &SystemPath, system: &dyn System) -> SystemPathBuf { + pub fn absolute(&self, configuration_root: &SystemPath, system: &dyn System) -> SystemPathBuf { let relative_to = match self.0.source() { - ValueSource::File(_) => project_root, - ValueSource::Cli | ValueSource::Editor | ValueSource::UvWorkspace => { + ValueSource::File(_) | ValueSource::ScriptMetadata(_) => configuration_root, + ValueSource::Cli | ValueSource::Editor | ValueSource::UvMetadata => { system.current_directory() } }; @@ -136,8 +136,8 @@ impl RelativeGlobPattern { kind: PortableGlobKind, ) -> Result { let relative_to = match self.0.source() { - ValueSource::File(_) => project_root, - ValueSource::Cli | ValueSource::Editor | ValueSource::UvWorkspace => { + ValueSource::File(_) | ValueSource::ScriptMetadata(_) => project_root, + ValueSource::Cli | ValueSource::Editor | ValueSource::UvMetadata => { system.current_directory() } }; diff --git a/crates/ty_project/src/script.rs b/crates/ty_project/src/script.rs new file mode 100644 index 0000000000..3ddfccc6a8 --- /dev/null +++ b/crates/ty_project/src/script.rs @@ -0,0 +1,532 @@ +use std::sync::Arc; + +use pep440_rs::VersionSpecifiers; +use ruff_db::Db as SourceDb; +use ruff_db::diagnostic::{ + Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, SubDiagnosticSeverity, +}; +use ruff_db::files::File; +use ruff_db::source::source_text; +use ruff_python_ast::script::ScriptTag; +use ruff_ranged_value::{RangedValue, ValueSource, ValueSourceGuard}; +use ruff_text_size::{Ranged, TextRange, TextSize}; +use serde::Deserialize; +use ty_combine::Combine; +use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings, UseDefaultStrategy}; +use ty_python_semantic::PythonVersionWithSource; +use ty_python_semantic::dependency::DependencyMetadata; + +use crate::metadata::options::{EnvironmentOptions, InnerOverrideOptions, Options, OptionsContext}; +use crate::metadata::pyproject::Tool; +use crate::metadata::settings::Settings; +use crate::metadata::value::RelativePathBuf; +use crate::uv::{DependencyMetadataError, UvMetadata, script_environment}; +use crate::{Db, ProjectMetadata}; + +/// A standalone PEP 723 script and its resolved settings. +#[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] +pub(crate) struct Script<'db> { + #[returns(copy)] + pub(crate) file: File, + + #[tracked] + #[returns(ref)] + pub(crate) settings: Settings, + + #[tracked] + #[returns(copy)] + pub(crate) program: Program<'db>, + + #[tracked] + #[returns(ref)] + pub(crate) python_version_with_source: PythonVersionWithSource, + + /// Whether the script's metadata, settings, and Python environment resolved without errors. + /// + /// For a script with invalid settings, `Program` is a best effort approximation + /// of the script's configuration. It's, therefore, important that ty doesn't run any destructive + /// operations or shows misleading diagnostics. That means, `--fix` should be a no-op and + /// `check_file` (and similar operations) should bail and only show the setting related diagnostics. + #[tracked] + #[returns(copy)] + pub(crate) has_valid_settings: bool, + + /// Diagnostics generated while parsing the script metadata and resolving its settings. + #[tracked] + #[returns(deref)] + pub(crate) settings_diagnostics: Box<[Diagnostic]>, + + /// The script's own `[tool.ty]` block, as a layer the project's `[[overrides]]` sit on top of. + /// + /// `settings` already has this folded in, but a file the project's `[[overrides]]` name is + /// resolved by replaying the layers in precedence order, so that path needs the block on its + /// own. `None` when the block configures nothing, or when an explicit `--config` outranks it. + #[tracked] + #[returns(ref)] + pub(crate) override_layer: Option>, +} + +#[salsa::tracked] +impl<'db> Script<'db> { + /// Returns the script for `file` without creating a second Salsa memo for ordinary files. + pub(crate) fn for_file(db: &'db dyn Db, file: File) -> Option { + // Most files are not scripts. Check the existing tag query first so ordinary files + // do not also allocate a tracked `script` memo just to cache another `None`. + script_tag(db, file)?; + script(db, file) + } + + /// Cache dependency declarations separately from settings, which can remain unchanged after + /// uv synchronizes an edit to the script's dependencies. + #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] + pub(crate) fn dependency_metadata( + self, + db: &'db dyn Db, + ) -> Result>, DependencyMetadataError> { + if !self.has_valid_settings(db) { + return Ok(None); + } + + let Some(metadata) = script_environment(db, self.file(db)) + .and_then(|environment| environment.uv_metadata(db)) + else { + return Ok(None); + }; + metadata + .dependency_metadata() + .map(|metadata| Some(Box::new(metadata))) + } +} + +impl get_size2::GetSize for Script<'_> {} + +/// Resolve the `Script` for `file` if it has a PEP 723 metadata block or `None` otherwise. +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn script(db: &dyn Db, file: File) -> Option> { + // Files without script metadata must not depend on the low-durability open-file set. + let tag = script_tag(db, file)?; + + // Never treat third-party files as scripts. + if !crate::is_project_file(db, file) { + return None; + } + + let mut diagnostics = ScriptConfigurationDiagnostics::default(); + let metadata = parse_script_metadata(file, tag, &mut diagnostics); + let environment = script_environment(db, file); + let uv_metadata = environment.and_then(|environment| environment.uv_metadata(db)); + + if let Some(error) = environment.and_then(|environment| environment.initialization_error(db)) { + diagnostics.report_invalid(uv_metadata_diagnostic(file, tag, error)); + } + + let configuration_root = file + .path(db) + .as_system_path() + .and_then(|path| path.parent()) + .unwrap_or_else(|| db.system().current_directory()); + let context = OptionsContext::Script(configuration_root); + + let project_metadata = db.project().metadata(db); + + let (options, override_layer) = resolve_script_options( + project_metadata, + &metadata, + uv_metadata, + file, + &mut diagnostics, + ); + let settings = resolve_script_settings(db, &options, context, &mut diagnostics); + let program_settings = resolve_script_program_settings( + db, + &options, + context, + project_metadata.name(), + file, + &mut diagnostics, + ); + + program_settings.search_paths.try_register_static_roots(db); + + let program = Program::from_settings(db, &program_settings); + + Some(Script::new( + db, + file, + settings, + program, + program_settings.python_version, + !diagnostics.has_invalid_settings, + diagnostics.diagnostics.into_boxed_slice(), + override_layer, + )) +} + +/// Returns the PEP 723 script tag embedded in `file`. +/// +/// Most files have no script tag. Boxing keeps the cached result compact when it is `None`. +#[salsa::tracked(returns(as_deref))] +pub fn script_tag(db: &dyn SourceDb, file: File) -> Option> { + let path = file.path(db); + if path.is_vendored_path() { + return None; + } + + let source = source_text(db, file); + if source.is_notebook() { + return None; + } + + ScriptTag::parse(source.as_bytes()).map(Box::new) +} + +fn parse_script_metadata( + file: File, + tag: &ScriptTag, + diagnostics: &mut ScriptConfigurationDiagnostics, +) -> ScriptMetadata { + let result = { + let _guard = ValueSourceGuard::with_source_map( + ValueSource::ScriptMetadata(file), + tag.source_map().clone(), + ); + toml::from_str::(tag.metadata()) + }; + + let mut metadata = match result { + Ok(metadata) => metadata, + Err(error) => { + let range = error.span().and_then(|span| { + let start = TextSize::try_from(span.start).ok()?; + let end = TextSize::try_from(span.end).ok()?; + Some(tag.source_map().map_range(TextRange::new(start, end))) + }); + + diagnostics.report_invalid(invalid_script_metadata_diagnostic( + file, + error.message(), + range, + )); + return ScriptMetadata::default(); + } + }; + + if let Some(tool) = metadata.tool.as_mut() { + for options in [tool.basedpython.as_mut(), tool.ty.as_mut()] + .into_iter() + .flatten() + { + options.prioritize_all_selectors(); + } + } + + metadata +} + +fn resolve_script_options( + project_metadata: &ProjectMetadata, + metadata: &ScriptMetadata, + uv_metadata: Option<&UvMetadata>, + file: File, + diagnostics: &mut ScriptConfigurationDiagnostics, +) -> (Options, Option>) { + // a script's own metadata block is one more configuration layer for that single + // file — the highest-precedence one — and not a replacement for the project's + // configuration. a script that says nothing about a rule is held to whatever the + // project it sits in says about that rule, the same as any other file, so a + // project can relax a rule for a vendored script or tighten one without every + // site needing its own suppression comment. + // + // an explicit `--config` is the one thing that outranks the block: it is a + // deliberate instruction from the command line about this very run, so the block + // is dropped rather than layered. + let script_layer = if project_metadata.config_file_override().is_some() { + None + } else { + Some(metadata.to_options(file, diagnostics)) + }; + + let uv_options = uv_metadata.map(|metadata| Options { + environment: Some(EnvironmentOptions { + python_version: metadata.python_version().cloned(), + python: metadata + .environment() + .map(|path| RelativePathBuf::new(path, ValueSource::UvMetadata)), + ..EnvironmentOptions::default() + }), + ..Options::default() + }); + + // the project's *search paths* are the one thing a script does not inherit. a script is + // resolved from where it sits, not from the project's source layout, so a `root` or + // `extra-paths` written for the project would point somewhere the script cannot reach — + // and a relative one is reported against the script as unresolvable. everything else the + // project says, rules and overrides included, still holds. + let mut project_options = project_metadata.options().clone(); + if let Some(environment) = project_options.environment.as_mut() { + environment.root = None; + environment.extra_paths = None; + } + + let mut options = Options::default(); + // Merge the options with CLI, LSP, user configuration, and fallback options + for layer in project_metadata.options_in_precedence_order_with_script( + &project_options, + script_layer.as_ref(), + uv_options.as_ref(), + ) { + options.combine_with(layer.clone()); + } + + // An explicit Python environment selects uv's interpreter, not the script's site-packages. + if let Some(environment) = uv_metadata.and_then(UvMetadata::environment) { + options.environment.get_or_insert_default().python = + Some(RelativePathBuf::new(environment, ValueSource::UvMetadata)); + } + + // Unlike Project's, default to `[]` for scripts (unless explicitly specified). + options + .environment + .get_or_insert_default() + .root + .get_or_insert_default(); + + // the block only becomes a layer if it actually configures something, so a script that + // merely declares dependencies does not displace anything + let override_layer = script_layer + .as_ref() + .map(|layer| InnerOverrideOptions { + rules: layer.rules.clone(), + analysis: layer.analysis.clone(), + }) + .filter(|layer| layer.rules.is_some() || layer.analysis.is_some()) + .map(Arc::new); + + (options, override_layer) +} + +fn resolve_script_settings( + db: &dyn Db, + options: &Options, + context: OptionsContext<'_>, + diagnostics: &mut ScriptConfigurationDiagnostics, +) -> Settings { + let (settings, settings_diagnostics) = match options.to_settings(db, context, &FallibleStrategy) + { + Ok(settings) => settings, + Err(error) => { + diagnostics.report_invalid(error.into_diagnostic().to_diagnostic()); + let Ok(settings) = options.to_settings(db, context, &UseDefaultStrategy); + settings + } + }; + diagnostics.extend( + settings_diagnostics + .into_iter() + .map(|diagnostic| diagnostic.to_diagnostic()), + ); + settings +} + +fn resolve_script_program_settings( + db: &dyn Db, + options: &Options, + context: OptionsContext<'_>, + project_name: &str, + file: File, + diagnostics: &mut ScriptConfigurationDiagnostics, +) -> ProgramSettings { + let (settings, settings_diagnostics) = match options.to_program_settings( + context, + project_name, + db.system(), + db.vendored(), + &FallibleStrategy, + ) { + Ok(settings) => settings, + Err(error) => { + let (source_file, range) = error + .setting_source(options) + .and_then(|(source, range)| source.file(db).map(|file| (file, range))) + .unwrap_or((file, None)); + + let mut diagnostic = + invalid_script_metadata_diagnostic(source_file, error.message(), range); + if let Some(hint) = error.hint() { + diagnostic.sub(SubDiagnostic::new(SubDiagnosticSeverity::Info, hint)); + } + diagnostics.report_invalid(diagnostic); + + let Ok(settings) = options.to_program_settings( + context, + project_name, + db.system(), + db.vendored(), + &UseDefaultStrategy, + ); + settings + } + }; + diagnostics.extend( + settings_diagnostics + .into_iter() + .map(|diagnostic| diagnostic.into_diagnostic(db).to_diagnostic()), + ); + settings +} + +/// PEP 723 metadata, whose Python requirement belongs at the top level rather than in `project`. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct ScriptMetadata { + requires_python: Option>, + tool: Option, +} + +impl ScriptMetadata { + fn to_options(&self, file: File, diagnostics: &mut ScriptConfigurationDiagnostics) -> Options { + let mut options = self.ty().unwrap_or_default(); + if let Err(error) = options.apply_requires_python(self.requires_python.as_ref()) { + let range = self.requires_python.as_ref().and_then(RangedValue::range); + let mut diagnostic = invalid_script_metadata_diagnostic(file, error.message(), range); + if let Some(hint) = error.hint() { + diagnostic.sub(SubDiagnostic::new(SubDiagnosticSeverity::Info, hint)); + } + diagnostics.report_invalid(diagnostic); + } + options + } + + /// The options configured in `[tool.basedpython]` and `[tool.ty]`. + /// + /// Both sections are honored; where they set the same option, `[tool.basedpython]` wins. + fn ty(&self) -> Option { + let tool = self.tool.as_ref()?; + tool.basedpython.clone().combine(tool.ty.clone()) + } +} + +#[derive(Default)] +struct ScriptConfigurationDiagnostics { + diagnostics: Vec, + has_invalid_settings: bool, +} + +impl ScriptConfigurationDiagnostics { + fn report_invalid(&mut self, diagnostic: Diagnostic) { + self.has_invalid_settings = true; + self.diagnostics.push(diagnostic); + } + + fn extend(&mut self, diagnostics: impl IntoIterator) { + self.diagnostics.extend(diagnostics); + } +} + +fn uv_metadata_diagnostic(file: File, tag: &ScriptTag, message: &str) -> Diagnostic { + let mut diagnostic = Diagnostic::new(DiagnosticId::UvMetadata, Severity::Error, message); + let mut annotation = Annotation::primary(Span::from(file).with_range(tag.range())); + annotation.hide_snippet(true); + diagnostic.annotate(annotation); + diagnostic +} + +fn invalid_script_metadata_diagnostic( + file: File, + message: impl std::fmt::Display, + range: Option, +) -> Diagnostic { + let mut diagnostic = Diagnostic::new( + DiagnosticId::InvalidScriptMetadata, + Severity::Error, + message, + ); + diagnostic.annotate(Annotation::primary( + Span::from(file).with_optional_range(range), + )); + diagnostic +} + +#[cfg(test)] +mod tests { + use ruff_db::files::system_path_to_file; + use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf}; + use ruff_db::testing::assert_function_query_was_not_run; + use ty_python_semantic::Db as _; + + use crate::db::testing::TestDb; + use crate::{Db as _, ProjectMetadata}; + + use super::{Script, script}; + + #[test] + fn ordinary_files_do_not_depend_on_open_files() -> anyhow::Result<()> { + let mut db = TestDb::new(ProjectMetadata::new( + "test", + SystemPathBuf::from("/project"), + )); + db.write_files([ + ("/project/ordinary.py", "value = 1\n"), + ("/project/opened.py", "value = 2\n"), + ])?; + let ordinary = system_path_to_file(&db, SystemPath::new("/project/ordinary.py"))?; + let opened = system_path_to_file(&db, SystemPath::new("/project/opened.py"))?; + + assert!(Script::for_file(&db, ordinary).is_none()); + let events = db.take_salsa_events(); + assert_function_query_was_not_run(&db, script, ordinary, &events); + + assert!(script(&db, ordinary).is_none()); + db.take_salsa_events(); + + db.project().open_file(&mut db, opened); + db.take_salsa_events(); + + assert!(script(&db, ordinary).is_none()); + let events = db.take_salsa_events(); + assert_function_query_was_not_run(&db, crate::should_check_file, ordinary, &events); + assert_function_query_was_not_run(&db, script, ordinary, &events); + + Ok(()) + } + + #[test] + fn equivalent_script_settings_share_programs() -> anyhow::Result<()> { + let mut db = TestDb::new(ProjectMetadata::new( + "test", + SystemPathBuf::from("/project"), + )); + db.write_dedented( + "/project/requirement.py", + r#" + # /// script + # requires-python = ">=3.12" + # /// + "#, + )?; + db.write_dedented( + "/project/nested/configured.py", + r#" + # /// script + # [tool.ty.environment] + # python-version = "3.12" + # /// + "#, + )?; + + let requirement = system_path_to_file(&db, SystemPath::new("/project/requirement.py"))?; + let configured = + system_path_to_file(&db, SystemPath::new("/project/nested/configured.py"))?; + + assert_eq!( + db.program_file(requirement).program(&db), + db.program_file(configured).program(&db) + ); + assert_ne!( + db.python_version_with_source(requirement), + db.python_version_with_source(configured) + ); + + Ok(()) + } +} diff --git a/crates/ty_project/src/uv.rs b/crates/ty_project/src/uv.rs new file mode 100644 index 0000000000..9c4a5f03c4 --- /dev/null +++ b/crates/ty_project/src/uv.rs @@ -0,0 +1,105 @@ +//! Runs uv commands and coordinates project and script environments. + +use ruff_db::system::{System, SystemPathBuf, WhichError}; +use ty_combine::Combine; +use ty_static::EnvVars; + +pub(crate) use command::{MetadataTarget, Uv, uv_executable_error}; +pub(crate) use environments::{ProjectEnvironment, ScriptEnvironmentCacheKey, script_environment}; +pub use environments::{ScriptEnvironmentAvailability, UvEnvironments, UvSyncChanges}; +pub(crate) use metadata::{DependencyMetadataError, UvMetadata, UvMetadataError}; +pub(crate) use service::{ + ScriptSyncRequest, ScriptSyncTask, UvMetadataResult, UvMetadataService, UvSyncTask, +}; + +mod command; +mod environments; +mod metadata; +mod service; + +/// The uv to run, or why there is none to run. +/// +/// `UV` names one outright; otherwise it is looked up on the path. A caller that +/// is deciding whether to *offer* something uv would do needs the same answer as +/// the caller about to run it, which is why this is asked rather than assumed. +pub fn executable(system: &dyn System) -> Result { + match system.env_var(EnvVars::UV) { + Ok(uv) => Ok(SystemPathBuf::from(uv)), + Err(_) => system.which("uv"), + } +} + +/// Controls which uv integrations ty uses. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + get_size2::GetSize, + serde::Deserialize, + serde::Serialize, +)] +#[serde(rename_all = "lowercase")] +pub enum UseUv { + /// Disable all uv integration. + #[default] + Off, + + /// Use uv to create environments for standalone scripts. + /// + /// This does not use uv for project discovery. + Scripts, + + /// Use uv for project discovery and standalone script environments. + On, +} + +impl UseUv { + /// Resolves the mode configured by the `TY_UV` environment variable. + pub fn from_system(system: &dyn System) -> Self { + match system.env_var(EnvVars::TY_UV).as_deref() { + Ok("1" | "true") => Self::On, + Ok("scripts") => Self::Scripts, + _ => Self::Off, + } + } + + pub(super) const fn workspace_discovery_enabled(self) -> bool { + matches!(self, Self::On) + } + + const fn script_environments_enabled(self) -> bool { + matches!(self, Self::Scripts | Self::On) + } +} + +impl Combine for UseUv { + fn combine_with(&mut self, other: Self) { + *self = other; + } +} + +#[cfg(test)] +mod tests { + use ruff_db::system::TestSystem; + use ty_static::EnvVars; + + use super::UseUv; + + #[test] + fn use_uv_from_system() { + let system = TestSystem::default(); + assert_eq!(UseUv::from_system(&system), UseUv::Off); + + system.set_env_var(EnvVars::TY_UV, "scripts"); + assert_eq!(UseUv::from_system(&system), UseUv::Scripts); + + system.set_env_var(EnvVars::TY_UV, "true"); + assert_eq!(UseUv::from_system(&system), UseUv::On); + + system.set_env_var(EnvVars::TY_UV, "off"); + assert_eq!(UseUv::from_system(&system), UseUv::Off); + } +} diff --git a/crates/ty_project/src/uv/command.rs b/crates/ty_project/src/uv/command.rs new file mode 100644 index 0000000000..a2f8edcd9c --- /dev/null +++ b/crates/ty_project/src/uv/command.rs @@ -0,0 +1,149 @@ +//! Constructs and executes uv metadata commands. + +use std::process::Output; + +use ruff_db::system::{Command, CommandExecutor, System, SystemPath, WhichError}; +use ty_static::EnvVars; + +use super::{UvMetadata, UvMetadataError}; + +#[derive(Clone)] +pub(crate) struct Uv { + executable: String, +} + +impl Uv { + pub(crate) fn new(system: &dyn System) -> Result { + let executable = match system.env_var(EnvVars::UV) { + Ok(executable) => executable, + Err(_) => system.which("uv")?.into_string(), + }; + + Ok(Self { executable }) + } + + /// Executes `uv workspace metadata` and parses and validates its output. + pub(crate) fn metadata( + &self, + system: &dyn System, + target: &MetadataTarget<'_>, + ) -> Result { + let output = system + .command_executor() + .ok_or_else(unsupported_command_execution) + .and_then(|executor| self.execute(executor, target)); + Self::parse_metadata_output(system, output) + } + + /// Executes `uv workspace metadata` without interpreting its output. + /// + /// This operation only requires a detached command executor, so it can run on a background + /// worker. + #[tracing::instrument(name = "Uv::execute", level = "debug", skip(self, executor))] + pub(crate) fn execute( + &self, + executor: &dyn CommandExecutor, + target: &MetadataTarget<'_>, + ) -> std::io::Result { + let mut command = Command::new(self.executable.as_str()); + command.args(["workspace", "metadata", "--quiet"]); + + match target { + MetadataTarget::Workspace(path) => { + // Use the environment selected by `uv check` without synchronizing it. + // Let uv apply its configured lockfile policy. + command.arg("--active").current_dir(path); + } + MetadataTarget::Script { path, python } => { + command.args(["--sync", "--script", path.as_str()]); + if let Some(python) = python { + command.args(["--python", python.as_str()]); + } + if let Some(parent) = path.parent() { + command.current_dir(parent); + } + } + } + + tracing::debug!( + "Running `{} {}`", + command.get_executable(), + command.get_args().join(" ") + ); + + let start = ruff_db::Instant::now(); + let output = executor.execute(command); + + tracing::debug!( + "uv metadata completed in {:.3}s", + start.elapsed().as_secs_f64() + ); + + output + } + + /// Parses and validates the output returned by [`Self::execute`]. + pub(crate) fn parse_metadata_output( + system: &dyn System, + output: std::io::Result, + ) -> Result { + let output = output.map_err(UvMetadataError::Invocation)?; + + if !output.status.success() { + return Err(UvMetadataError::CommandFailed { + status: output.status, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + UvMetadata::from_metadata(&output.stdout, system) + } +} + +/// The workspace or standalone script for which to request uv metadata. +#[derive(Debug)] +pub(crate) enum MetadataTarget<'path> { + /// The directory from which uv discovers the workspace, not necessarily the workspace root. + Workspace(&'path SystemPath), + /// A standalone Python script. + Script { + /// The script file passed to `--script`. + path: &'path SystemPath, + /// The optional `--python` argument. + python: Option<&'path SystemPath>, + }, +} + +pub(crate) fn uv_executable_error(error: WhichError) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("failed to resolve uv executable: {error}"), + ) +} + +pub(super) fn unsupported_command_execution() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "running commands is not supported by this system", + ) +} + +#[cfg(test)] +mod tests { + use ruff_db::system::TestSystem; + use ty_static::EnvVars; + + use super::Uv; + + #[test] + fn explicit_uv_override_skips_path_lookup() -> anyhow::Result<()> { + let system = TestSystem::default(); + system.set_env_var(EnvVars::UV, "custom-uv"); + + let uv = Uv::new(&system)?; + + assert_eq!(uv.executable, "custom-uv"); + + Ok(()) + } +} diff --git a/crates/ty_project/src/uv/environments.rs b/crates/ty_project/src/uv/environments.rs new file mode 100644 index 0000000000..01c109a478 --- /dev/null +++ b/crates/ty_project/src/uv/environments.rs @@ -0,0 +1,1327 @@ +//! Manages the Python environments used to check projects and standalone scripts. +//! +//! ty needs to know which Python version and packages are available when checking a file. For +//! project files, uv provides metadata about the workspace's environment. Standalone scripts can +//! declare their own requirements in inline metadata, so uv may need to create and synchronize +//! separate environments for them. +//! +//! The project environment is resolved during initial discovery. The file index identifies +//! standalone scripts by reading their inline metadata, but does not synchronize their environments. +//! +//! The CLI requests synchronization for indexed scripts and applies the results before checking. +//! Checks use the environment available when they run; they never invoke uv. Waiting before the +//! check is necessary because the script's dependencies and Python version must be known to +//! produce accurate diagnostics. +//! +//! The language server requests synchronization for indexed scripts when opening a project, +//! including scripts that are not open in the editor. It also requests synchronization when +//! scripts are discovered, opened or saved, or when a file-watcher event reports a change to a +//! closed script. Project metadata is refreshed when configuration changes. +//! +//! These operations run in the background because synchronizing scripts can create environments +//! and install packages. Waiting for them would increase the latency of other editor requests; +//! for example, semantic tokens should remain available while synchronization is running. +//! Scripts are discovered independently of diagnostics because workspace symbols and references +//! also need their environments. +//! This avoids a rust-analyzer-like experience where editor operations wait for `cargo check` to +//! complete before becoming available. +//! +//! While a script's initial environment is unavailable, the language server defers its semantic +//! diagnostics to avoid incorrect missing-dependency errors. Document pull requests receive an +//! empty diagnostic report, while workspace diagnostic requests are suspended. After applying the +//! synchronization result, the server resumes suspended requests and refreshes diagnostics. +//! +//! Refreshing an available environment does not defer diagnostics. Projects and scripts continue +//! using their most recently applied environments until the refresh results are applied. +//! +//! uv reads files from disk, not from the editor. If a user adds a script metadata block to an +//! open file, ty does not request synchronization until the file is saved. It keeps checking the +//! file using ty's settings, so existing diagnostics stay visible. Once the file is saved and uv +//! finishes synchronization, ty checks it again using the environment returned by uv. +//! +//! CLI watch mode also schedules requests in the background after filesystem changes, but delays +//! the next check until those requests have completed. Repeated changes to a project or script +//! are combined so only the latest requested update runs after the current one. +//! +//! The main loop applies project metadata by rediscovering the existing project, including when uv +//! fails. Each script's virtual environment is represented by a stable [`ScriptEnvironment`] Salsa +//! input. Updating these inputs invalidates semantic queries that depend on the Python version or +//! module search paths, ensuring that checks are rerun after synchronization. +//! +//! Applying an update cancels active queries and waits for their database snapshots to be dropped. +//! A query waiting for an existing environment input to be updated would therefore prevent that +//! update. Semantic queries therefore use the available environment without waiting for +//! synchronization. The host applies results through [`UvEnvironments::poll_sync`]. +//! +//! # Scheduling and capacity +//! +//! [`UvEnvironments::request_sync`] owns request construction, coalescing, and submission in one +//! call. The request and result queues have no fixed capacity, so submission does not wait for uv. +//! Each project or script has at most one job queued, running, or awaiting result processing. A +//! newer request replaces the latest follow-up rather than adding another job. Queue lengths are +//! therefore bounded by the number of projects and scripts, not the number of changes. The worker +//! count limits concurrent uv processes, but the queues do not apply backpressure to the host. +//! +//! A newer request cancels the previous job if a worker has not started it yet. Running uv processes +//! are allowed to finish. Both cancelled and executed jobs report completion; only then can +//! `poll_sync` submit the latest follow-up, keeping the same progress reporter. This avoids both +//! overlapping synchronizations for one environment and accumulating cancelled queue entries. + +use std::hash::Hasher; +use std::sync::Arc; + +use crossbeam::channel::Receiver; +use parking_lot::Mutex; +use ruff_cache::{CacheKey, CacheKeyHasher}; +use ruff_db::FxDashMap; +use ruff_db::cancellation::CancellationTokenSource; +use ruff_db::files::{File, Files}; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use salsa::Setter; + +use crate::script::script_tag; +use crate::uv::{ + ScriptSyncRequest, ScriptSyncTask, Uv, UvMetadata, UvMetadataResult, UvMetadataService, + UvSyncTask, +}; +use crate::{Db, ProjectReloadResult, ProjectSyncProgressFactory, UseUv, UvSyncProgress}; + +type ProgressFactory<'factory> = + dyn Fn(&dyn Db, File) -> Option> + 'factory; + +/// Returns the Salsa input representing `file`'s script environment. +/// +/// The CLI and language server normally synchronize the script's virtual environment before it +/// is needed. This function never invokes uv; it returns the [`ScriptEnvironment`] input so +/// semantic queries can depend on it. +/// +/// If no [`ScriptEnvironment`] exists, creates one without uv metadata. Its identity +/// remains the same when synchronization later provides that metadata, just as a [`File`] +/// continues to identify the same path when a previously nonexistent file is created. Updating +/// the existing input ensures Salsa invalidates queries that read it before initialization. +/// +/// Returns `None` if script integration is disabled or the script is not an actual file on disk. +pub(crate) fn script_environment(db: &dyn Db, file: File) -> Option { + db.uv_environments().environment(db, file) +} + +/// Coordinates project and PEP 723 script environments using `uv metadata`. +#[derive(Clone, Default)] +pub struct UvEnvironments { + inner: Arc, +} + +impl UvEnvironments { + pub(crate) fn new(use_uv: UseUv) -> Self { + Self { + inner: Arc::new(UvEnvironmentsInner { + use_uv, + ..UvEnvironmentsInner::default() + }), + } + } + + /// Requests fresh workspace metadata for project rediscovery. + pub fn request_project_sync( + &self, + db: &dyn Db, + path: &SystemPath, + make_progress: &ProjectSyncProgressFactory<'_>, + ) { + let (progress, cancellation) = { + let mut project = self.inner.project.lock(); + if let Some(sync) = project.as_mut() { + sync.next_request = Some(path.to_path_buf()); + sync.cancellation.cancel(); + return; + } + + let progress = make_progress(db, db.project()); + let cancellation = CancellationTokenSource::new(); + let token = cancellation.token(); + *project = Some(ProjectSync { + next_request: None, + cancellation, + }); + (progress, token) + }; + + tracing::debug!("Requested workspace metadata for `{path}`"); + self.inner.sync_service.schedule_one( + db.system(), + UvSyncTask::Workspace(path.to_path_buf()), + cancellation, + progress, + ); + } + + /// Returns a receiver for background synchronization wakeups. + /// + /// A wakeup indicates that synchronization results may be ready to process with + /// [`poll_sync`](Self::poll_sync). Wakeups are coalesced, so one signal can represent + /// multiple completed synchronizations. + /// + /// The CLI and language-server main loops wait on this receiver alongside their other events. + /// When signaled, they call [`poll_sync`](Self::poll_sync) to apply project and script results + /// and refresh the affected diagnostics. + pub fn sync_wakeups(&self) -> Receiver<()> { + self.inner.sync_wakeups.clone() + } + + /// Returns whether `file`'s environment is [`Pending`](ScriptEnvironmentAvailability::Pending). + /// + /// A `false` result does not guarantee that initialization has finished. It may not have been + /// requested yet, and another database handle can request it after this call. Callers must submit + /// any required initial synchronization before scheduling operations that rely on this check. + /// + /// Refreshing an available environment does not make it pending. A pending environment stays + /// pending until [`poll_sync`](Self::poll_sync) applies its result, even if uv has finished. + /// + /// Salsa does not track changes to the pending state. + pub fn is_initialization_pending(&self, db: &dyn Db, file: File) -> bool { + if !self.is_enabled() || script_tag(db, file).is_none() { + return false; + } + + self.existing_entry(file).is_some_and(|entry| { + matches!( + *entry.lock(), + ScriptEnvironmentState::Synchronizing { + availability: ScriptEnvironmentAvailability::Pending, + .. + } + ) + }) + } + + /// Returns whether any script's initial environment is unavailable. + /// + /// Like [`Self::is_initialization_pending`], this only covers requested synchronizations. + /// Refreshing an available environment does not make it unavailable again. Project + /// environments are initialized during discovery, before background requests are scheduled. + pub fn has_pending_initializations(&self) -> bool { + self.inner.scripts.iter().any(|entry| { + matches!( + *entry.lock(), + ScriptEnvironmentState::Synchronizing { + availability: ScriptEnvironmentAvailability::Pending, + .. + } + ) + }) + } + + /// Requests background synchronization for `file`'s environment. + /// + /// If this call creates the script's first `ScriptEnvironment`, `availability` determines + /// whether it can be used while synchronization runs. An existing `ScriptEnvironment` + /// remains available, even if its virtual environment has not previously been synchronized. + /// + /// If another synchronization is pending, records the latest request to run afterward and + /// cancels the queued job if it has not started. Running uv processes are allowed to finish. + /// The follow-up reuses the existing progress reporter. Otherwise, creates a new progress + /// reporter and submits the synchronization. + /// + /// Submission does not wait for uv or queue space. + pub fn request_sync( + &self, + db: &mut dyn Db, + file: File, + availability: ScriptEnvironmentAvailability, + make_progress: &ProgressFactory<'_>, + ) { + if !self.is_enabled() { + return; + } + + let Some(task) = script_sync_task(db, file) else { + return; + }; + let entry: Arc = self.entry(file); + let mut state = entry.lock(); + + let (environment, availability) = match &mut *state { + ScriptEnvironmentState::Vacant => { + (ScriptEnvironment::new(db, None, None, None), availability) + } + ScriptEnvironmentState::Current { environment } => { + let synchronized_cache_key = environment.synchronized_cache_key(db); + let already_synchronized = synchronized_cache_key == Some(task.request.cache_key()); + + if already_synchronized { + tracing::trace!( + "Script environment for `{}` is already synchronized", + task.request.path() + ); + return; + } + + (*environment, ScriptEnvironmentAvailability::Available) + } + ScriptEnvironmentState::Synchronizing { sync, .. } => { + if !sync.update_next_request(task.request.clone()) { + tracing::trace!( + "Script environment synchronization for `{}` is already requested", + task.request.path() + ); + } else { + tracing::debug!( + "Updated pending script environment synchronization for `{}`", + task.request.path() + ); + } + + return; + } + }; + + let progress = make_progress(db, file); + let cancellation = CancellationTokenSource::new(); + let token = cancellation.token(); + *state = ScriptEnvironmentState::Synchronizing { + environment, + availability, + sync: InFlightSync { + active_cache_key: task.request.cache_key(), + next_request: None, + cancellation, + }, + }; + + tracing::debug!( + "Requested script environment synchronization for `{}`", + task.request.path() + ); + + drop(state); + + self.inner.sync_service.schedule_one( + db.system(), + UvSyncTask::Script(task), + token, + progress, + ); + } + + /// Applies completed background requests to their projects or script environments. + /// + /// Background workers cannot apply their results because updating an existing Salsa input + /// requires mutable access to the database. The CLI and language-server main loops call this + /// method after receiving a [`sync_wakeups`](Self::sync_wakeups) notification. + /// + /// If a newer synchronization was requested while the current one was pending, discards the + /// outdated result and schedules the newer request instead, transferring the existing progress + /// reporter. Cancelled jobs also schedule their replacement without changing the environment. + /// + /// Reports project completions and changed scripts so callers can refresh diagnostics. + pub fn poll_sync(&self, db: &mut dyn Db) -> UvSyncChanges { + // Updating a Salsa input waits for outstanding snapshots to be dropped. Cancel + // them before taking an entry lock, which their queries may need to finish. + db.trigger_cancellation(); + let mut changes = UvSyncChanges::default(); + + while let Ok(result) = self.inner.sync_results.try_recv() { + let UvMetadataResult { + task, + output, + progress, + } = result; + match task { + UvSyncTask::Workspace(path) => { + let mut project_sync = self.inner.project.lock(); + let next = project_sync + .as_mut() + .and_then(|sync| sync.next_request.take()); + let output = match (next, output) { + (None, Some(output)) => output, + (next, _) => { + tracing::debug!("Discarded superseded workspace metadata for `{path}`"); + let cancellation = CancellationTokenSource::new(); + let token = cancellation.token(); + *project_sync = Some(ProjectSync { + next_request: None, + cancellation, + }); + drop(project_sync); + + self.inner.sync_service.schedule_one( + db.system(), + UvSyncTask::Workspace(next.unwrap_or(path)), + token, + progress, + ); + continue; + } + }; + drop(project_sync); + let project = db.project(); + let environment = match Uv::parse_metadata_output(db.system(), output) { + Ok(metadata) => ProjectEnvironment { + metadata: Some(metadata), + error: None, + }, + // Keep the last working uv metadata so a failed refresh does not change + // the environment used for checking. Report the new error instead. + Err(error) => ProjectEnvironment { + error: Some(error.to_string().into_boxed_str()), + ..project.metadata(db).environment().clone() + }, + }; + changes.project = Some(match project.rediscover(db, &path, environment) { + Ok(result) => result, + Err(error) => { + let error = anyhow::Error::new(error); + tracing::error!( + "Failed to load project, keeping old project configuration: {error:#}" + ); + ProjectReloadResult::Unchanged + } + }); + *self.inner.project.lock() = None; + } + UvSyncTask::Script(task) => { + let file = task.file; + let request = task.request; + let Some(entry) = self.existing_entry(file) else { + panic!( + "received a synchronization result for unknown script `{}`", + request.path(), + ); + }; + + let mut state = entry.lock(); + let ScriptEnvironmentState::Synchronizing { + environment, sync, .. + } = &mut *state + else { + panic!( + "synchronization result for `{}` does not match any task currently in flight", + request.path(), + ); + }; + assert_eq!( + sync.active_cache_key, + request.cache_key(), + "synchronization result for `{}` does not match the task currently in flight", + request.path() + ); + + let output = match (sync.next_request.take(), output) { + (None, Some(output)) => output, + (next, _) => { + // uv updates the same environment on disk for every version of this script. If the + // metadata changes A -> B -> A, the B synchronization may already have modified the + // environment. Run A again even though its cache key matches the last completed + // synchronization. + // + // A cancelled request can become current again after another edit. Retry it + // when there is no newer request; cancellation does not update the environment. + let next = next.unwrap_or(request); + sync.active_cache_key = next.cache_key(); + sync.cancellation = CancellationTokenSource::new(); + let token = sync.cancellation.token(); + + tracing::debug!( + "Discarded superseded script environment synchronization result for `{}`", + next.path() + ); + + drop(state); + + self.inner.sync_service.schedule_one( + db.system(), + UvSyncTask::Script(ScriptSyncTask { + file, + request: next, + }), + token, + progress, + ); + continue; + } + }; + + let environment = *environment; + apply_sync_result(db, environment, &request, output); + *state = ScriptEnvironmentState::Current { environment }; + changes.scripts.push(file); + } + } + + if let Some(progress) = progress { + progress.completed(); + } + } + + changes + } + + /// Returns whether any project or script synchronization is pending. + /// + /// A request stays pending until [`poll_sync`](Self::poll_sync) applies its result, even if uv + /// has already finished. + /// + /// The result reflects the current state. A new synchronization can be requested after this + /// method returns. + pub fn has_pending_synchronizations(&self) -> bool { + self.inner.project.lock().is_some() + || self + .inner + .scripts + .iter() + .any(|entry| matches!(*entry.lock(), ScriptEnvironmentState::Synchronizing { .. })) + } + + fn environment(&self, db: &dyn Db, file: File) -> Option { + if !self.is_enabled() || file.path(db).as_system_path().is_none() { + return None; + } + + let entry = self.entry(file); + let mut state = entry.lock(); + + match *state { + ScriptEnvironmentState::Vacant => { + let environment = ScriptEnvironment::new(db, None, None, None); + *state = ScriptEnvironmentState::Current { environment }; + Some(environment) + } + ScriptEnvironmentState::Current { environment } + | ScriptEnvironmentState::Synchronizing { environment, .. } => Some(environment), + } + } + + fn is_enabled(&self) -> bool { + self.inner.use_uv.script_environments_enabled() + } + + fn existing_entry(&self, file: File) -> Option> { + let entry = self.inner.scripts.get(&file)?; + Some(Arc::clone(entry.value())) + } + + fn entry(&self, file: File) -> Arc { + // Return an owned entry so the map's shard lock is released before the caller locks the + // script's state. Otherwise, unrelated scripts in the same shard would also be blocked. + Arc::clone(self.inner.scripts.entry(file).or_default().value()) + } +} + +impl std::panic::RefUnwindSafe for UvEnvironments {} + +/// Changes applied by polling completed uv metadata requests. +#[derive(Debug, Default)] +pub struct UvSyncChanges { + pub scripts: Vec, + /// `Some` also reports completion when rediscovery leaves the project unchanged or fails. + pub project: Option, +} + +impl UvSyncChanges { + pub fn is_empty(&self) -> bool { + self.scripts.is_empty() && self.project.is_none() + } +} + +/// Applied workspace metadata and the error from its latest request. +/// Both fields are absent when no workspace metadata has been requested. +#[derive(Debug, Default, Clone, PartialEq, Eq, get_size2::GetSize)] +pub(crate) struct ProjectEnvironment { + pub(crate) metadata: Option, + pub(crate) error: Option>, +} + +/// Whether a script environment is suitable for operations that depend on its dependencies. +/// +/// When opening a script, the initial environment lacks its declared dependencies and can produce +/// incorrect results. Operations affected by those dependencies, such as semantic diagnostics, +/// should be deferred. Operations such as semantic tokens can continue. +/// +/// During a resync, the previous environment remains a reasonable approximation. If an unsaved edit +/// turns an ordinary file into a script, its default environment is also used: it already reflects +/// the script's settings, and synchronization is deferred until the file is saved. +#[derive(Clone, Copy)] +pub enum ScriptEnvironmentAvailability { + /// The host should skip semantic diagnostics until synchronization finishes. + Pending, + + /// The default or previously synchronized environment can be used. + Available, +} + +/// Identifies when a script's environment needs to be synchronized. +/// +/// Matching keys allow an existing environment or synchronization request to be reused without +/// invoking uv again. The key changes when the script's PEP 723 metadata or configured Python +/// override changes; edits elsewhere in the script leave it unchanged. +pub(crate) type ScriptEnvironmentCacheKey = u64; + +/// The stable Salsa identity for a script's environment. +/// +/// Like [`File`], this input can exist before the resource it represents is initialized. If +/// semantic analysis reaches a script before synchronization, [`script_environment`] creates +/// this input without invoking uv. +/// +/// The CLI or language server later synchronizes the script and updates the same input. Keeping +/// its identity stable ensures that Salsa invalidates queries which observed the earlier +/// environment when its Python version, module search paths, or initialization error changes. +#[salsa::input(heap_size=ruff_memory_usage::heap_size)] +#[derive(Debug)] +pub(crate) struct ScriptEnvironment { + /// The cache key of the most recently completed synchronization. + /// + /// `None` means the environment has not been synchronized yet. Both successful and failed + /// synchronizations store their cache key, preventing repeated uv invocations until the script + /// metadata or Python override changes. + #[returns(copy)] + synchronized_cache_key: Option, + + /// The environment metadata returned by the most recent successful synchronization. + /// + /// `None` means the environment has not been synchronized or synchronization failed. + /// [`initialization_error`](Self::initialization_error) distinguishes those cases. + #[returns(as_ref)] + pub(crate) uv_metadata: Option, + + /// The error from the most recent synchronization. + /// + /// `None` if synchronization has not completed or completed successfully. + #[returns(as_deref)] + pub(crate) initialization_error: Option>, +} + +struct UvEnvironmentsInner { + use_uv: UseUv, + project: Mutex>, + scripts: FxDashMap>, + sync_service: UvMetadataService, + sync_results: Receiver, + sync_wakeups: Receiver<()>, +} + +impl Default for UvEnvironmentsInner { + fn default() -> Self { + let (results_sender, sync_results) = crossbeam::channel::unbounded(); + let (wake_sender, sync_wakeups) = crossbeam::channel::bounded(1); + Self { + use_uv: UseUv::default(), + project: Mutex::default(), + scripts: FxDashMap::default(), + sync_service: UvMetadataService::new(results_sender, wake_sender), + sync_results, + sync_wakeups, + } + } +} + +struct ProjectSync { + next_request: Option, + cancellation: CancellationTokenSource, +} + +type ScriptEnvironmentEntry = Mutex; + +/// The synchronization state of one script environment. +/// +/// Ensures that at most one synchronization runs for a script at a time. +#[derive(Default)] +enum ScriptEnvironmentState { + /// No [`ScriptEnvironment`] exists and no synchronization is running. + #[default] + Vacant, + + /// The last synchronized environment, or the default environment before synchronization. + /// + /// A completed synchronization stores its metadata and any initialization error in the + /// [`ScriptEnvironment`] input. If semantic analysis reaches a script before synchronization + /// has been requested, [`script_environment`] creates an input without uv metadata instead. + /// + /// For example, when an unsaved edit adds script metadata to a file, synchronization is + /// deferred until the file is saved. Checking continues with the default environment so + /// existing diagnostics do not disappear in the meantime. + /// + /// The environment does not necessarily match the latest script metadata. The CLI or language + /// server is responsible for requesting synchronization when an updated environment is needed. + Current { environment: ScriptEnvironment }, + + /// A background synchronization is initializing or updating the script's virtual environment. + /// + /// If no input exists yet, one is created before synchronization starts so semantic queries + /// have a stable Salsa identity to depend on. + /// + /// The background worker cannot update the [`ScriptEnvironment`] because modifying a Salsa + /// input requires mutable access to the database. Instead, the CLI or language-server main loop + /// updates it when synchronization finishes. + Synchronizing { + /// The [`ScriptEnvironment`] input that will receive the synchronization result. + environment: ScriptEnvironment, + + availability: ScriptEnvironmentAvailability, + + /// The active synchronization and the next request, if any. + sync: InFlightSync, + }, +} + +/// An active synchronization and the latest request to run after it. +/// +/// At most one additional request is retained. If the script changes repeatedly while uv is +/// running, newer requests replace the pending request instead of accumulating in a queue. +struct InFlightSync { + active_cache_key: ScriptEnvironmentCacheKey, + next_request: Option, + /// Signals the worker to skip this request if it has not started. + cancellation: CancellationTokenSource, +} + +impl InFlightSync { + /// Updates the synchronization to run after the active request. + /// + /// If `request` matches the active synchronization, removes any previously requested follow-up. + /// Otherwise, replaces the follow-up with `request`. A changed request cancels the queued job; + /// an already running uv process is allowed to finish. + /// + /// Returns whether the requested synchronization changed. + fn update_next_request(&mut self, request: ScriptSyncRequest) -> bool { + let desired = self + .next_request + .as_ref() + .map_or(self.active_cache_key, ScriptSyncRequest::cache_key); + if desired == request.cache_key() { + return false; + } + + self.next_request = if self.active_cache_key == request.cache_key() { + None + } else { + Some(request) + }; + self.cancellation.cancel(); + true + } +} + +/// Applies a completed synchronization to the existing [`ScriptEnvironment`] input. +/// +/// Stores the returned metadata or initialization error and records the synchronized cache key. +/// Updating the input invalidates semantic queries that depend on the script's virtual +/// environment. +fn apply_sync_result( + db: &mut dyn Db, + environment: ScriptEnvironment, + request: &ScriptSyncRequest, + output: std::io::Result, +) { + let previous_root = environment + .uv_metadata(db) + .and_then(UvMetadata::environment) + .map(ToOwned::to_owned); + let recovering_from_error = environment.initialization_error(db).is_some(); + let (uv_metadata, initialization_error) = match Uv::parse_metadata_output(db.system(), output) { + Ok(metadata) => (Some(metadata), None), + Err(error) => (None, Some(error.to_string().into_boxed_str())), + }; + let current_root = uv_metadata.as_ref().and_then(UvMetadata::environment); + + if let Some(root) = previous_root + .as_deref() + .or_else(|| current_root.filter(|_| recovering_from_error)) + { + // uv can install, update, or remove packages without changing the virtual-environment path. + // Refresh files under that path so semantic queries see the updated package contents. + // After a failed synchronization, recover the path from the new metadata because the + // previous metadata was cleared along with its virtual-environment path. + // + // FIXME: This is overbroad. A file watcher can tell us precisely what changed. + // Changes inside virtual environments should instead be watched and processed through `ProjectDatabase::apply_changes`. + // Using a file watcher also ensures that virtual environment changes in + // scripts without using uv are detected. + Files::sync_all_recursive(db, [root]); + } + + if environment.uv_metadata(db) != uv_metadata.as_ref() { + environment.set_uv_metadata(db).to(uv_metadata); + } + + if environment.initialization_error(db) != initialization_error.as_deref() { + environment + .set_initialization_error(db) + .to(initialization_error); + } + + let cache_key = Some(request.cache_key()); + if environment.synchronized_cache_key(db) != cache_key { + environment.set_synchronized_cache_key(db).to(cache_key); + } + + tracing::debug!( + "Applied script environment synchronization result for `{}`", + request.path() + ); +} + +fn script_sync_task(db: &dyn Db, file: File) -> Option { + let path = file.path(db).as_system_path()?; + let tag = script_tag(db, file)?; + let python = script_python(db); + + // Hash the metadata text directly to avoid parsing it solely to compute the cache key. + // Formatting-only metadata changes may therefore trigger an unnecessary synchronization. + let mut hasher = CacheKeyHasher::new(); + tag.metadata().cache_key(&mut hasher); + python.cache_key(&mut hasher); + + Some(ScriptSyncTask::new( + file, + path.to_path_buf(), + python, + hasher.finish(), + )) +} + +fn script_python(db: &dyn Db) -> Option { + let metadata = db.project().metadata(db); + + metadata + .override_options() + .and_then(|options| options.environment.as_ref()) + .and_then(|environment| environment.python.as_ref()) + .map(|python| python.absolute(metadata.root(), db.system())) +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use ruff_db::Db as _; + use ruff_db::files::{File, system_path_to_file}; + use ruff_db::system::{DbWithWritableSystem, SystemPath}; + use salsa::Setter; + use salsa::plumbing::AsId; + use serde_json::{Value, json}; + use ty_python_semantic::Db as _; + + use super::{UvMetadata, script_environment}; + use crate::db::testing::TestDb; + use crate::{Db as _, ProjectMetadata, UseUv}; + + #[test] + fn semantic_lookup_creates_a_stable_default_environment() -> anyhow::Result<()> { + let root = SystemPath::new("/project").to_path_buf(); + let path = root.join("script.py"); + let metadata = ProjectMetadata::new("test", root).with_use_uv(UseUv::Scripts); + let mut db = TestDb::new(metadata); + db.write_dedented( + path.as_str(), + r#" + # /// script + # dependencies = [] + # /// + "#, + )?; + let file = system_path_to_file(&db, &path)?; + let environments = db.uv_environments().clone(); + + // Semantic queries can reach a script before its environment has been synchronized. They + // create one stable Salsa input with no uv metadata or initialization error. + let environment = script_environment(&db, file).context("expected a script environment")?; + assert_eq!(script_environment(&db, file), Some(environment)); + assert_eq!(environment.synchronized_cache_key(&db), None); + assert_eq!(environment.uv_metadata(&db), None); + assert_eq!(environment.initialization_error(&db), None); + assert!(!environments.is_initialization_pending(&db, file)); + + Ok(()) + } + + #[test] + fn dependency_metadata_changes_recheck_unchanged_imports() -> anyhow::Result<()> { + let root = SystemPath::new(if cfg!(windows) { + "C:/project" + } else { + "/project" + }); + let path = root.join("script.py"); + let environment = root.join(".venv"); + let site_packages = environment.join(if cfg!(windows) { + "Lib/site-packages" + } else { + "lib/python3.13/site-packages" + }); + let indirect = r#" + # /// script + # dependencies = ['parent'] + # [tool.ty.rules] + # missing-direct-dependency = 'error' + # /// + import leaf + "#; + let declared = indirect.replace("['parent']", "['parent', 'leaf']"); + let metadata = ProjectMetadata::new("test", root.to_path_buf()).with_use_uv(UseUv::Scripts); + let mut db = TestDb::new(metadata); + db.write_dedented( + environment.join("pyvenv.cfg").as_str(), + &format!( + r#" + home = {root} + include-system-site-packages = false + version = 3.13.5 + "#, + ), + )?; + db.write_file(site_packages.join("leaf.py"), "")?; + db.write_dedented(path.as_str(), indirect)?; + let file = system_path_to_file(&db, &path)?; + + let indirect_metadata = dependency_metadata(root, &path, &["parent"]); + apply_dependency_metadata(&mut db, file, &indirect_metadata)?; + let diagnostics = db.check_file(file); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .id() + .is_lint_named("missing-direct-dependency") + ); + + // Before synchronization, dependency checks keep using the previous declarations. + db.write_dedented(path.as_str(), &declared)?; + assert_eq!(db.check_file(file).len(), 1); + + let declared_metadata = dependency_metadata(root, &path, &["parent", "leaf"]); + apply_dependency_metadata(&mut db, file, &declared_metadata)?; + assert!(db.check_file(file).is_empty()); + + db.write_dedented(path.as_str(), indirect)?; + assert!(db.check_file(file).is_empty()); + let program = db.program_file(file).program(&db).as_id(); + + // Only the synchronization result changes after the preceding check. Its dependency + // declarations must invalidate the cached diagnostic even though `Program` is unchanged. + apply_dependency_metadata(&mut db, file, &indirect_metadata)?; + assert_eq!(db.program_file(file).program(&db).as_id(), program); + let diagnostics = db.check_file(file); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .id() + .is_lint_named("missing-direct-dependency") + ); + + Ok(()) + } + + fn dependency_metadata(root: &SystemPath, path: &SystemPath, dependencies: &[&str]) -> Value { + json!({ + "schema": {"version": "preview"}, + "workspace_root": root.as_str(), + "environment": {"root": root.join(".venv"), "python": {"version": "3.13.5"}}, + "script": {"path": path.as_str(), "id": "script+test"}, + "resolution": { + "script+test": { + "kind": "script", + "dependencies": dependencies.iter().map(|id| json!({"id": id})).collect::>() + }, + "parent": { + "kind": "package", "name": "parent", "dependencies": [{"id": "leaf"}] + }, + "leaf": {"kind": "package", "name": "leaf", "dependencies": []} + }, + "module_owners": { + "leaf": [{"package_id": "leaf"}] + } + }) + } + + fn apply_dependency_metadata( + db: &mut TestDb, + file: File, + metadata: &Value, + ) -> anyhow::Result<()> { + let metadata = UvMetadata::from_metadata(&serde_json::to_vec(metadata)?, db.system())?; + let environment = script_environment(db, file).context("expected a script environment")?; + environment.set_uv_metadata(db).to(Some(metadata)); + Ok(()) + } + + #[cfg(feature = "test-uv")] + mod uv { + use std::process::Command; + use std::thread; + use std::time::{Duration, Instant}; + + use anyhow::Context; + use ruff_db::files::{File, system_path_to_file}; + use ruff_db::system::{ + DbWithTestSystem, DbWithWritableSystem, OsSystem, System as _, SystemPath, + SystemPathBuf, + }; + use salsa::Database as _; + use ty_python_semantic::Db as _; + use ty_static::EnvVars; + + use super::super::{ScriptEnvironmentAvailability, UvSyncChanges, script_environment}; + use crate::db::testing::TestDb; + use crate::{Db as _, ProjectMetadata, UseUv}; + + #[test] + fn newer_project_refresh_discards_old_metadata() -> anyhow::Result<()> { + let mut case = UvTestCase::project( + r#" + [project] + name = 'example' + version = '0.1.0' + requires-python = '>=3.8' + "#, + )?; + let root = case.db.project().root(&case.db).to_path_buf(); + let environments = case.db.uv_environments().clone(); + environments.request_project_sync(&case.db, &root, &|_, _| None); + + // Leave the first result unapplied, then add a dependency-free workspace member. + environments + .sync_wakeups() + .recv_timeout(Duration::from_secs(30))?; + assert!(environments.has_pending_synchronizations()); + let member_root = root.join("member"); + case.db.write_dedented( + member_root.join("pyproject.toml").as_str(), + r#" + [project] + name = 'member' + version = '0.1.0' + requires-python = '>=3.8' + "#, + )?; + case.db.write_dedented( + case.path.as_str(), + r#" + [project] + name = 'example' + version = '0.1.0' + requires-python = '>=3.8' + + [tool.uv.workspace] + members = ['member'] + "#, + )?; + case.sync_workspace()?; + environments.request_project_sync(&case.db, &root, &|_, _| None); + + let mut changes = environments.poll_sync(&mut case.db); + if changes.project.is_none() { + changes = case.wait_for_synchronizations()?; + } + assert!(changes.project.is_some()); + assert!(!environments.has_pending_synchronizations()); + + let environment = case.db.project().metadata(&case.db).environment(); + assert_eq!(environment.error, None); + assert_eq!( + environment + .metadata + .as_ref() + .context("missing uv metadata")? + .members() + .iter() + .find(|member| member.name.as_ref() == "member") + .map(|member| member.path.as_path()), + Some(member_root.as_path()) + ); + Ok(()) + } + + #[test] + fn initial_background_synchronization_is_pending_until_completion() -> anyhow::Result<()> { + let mut case = UvTestCase::script( + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#, + )?; + let environments = case.db.uv_environments().clone(); + + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + assert!(environments.is_initialization_pending(&case.db, case.file)); + + assert_eq!(case.wait_for_synchronizations()?.scripts, vec![case.file]); + assert!(!environments.is_initialization_pending(&case.db, case.file)); + case.assert_can_import("attrs")?; + + Ok(()) + } + + #[test] + fn existing_environment_remains_available_during_background_synchronization() + -> anyhow::Result<()> { + let mut case = UvTestCase::script( + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#, + )?; + let environments = case.db.uv_environments().clone(); + + // Semantic analysis can reach a script before the host requests synchronization. + // The missing-import diagnostic must clear when the environment becomes available. + let _ = case.db.program_file(case.file); + let diagnostics = crate::check_file(&case.db, case.file); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].id().as_str(), "unresolved-import"); + + let environment = script_environment(&case.db, case.file) + .context("expected a default script environment")?; + + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + assert_eq!(script_environment(&case.db, case.file), Some(environment)); + assert!(!environments.is_initialization_pending(&case.db, case.file)); + + assert_eq!(case.wait_for_synchronizations()?.scripts, vec![case.file]); + assert_eq!(script_environment(&case.db, case.file), Some(environment)); + case.assert_can_import("attrs")?; + assert!(crate::check_file(&case.db, case.file).is_empty()); + + Ok(()) + } + + #[test] + fn returning_to_previous_metadata_resynchronizes_the_environment() -> anyhow::Result<()> { + let initial = r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["attrs==25.4.0"] + # /// + from attrs import define + "#; + let mut case = UvTestCase::script(initial)?; + let environments = case.db.uv_environments().clone(); + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + assert_eq!(case.wait_for_synchronizations()?.scripts, vec![case.file]); + case.assert_can_import("attrs")?; + + case.db.write_dedented( + case.path.as_str(), + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["anyio"] + # /// + from attrs import define + "#, + )?; + let wakeups = environments.sync_wakeups(); + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + + // Wait until uv has changed the virtual environment, but leave its result unapplied. + wakeups + .recv_timeout(Duration::from_secs(30)) + .context("intermediate script synchronization did not finish")?; + + // Restoring the original metadata must reinstall its dependencies, even though its + // cache key matches the last synchronization whose result was applied. + case.db.write_dedented(case.path.as_str(), initial)?; + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + + let mut changed = environments.poll_sync(&mut case.db).scripts; + changed.extend(case.wait_for_synchronizations()?.scripts); + assert_eq!(changed, vec![case.file]); + case.assert_can_import("attrs")?; + + Ok(()) + } + + #[test] + fn background_result_cancels_snapshots_before_locking_entry() -> anyhow::Result<()> { + let mut case = UvTestCase::script( + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + "#, + )?; + let environments = case.db.uv_environments().clone(); + environments.request_sync( + &mut case.db, + case.file, + ScriptEnvironmentAvailability::Pending, + &|_, _| None, + ); + environments + .sync_wakeups() + .recv_timeout(Duration::from_secs(30)) + .context("script synchronization did not finish")?; + + let entry = environments + .existing_entry(case.file) + .context("expected a script environment entry")?; + let snapshot = case.db.clone(); + let reader = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + while salsa::Cancelled::catch(|| snapshot.unwind_if_revision_cancelled()).is_ok() { + assert!(Instant::now() < deadline, "snapshot was not cancelled"); + thread::sleep(Duration::from_millis(1)); + } + + // A cancelled query may need this lock before it can drop its snapshot. Use a + // timeout so a regression fails instead of deadlocking the test itself. + assert!( + entry.try_lock_for(Duration::from_secs(1)).is_some(), + "the entry lock was held while waiting for a cancelled snapshot" + ); + drop(snapshot); + }); + + assert_eq!( + environments.poll_sync(&mut case.db).scripts, + vec![case.file] + ); + reader + .join() + .map_err(|_| anyhow::anyhow!("reader panicked"))?; + Ok(()) + } + + struct UvTestCase { + _temp_dir: tempfile::TempDir, + db: TestDb, + file: File, + path: SystemPathBuf, + } + + impl UvTestCase { + fn script(source: &str) -> anyhow::Result { + Self::new("script.py", source, UseUv::Scripts) + } + + fn project(source: &str) -> anyhow::Result { + let case = Self::new("pyproject.toml", source, UseUv::On)?; + case.sync_workspace()?; + Ok(case) + } + + fn wait_for_synchronizations(&mut self) -> anyhow::Result { + let environments = self.db.uv_environments().clone(); + let wakeups = environments.sync_wakeups(); + let mut changes = UvSyncChanges::default(); + + while environments.has_pending_synchronizations() { + wakeups + .recv_timeout(Duration::from_secs(30)) + .context("uv synchronization did not finish")?; + let completed = environments.poll_sync(&mut self.db); + changes.scripts.extend(completed.scripts); + changes.project = completed.project.or(changes.project); + } + + Ok(changes) + } + + fn sync_workspace(&self) -> anyhow::Result<()> { + let output = Command::new(self.db.test_system().env_var(EnvVars::UV)?) + .current_dir(self.db.project().root(&self.db)) + .args(["sync", "--offline"]) + .output()?; + anyhow::ensure!( + output.status.success(), + "uv sync failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + fn assert_can_import(&self, module: &str) -> anyhow::Result<()> { + let environment = script_environment(&self.db, self.file) + .context("expected a script environment")?; + let metadata = environment.uv_metadata(&self.db).with_context(|| { + format!( + "script synchronization did not produce uv metadata: {:?}", + environment.initialization_error(&self.db) + ) + })?; + let root = metadata + .environment() + .context("uv metadata did not include a virtual environment")?; + let python = if cfg!(windows) { + root.join("Scripts/python.exe") + } else { + root.join("bin/python") + }; + let output = Command::new(python.as_std_path()) + .args(["-c", &format!("import {module}")]) + .output()?; + + anyhow::ensure!( + output.status.success(), + "failed to import `{module}` from the synchronized environment: {}", + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) + } + + fn new(file_name: &str, source: &str, use_uv: UseUv) -> anyhow::Result { + let temp_dir = tempfile::tempdir()?; + let root = SystemPath::from_std_path(temp_dir.path()) + .context("temporary directory is not a valid UTF-8 path")?; + // uv resolves symlinks, including macOS's symlinked temporary directory. + let root = OsSystem::default().canonicalize_path(root)?; + let metadata = ProjectMetadata::new("test", root.clone()).with_use_uv(use_uv); + let mut db = TestDb::new(metadata); + db.use_system(OsSystem::new(&root)); + + let uv = OsSystem::default().which("uv")?; + db.test_system().set_env_var(EnvVars::UV, uv.as_str()); + for name in [ + EnvVars::VIRTUAL_ENV, + EnvVars::CONDA_PREFIX, + EnvVars::CONDA_DEFAULT_ENV, + EnvVars::CONDA_ROOT, + EnvVars::PYTHONPATH, + ] { + db.test_system().remove_env_var(name); + } + + let path = root.join(file_name); + db.write_dedented(path.as_str(), source)?; + let file = system_path_to_file(&db, &path)?; + + Ok(Self { + _temp_dir: temp_dir, + db, + file, + path, + }) + } + } + } +} diff --git a/crates/ty_project/src/uv/metadata.rs b/crates/ty_project/src/uv/metadata.rs new file mode 100644 index 0000000000..0d6ea57f53 --- /dev/null +++ b/crates/ty_project/src/uv/metadata.rs @@ -0,0 +1,365 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use compact_str::CompactString; +use pep440_rs::Version; +use ruff_db::system::{System, SystemPath, SystemPathBuf}; +use ruff_ranged_value::{RangedValue, ValueSource}; +use serde::Deserialize; +use thiserror::Error; + +use crate::metadata::python_version::SupportedPythonVersion; + +mod dependencies; + +pub(crate) use dependencies::DependencyMetadataError; + +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +pub(crate) struct UvMetadata { + workspace_root: SystemPathBuf, + members: Box<[WorkspaceMember]>, + environment: Option, + python_version: Option>, + schema: Schema, + workspace: Option, + script: Option, + resolution: BTreeMap, + module_owners: BTreeMap>, +} + +impl UvMetadata { + pub(crate) fn workspace_root(&self) -> &SystemPath { + &self.workspace_root + } + + /// Workspace members returned by uv. Empty for standalone scripts. + #[cfg(test)] + pub(crate) fn members(&self) -> &[WorkspaceMember] { + &self.members + } + + pub(crate) fn environment(&self) -> Option<&SystemPath> { + self.environment.as_deref() + } + + pub(crate) fn python_version(&self) -> Option<&RangedValue> { + self.python_version.as_ref() + } + + pub(crate) fn from_metadata( + metadata: &[u8], + system: &dyn System, + ) -> Result { + let metadata = serde_json::from_slice::(metadata) + .map_err(UvMetadataError::InvalidMetadata)?; + + let workspace_root = existing_directory(metadata.workspace_root, "workspace root", system)?; + + let (environment, python_version) = match metadata.environment { + Some(environment) => ( + Some(existing_directory( + environment.root, + "environment root", + system, + )?), + Some(resolve_python_version(&environment.python.version)?), + ), + None => (None, None), + }; + + Ok(Self { + workspace_root, + members: metadata.members, + environment, + python_version, + schema: metadata.schema, + workspace: metadata.workspace, + script: metadata.script, + resolution: metadata.resolution, + module_owners: metadata.module_owners, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +pub(crate) struct WorkspaceMember { + pub(crate) name: Box, + /// Directory containing the member's `pyproject.toml`. + pub(crate) path: SystemPathBuf, + id: CompactString, +} + +#[derive(Debug, Error)] +pub(crate) enum UvMetadataError { + #[error("Failed to invoke `uv workspace metadata`: {0}")] + Invocation(#[source] std::io::Error), + + #[error("`uv workspace metadata` failed with status {status}: {stderr}")] + CommandFailed { + status: std::process::ExitStatus, + stderr: String, + }, + + #[error("invalid `uv workspace metadata` JSON: {0}")] + InvalidMetadata(serde_json::Error), + + #[error("unsupported Python version `{0}` returned by `uv workspace metadata`")] + InvalidPythonVersion(Version), + + #[error("non-Unicode {description} returned by `uv workspace metadata`: `{path}`", path = path.display())] + NonUnicodePath { + description: &'static str, + path: PathBuf, + }, + + #[error("missing {description} returned by `uv workspace metadata`: `{path}`")] + MissingDirectory { + description: &'static str, + path: SystemPathBuf, + }, +} +fn existing_directory( + path: PathBuf, + description: &'static str, + system: &dyn System, +) -> Result { + let path = match SystemPathBuf::from_path_buf(path) { + Ok(path) => path, + Err(path) => return Err(UvMetadataError::NonUnicodePath { description, path }), + }; + + if !system.is_directory(&path) { + return Err(UvMetadataError::MissingDirectory { description, path }); + } + + Ok(path) +} + +fn resolve_python_version( + version: &Version, +) -> Result, UvMetadataError> { + let [major, minor, ..] = version.release() else { + return Err(UvMetadataError::InvalidPythonVersion(version.clone())); + }; + let version = format!("{major}.{minor}") + .parse::() + .map_err(|_| UvMetadataError::InvalidPythonVersion(version.clone()))?; + + Ok(RangedValue::new(version, ValueSource::UvMetadata)) +} + +/// The uv metadata used to discover the workspace and check imports against its dependencies. +/// +/// See uv's [schema documentation] and [serialization types] for the upstream format. +/// +/// [schema documentation]: https://docs.astral.sh/uv/reference/internals/metadata/#schema +/// [serialization types]: https://github.com/astral-sh/uv/blob/main/crates/uv-resolver/src/lock/export/metadata.rs +#[derive(Deserialize)] +struct WorkspaceMetadata { + workspace_root: PathBuf, + #[serde(default)] + members: Box<[WorkspaceMember]>, + environment: Option, + schema: Schema, + workspace: Option, + script: Option, + #[serde(default)] + resolution: BTreeMap, + #[serde(default)] + module_owners: BTreeMap>, +} + +#[derive(Deserialize)] +struct WorkspaceEnvironment { + root: PathBuf, + python: WorkspacePython, +} + +#[derive(Deserialize)] +struct WorkspacePython { + version: Version, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct Schema { + version: SchemaVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +#[serde(rename_all = "snake_case")] +enum SchemaVersion { + Preview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct PathNodeReference { + path: SystemPathBuf, + id: CompactString, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct ModuleOwner { + package_id: CompactString, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct ResolutionNode { + kind: NodeKind, + name: Option, + source: Option, + // uv always emits this field, even for leaves. Missing edges are incomplete metadata, not + // evidence that a project has no direct dependencies. + dependencies: Box<[NodeReference]>, + #[serde(default)] + optional_dependencies: Box<[NodeReference]>, + #[serde(default)] + dependency_groups: Box<[NodeReference]>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +#[serde(rename_all = "snake_case")] +enum NodeKind { + Package, + Extra(CompactString), + Group(CompactString), + Workspace, + Script, + Build, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct Source { + editable: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, get_size2::GetSize)] +struct NodeReference { + id: CompactString, +} + +#[cfg(test)] +mod tests { + use std::assert_matches; + + use ruff_db::system::{SystemPath, TestSystem}; + use serde_json::json; + + use super::{UvMetadata, UvMetadataError}; + + #[test] + fn rejects_invalid_metadata() { + let system = TestSystem::default(); + + assert_matches!( + UvMetadata::from_metadata(b"{", &system), + Err(UvMetadataError::InvalidMetadata(_)) + ); + } + + #[test] + fn environment_can_be_omitted() -> anyhow::Result<()> { + let system = TestSystem::default(); + system + .memory_file_system() + .write_file_all("/app/pyproject.toml", "[tool.uv.workspace]")?; + let metadata = br#"{ + "schema": {"version": "preview"}, + "workspace_root": "/app" + }"#; + + let workspace = UvMetadata::from_metadata(metadata, &system)?; + + assert!(workspace.environment().is_none()); + assert!(workspace.python_version().is_none()); + assert!(workspace.members().is_empty()); + assert!(workspace.dependency_metadata().is_err()); + + Ok(()) + } + + #[test] + fn uses_environment_python_version() -> anyhow::Result<()> { + let system = TestSystem::default(); + system.memory_file_system().write_files_all([ + ("/app/pyproject.toml", "[tool.uv.workspace]"), + ("/env/marker", ""), + ])?; + let metadata = br#"{ + "schema": {"version": "preview"}, + "workspace_root": "/app", + "environment": { + "root": "/env", + "python": { "version": "3.13.5" } + } + }"#; + + let workspace = UvMetadata::from_metadata(metadata, &system)?; + + assert_eq!(workspace.environment(), Some(SystemPath::new("/env"))); + assert_eq!( + workspace.python_version().map(ToString::to_string), + Some("3.13".to_string()) + ); + + Ok(()) + } + + #[test] + fn rejects_unsupported_environment_python_version() -> anyhow::Result<()> { + let system = TestSystem::default(); + system.memory_file_system().write_files_all([ + ("/app/pyproject.toml", "[tool.uv.workspace]"), + ("/env/marker", ""), + ])?; + let metadata = br#"{ + "schema": {"version": "preview"}, + "workspace_root": "/app", + "environment": { + "root": "/env", + "python": { "version": "3.16.0" } + } + }"#; + + assert_matches!( + UvMetadata::from_metadata(metadata, &system), + Err(UvMetadataError::InvalidPythonVersion(_)) + ); + + Ok(()) + } + + #[test] + fn rejects_incompatible_dependency_metadata() -> anyhow::Result<()> { + let system = TestSystem::default(); + system.memory_file_system().write_files_all([ + ("/app/pyproject.toml", "[tool.uv.workspace]"), + ("/env/marker", ""), + ])?; + for (schema, resolution) in [ + ("future-version", json!({})), + ("preview", json!(["a different format"])), + ] { + let metadata = json!({ + "workspace_root": "/app", + "environment": { + "root": "/env", + "python": { "version": "3.13.5" } + }, + "schema": { "version": schema }, + "resolution": resolution + }); + + let metadata = serde_json::to_string_pretty(&metadata)?; + let error = match UvMetadata::from_metadata(metadata.as_bytes(), &system) { + Err(UvMetadataError::InvalidMetadata(error)) => error, + result => anyhow::bail!("expected invalid metadata, got {result:?}"), + }; + assert!( + error.line() > 0 && error.line() < metadata.lines().count(), + "expected the error to point to its field, not the end of the response: {error}" + ); + } + + Ok(()) + } +} diff --git a/crates/ty_project/src/uv/metadata/dependencies.rs b/crates/ty_project/src/uv/metadata/dependencies.rs new file mode 100644 index 0000000000..813acfc60d --- /dev/null +++ b/crates/ty_project/src/uv/metadata/dependencies.rs @@ -0,0 +1,663 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use compact_str::CompactString; +use ruff_db::diagnostic::{ + Diagnostic, DiagnosticId, Severity, SubDiagnostic, SubDiagnosticSeverity, +}; +use ruff_db::system::SystemPathBuf; +use thiserror::Error; +use ty_module_resolver::ModuleName; +use ty_python_semantic::dependency::{ + DependencyDistribution, DependencyMetadata, DependencyProject, DependencyProjectKind, +}; + +use super::{NodeKind, ResolutionNode, UvMetadata}; + +impl UvMetadata { + pub(crate) fn dependency_metadata( + &self, + ) -> Result { + let root = self.workspace_root(); + let mut distributions = BTreeMap::new(); + let mut extra_packages = BTreeMap::new(); + + for (id, node) in &self.resolution { + if node.kind != NodeKind::Package { + continue; + } + + let editable_path = node + .source + .as_ref() + .and_then(|source| source.editable.clone()); + if let Some(path) = &editable_path + && !path.is_absolute() + { + return Err(DependencyMetadataError::RelativePath { + kind: "editable package", + id: id.clone(), + path: path.clone(), + }); + } + + distributions.insert( + id.clone(), + DependencyDistribution { + name: node + .name + .clone() + .ok_or_else(|| DependencyMetadataError::MissingPackageName(id.clone()))?, + editable_path, + }, + ); + + for extra in &node.optional_dependencies { + let extra_node = self.node(&extra.id)?; + if !matches!(extra_node.kind, NodeKind::Extra(_)) { + return Err(DependencyMetadataError::UnexpectedNodeKind { + id: extra.id.clone(), + expected: "extra", + }); + } + if let Some(previous) = extra_packages.insert(&extra.id, id) + && previous != id + { + return Err(DependencyMetadataError::SharedExtra { + id: extra.id.clone(), + first: previous.clone(), + second: id.clone(), + }); + } + } + } + + let package_id = |id: &CompactString| { + if distributions.contains_key(id) { + Ok(id.clone()) + } else { + extra_packages + .get(id) + .copied() + .cloned() + .ok_or_else(|| DependencyMetadataError::UnknownDependency(id.clone())) + } + }; + + let dependencies = |node: &ResolutionNode| { + node.dependencies + .iter() + .map(|dependency| package_id(&dependency.id)) + .collect::, _>>() + }; + + let group_dependencies = |node: &ResolutionNode| { + let mut groups = BTreeSet::new(); + for group in &node.dependency_groups { + let group_node = self.node(&group.id)?; + if !matches!(group_node.kind, NodeKind::Group(_)) { + return Err(DependencyMetadataError::UnexpectedNodeKind { + id: group.id.clone(), + expected: "dependency group", + }); + } + groups.extend(dependencies(group_node)?); + } + Ok(groups) + }; + + let workspace_groups = match &self.workspace { + Some(workspace) => { + let node = self.node(&workspace.id)?; + if node.kind != NodeKind::Workspace { + return Err(DependencyMetadataError::UnexpectedNodeKind { + id: workspace.id.clone(), + expected: "workspace", + }); + } + group_dependencies(node)? + } + None => BTreeSet::new(), + }; + + let mut projects = Vec::new(); + if let Some(script) = &self.script { + if !script.path.is_absolute() { + return Err(DependencyMetadataError::RelativePath { + kind: "script", + id: script.id.clone(), + path: script.path.clone(), + }); + } + let node = self.node(&script.id)?; + if node.kind != NodeKind::Script { + return Err(DependencyMetadataError::UnexpectedNodeKind { + id: script.id.clone(), + expected: "script", + }); + } + projects.push(DependencyProject { + path: script.path.clone(), + kind: DependencyProjectKind::Script, + distribution: None, + dependencies: dependencies(node)?, + group_dependencies: BTreeSet::new(), + }); + } + + let mut member_paths = BTreeSet::new(); + for member in &self.members { + if !member.path.is_absolute() { + return Err(DependencyMetadataError::RelativePath { + kind: "workspace member", + id: member.id.clone(), + path: member.path.clone(), + }); + } + if !member_paths.insert(&member.path) { + return Err(DependencyMetadataError::DuplicateMemberPath( + member.path.clone(), + )); + } + let node = self.node(&member.id)?; + if node.kind != NodeKind::Package { + return Err(DependencyMetadataError::UnexpectedNodeKind { + id: member.id.clone(), + expected: "package", + }); + } + + let mut direct = dependencies(node)?; + for extra in &node.optional_dependencies { + // A member's own extras declare dependencies directly. By contrast, requesting an + // extra of another package only declares that package, not its extra's dependencies. + direct.extend(dependencies(self.node(&extra.id)?)?); + } + // Extra nodes also point back to their own package. A project's own imports are + // accounted for by `distribution`, not by listing itself as a dependency. + direct.remove(&member.id); + + let mut groups = group_dependencies(node)?; + groups.extend(workspace_groups.iter().cloned()); + + projects.push(DependencyProject { + path: member.path.clone(), + kind: DependencyProjectKind::Project, + distribution: Some(member.id.clone()), + dependencies: direct, + group_dependencies: groups, + }); + } + + if self.workspace.is_some() + && !projects + .iter() + .any(|project| project.path.as_path() == root) + { + projects.push(DependencyProject { + path: root.to_path_buf(), + kind: DependencyProjectKind::Project, + distribution: None, + dependencies: BTreeSet::new(), + group_dependencies: workspace_groups, + }); + } + + if projects.is_empty() { + return Err(DependencyMetadataError::MissingProjects); + } + + let mut module_owners: BTreeMap> = BTreeMap::new(); + for (module, owners) in &self.module_owners { + let Some(module) = ModuleName::new(module) else { + continue; + }; + // Keep an empty entry when any owner is unknown. Omitting the entry would allow a + // caller to use a known parent module's owner for this incomplete child module. + let owners = owners + .iter() + .map(|owner| { + distributions + .contains_key(&owner.package_id) + .then(|| owner.package_id.clone()) + }) + .collect::>>(); + let owners = owners.map_or_else(Box::default, |owners| owners.into_iter().collect()); + module_owners.insert(module, owners); + } + + if module_owners.values().all(|owners| owners.is_empty()) + && !distributions + .values() + .any(|distribution| distribution.editable_path.is_some()) + // Check for a package outside the workspace. A dependency-free virtual workspace + // has no modules to attribute, so its empty ownership map is valid. + && distributions + .keys() + .any(|id| !self.members.iter().any(|member| member.id == id)) + { + return Err(DependencyMetadataError::MissingModuleOwnership); + } + + projects.sort_by(|left, right| left.path.cmp(&right.path)); + + Ok(DependencyMetadata { + projects: projects.into_boxed_slice(), + distributions, + module_owners, + }) + } + + fn node(&self, id: &CompactString) -> Result<&ResolutionNode, DependencyMetadataError> { + self.resolution + .get(id) + .ok_or_else(|| DependencyMetadataError::MissingNode(id.clone())) + } +} + +/// Why uv's dependency metadata cannot be used for the selected Python environment. +#[derive(Debug, Clone, PartialEq, Eq, Error, get_size2::GetSize)] +pub(crate) enum DependencyMetadataError { + #[error("resolution node `{0}` is missing")] + MissingNode(CompactString), + #[error("package node `{0}` is missing its name")] + MissingPackageName(CompactString), + #[error("resolution node `{id}` is not a {expected} node")] + UnexpectedNodeKind { + id: CompactString, + expected: &'static str, + }, + #[error("dependency `{0}` is not a known package or extra")] + UnknownDependency(CompactString), + #[error("extra node `{id}` belongs to both package `{first}` and package `{second}`")] + SharedExtra { + id: CompactString, + first: CompactString, + second: CompactString, + }, + #[error("{kind} `{id}` has a non-absolute path `{path}`")] + RelativePath { + kind: &'static str, + id: CompactString, + path: SystemPathBuf, + }, + #[error("multiple workspace members use path `{0}`")] + DuplicateMemberPath(SystemPathBuf), + #[error("no workspace project information is available in uv metadata")] + MissingProjects, + #[error("uv metadata has no module ownership or editable source paths")] + MissingModuleOwnership, + #[error("uv did not provide a Python environment")] + MissingEnvironment, + #[error("could not read uv's Python environment `{path}`: {message}")] + InvalidEnvironment { + path: SystemPathBuf, + message: Box, + }, + #[error("could not resolve the selected Python environment: {0}")] + EnvironmentResolution(Box), + #[error("no Python environment is configured")] + MissingSelectedEnvironment, + #[error( + "selected Python environment `{selected}` (from {selected_origin}) differs from uv's environment `{uv}`" + )] + EnvironmentMismatch { + selected: SystemPathBuf, + selected_origin: Box, + uv: SystemPathBuf, + }, +} + +impl DependencyMetadataError { + pub(crate) fn to_diagnostic(&self, kind: DependencyProjectKind) -> Diagnostic { + let mut diagnostic = Diagnostic::new( + DiagnosticId::UvMetadata, + Severity::Warning, + "Failed to load uv dependency metadata", + ); + diagnostic.set_concise_message(format_args!( + "Failed to load uv dependency metadata: {self}" + )); + diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Info, + self.to_string(), + )); + match self { + Self::MissingModuleOwnership + | Self::MissingEnvironment + | Self::MissingSelectedEnvironment + if kind == DependencyProjectKind::Project => + { + diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Help, + "Synchronize the environment with `uv sync` or run ty through `uv check`", + )); + } + Self::EnvironmentMismatch { uv, .. } => { + diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Help, + format_args!("Use `--python` to select uv's Python environment at `{uv}`"), + )); + } + _ => {} + } + diagnostic + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use anyhow::Context; + use compact_str::CompactString; + use ruff_db::system::{SystemPathBuf, TestSystem}; + use serde_json::{Value, json}; + use ty_module_resolver::ModuleName; + use ty_python_semantic::dependency::{DependencyMetadata, DependencyProject}; + + use super::UvMetadata; + + fn absolute(path: &str) -> SystemPathBuf { + if cfg!(windows) { + SystemPathBuf::from(format!("C:{path}")) + } else { + SystemPathBuf::from(path) + } + } + + fn metadata() -> Value { + json!({ + "schema": {"version": "preview"}, + "workspace_root": absolute("/app"), + "workspace": {"id": "workspace"}, + "members": [{"id": "member", "name": "app", "path": absolute("/app")}], + "module_owners": { + "direct": [{"package_id": "direct"}], + "indirect": [{"package_id": "indirect"}], + "namespace": [{"package_id": "direct"}, {"package_id": "indirect"}], + "namespace.direct": [{"package_id": "direct"}], + "namespace.indirect": [{"package_id": "indirect"}] + }, + "resolution": { + "workspace": { + "kind": "workspace", "dependencies": [], + "dependency_groups": [{"id": "workspace-group"}] + }, + "workspace-group": { + "kind": {"group": "dev"}, + "dependencies": [{"id": "workspace-tool"}] + }, + "member": { + "kind": "package", "name": "app", "source": {"virtual": absolute("/app")}, + "dependencies": [{"id": "required-extra"}], + "optional_dependencies": [{"id": "member-extra"}], + "dependency_groups": [{"id": "member-group"}] + }, + "member-extra": { + "kind": {"extra": "feature"}, + "dependencies": [{"id": "member"}, {"id": "optional"}] + }, + "member-group": { + "kind": {"group": "test"}, + "dependencies": [{"id": "development"}] + }, + "direct": { + "kind": "package", "name": "a-different-distribution-name", + "dependencies": [{"id": "indirect"}], + "optional_dependencies": [{"id": "required-extra"}] + }, + "required-extra": { + "kind": {"extra": "feature"}, + "dependencies": [{"id": "direct"}, {"id": "indirect"}] + }, + "indirect": {"kind": "package", "name": "indirect", "dependencies": []}, + "optional": {"kind": "package", "name": "optional", "dependencies": []}, + "development": { + "kind": "package", "name": "development", + "dependencies": [{"id": "indirect"}] + }, + "workspace-tool": { + "kind": "package", "name": "workspace-tool", "dependencies": [] + } + } + }) + } + + fn extract(metadata: &Value) -> anyhow::Result { + let system = TestSystem::default(); + system + .memory_file_system() + .write_file_all(absolute("/app/pyproject.toml"), "[tool.uv.workspace]")?; + let metadata = UvMetadata::from_metadata(&serde_json::to_vec(metadata)?, &system)?; + Ok(metadata.dependency_metadata()?) + } + + fn project<'a>( + metadata: &'a DependencyMetadata, + path: &str, + ) -> anyhow::Result<&'a DependencyProject> { + let path = absolute(path); + metadata + .projects + .iter() + .find(|project| project.path == path) + .context("expected a project at this path") + } + + fn ids(ids: [&str; N]) -> BTreeSet { + ids.into_iter().map(CompactString::from).collect() + } + + #[test] + fn separates_direct_dependencies_from_transitive_packages() -> anyhow::Result<()> { + let metadata = extract(&metadata())?; + let project = project(&metadata, "/app")?; + + assert_eq!(project.distribution.as_deref(), Some("member")); + assert_eq!(project.dependencies, ids(["direct", "optional"])); + assert_eq!( + project.group_dependencies, + ids(["development", "workspace-tool"]) + ); + assert_eq!( + metadata + .distributions + .get("direct") + .map(|distribution| distribution.name.as_str()), + Some("a-different-distribution-name") + ); + + Ok(()) + } + + #[test] + fn preserves_ambiguous_namespace_owners_and_submodules() -> anyhow::Result<()> { + let metadata = extract(&metadata())?; + + for (name, expected) in [ + ("namespace", vec!["direct", "indirect"]), + ("namespace.direct", vec!["direct"]), + ("namespace.indirect", vec!["indirect"]), + ] { + let name = ModuleName::new(name).context("expected a valid module name")?; + let owners = metadata + .module_owners + .get(&name) + .context("expected module ownership")?; + assert_eq!( + owners.iter().map(CompactString::as_str).collect::>(), + expected + ); + } + + Ok(()) + } + + #[test] + fn workspace_groups_apply_to_each_member_and_virtual_root() -> anyhow::Result<()> { + let mut input = metadata(); + input["members"] = json!([ + {"id": "member", "name": "app", "path": absolute("/app/packages/member")}, + {"id": "sibling", "name": "sibling", "path": absolute("/app/packages/sibling")} + ]); + input["resolution"]["sibling"] = json!({ + "kind": "package", "name": "sibling", "dependencies": [] + }); + let metadata = extract(&input)?; + + assert_eq!(metadata.projects.len(), 3); + let root = project(&metadata, "/app")?; + assert_eq!(root.distribution, None); + assert_eq!(root.dependencies, ids([])); + assert_eq!(root.group_dependencies, ids(["workspace-tool"])); + + let member = project(&metadata, "/app/packages/member")?; + assert_eq!(member.dependencies, ids(["direct", "optional"])); + assert_eq!( + member.group_dependencies, + ids(["development", "workspace-tool"]) + ); + + let sibling = project(&metadata, "/app/packages/sibling")?; + assert_eq!(sibling.dependencies, ids([])); + assert_eq!(sibling.group_dependencies, ids(["workspace-tool"])); + + Ok(()) + } + + #[test] + fn workspace_without_members_can_supply_groups() -> anyhow::Result<()> { + let metadata = extract(&json!({ + "schema": {"version": "preview"}, + "workspace_root": absolute("/app"), + "workspace": {"id": "workspace"}, + "module_owners": {"tool": [{"package_id": "tool"}]}, + "resolution": { + "workspace": { + "kind": "workspace", "dependencies": [], + "dependency_groups": [{"id": "group"}] + }, + "group": {"kind": {"group": "dev"}, "dependencies": [{"id": "tool"}]}, + "tool": {"kind": "package", "name": "tool", "dependencies": []} + } + }))?; + + let root = project(&metadata, "/app")?; + assert_eq!(root.distribution, None); + assert_eq!(root.group_dependencies, ids(["tool"])); + + Ok(()) + } + + #[test] + fn editable_paths_supply_ownership_without_recorded_modules() -> anyhow::Result<()> { + let mut input = metadata(); + input["module_owners"] = json!({}); + input["resolution"]["direct"]["source"] = + json!({"editable": absolute("/editable-package")}); + let metadata = extract(&input)?; + + assert!(metadata.module_owners.is_empty()); + assert_eq!( + metadata + .distributions + .get("direct") + .and_then(|distribution| distribution.editable_path.as_deref()), + Some(absolute("/editable-package").as_path()) + ); + + Ok(()) + } + + #[test] + fn unknown_module_owners_prevent_parent_fallback() -> anyhow::Result<()> { + let mut input = metadata(); + input["module_owners"]["namespace"] = json!([{"package_id": "direct"}]); + input["module_owners"]["namespace.indirect"] = json!([ + {"package_id": "indirect"}, {"package_id": "missing-package"} + ]); + input["module_owners"]["namespace.empty"] = json!([]); + input["module_owners"]["not-a-module"] = json!([{"package_id": "direct"}]); + let metadata = extract(&input)?; + + let namespace = ModuleName::new("namespace").context("expected a valid module name")?; + assert_eq!( + metadata.module_owners.get(&namespace).map(AsRef::as_ref), + Some([CompactString::from("direct")].as_slice()) + ); + for child in ["namespace.indirect", "namespace.empty"] { + let child = ModuleName::new(child).context("expected a valid module name")?; + assert!( + metadata + .module_owners + .get(&child) + .is_some_and(|owners| owners.is_empty()) + ); + } + assert_eq!(metadata.module_owners.len(), 6); + + Ok(()) + } + + #[test] + fn virtual_projects_without_dependencies_do_not_require_module_ownership() -> anyhow::Result<()> + { + for input in [ + json!({ + "schema": {"version": "preview"}, + "workspace_root": absolute("/app"), + "workspace": {"id": "workspace"}, + "resolution": { + "workspace": {"kind": "workspace", "dependencies": []} + } + }), + json!({ + "schema": {"version": "preview"}, + "workspace_root": absolute("/app"), + "members": [{"id": "app", "name": "app", "path": absolute("/app")}], + "resolution": { + "app": { + "kind": "package", "name": "app", "source": {"virtual": absolute("/app")}, + "dependencies": [] + } + } + }), + ] { + let metadata = extract(&input)?; + assert!(project(&metadata, "/app")?.dependencies.is_empty()); + assert!(metadata.module_owners.is_empty()); + } + + Ok(()) + } + + #[test] + fn unavailable_dependency_information_has_a_reason() -> anyhow::Result<()> { + let mut missing_graph = metadata(); + missing_graph["resolution"] = json!({}); + let mut missing_projects = metadata(); + missing_projects["workspace"] = json!(null); + missing_projects["members"] = json!([]); + let mut missing_owners = metadata(); + missing_owners["module_owners"] = json!({}); + + for (input, expected) in [ + (missing_graph, "resolution node `workspace` is missing"), + ( + missing_projects, + "no workspace project information is available in uv metadata", + ), + ( + missing_owners, + "uv metadata has no module ownership or editable source paths", + ), + ] { + let error = extract(&input) + .err() + .context("expected unavailable information to disable dependency checks")?; + assert_eq!(format!("{error:#}"), expected, "{input}"); + } + + Ok(()) + } +} diff --git a/crates/ty_project/src/uv/service.rs b/crates/ty_project/src/uv/service.rs new file mode 100644 index 0000000000..b9e10cc8f1 --- /dev/null +++ b/crates/ty_project/src/uv/service.rs @@ -0,0 +1,360 @@ +use std::process::Output; +use std::sync::{Arc, OnceLock}; + +use crossbeam::channel::{Receiver, Sender, TrySendError}; +use ruff_db::cancellation::CancellationToken; +use ruff_db::files::File; +use ruff_db::system::{CommandExecutor, System, SystemPathBuf}; + +use super::command::unsupported_command_execution; +use super::{MetadataTarget, ScriptEnvironmentCacheKey, Uv, uv_executable_error}; +use crate::UvSyncProgress; + +/// Runs workspace and standalone-script metadata requests with uv. +/// +/// A project stores one service in its shared `UvEnvironments`, so every database snapshot uses +/// the same request queue and workers. The queue has no fixed capacity, so submitting work does +/// not wait for uv. The number of workers still limits concurrent uv processes. +/// +/// [`UvEnvironments`](crate::UvEnvironments) submits at most one job per project or script at a +/// time, including jobs whose results have not yet been consumed. It retains only the latest +/// follow-up and submits it after consuming the previous result. Both request and result queues +/// are therefore bounded by the number of projects and scripts, not the number of changes. +/// Superseded jobs are cancelled before execution when possible. Running uv processes are not +/// interrupted, and cancelled jobs still return a result so their replacements can be scheduled. +/// +/// The service deliberately owns neither a database nor its `System`. Workers stay alive between +/// jobs, while the host must be able to apply file changes and completed uv results. Salsa waits +/// for all database snapshots to be dropped before updating an input. If a worker retained a +/// snapshot, even an idle worker would block those updates. Workers retain only the configured uv +/// executable and a detached command executor. +/// +/// The service only owns scheduling of `uv metadata` calls. +/// [`UvEnvironments`](crate::UvEnvironments) is the higher level abstraction that +/// application code should use. +pub(crate) struct UvMetadataService { + workers: OnceLock>, + + /// Channel, where to send the background results to. + results_sender: Sender, + + /// Signals when new background results are available. + /// + /// This overlaps with `results_sender`, but the main difference is that it doesn't expose the + /// sync result. The LSP and CLI use it as a wake up signal for when to call + /// [`UvEnvironments::poll_sync`](crate::UvEnvironments::poll_sync). + wake_sender: Sender<()>, +} + +impl UvMetadataService { + pub(crate) fn new(results_sender: Sender, wake_sender: Sender<()>) -> Self { + Self { + workers: OnceLock::new(), + results_sender, + wake_sender, + } + } + + /// Submits one background synchronization. + /// + /// Submission does not wait for queue space. Cancellation skips a job that has not started, + /// but still produces a result and wakeup. It does not interrupt a running uv process. + pub(crate) fn schedule_one( + &self, + system: &dyn System, + task: UvSyncTask, + cancellation: CancellationToken, + progress: Option>, + ) { + let workers = match self.worker_pool(system) { + Ok(workers) => workers, + Err(error) => { + self.publish_result(UvMetadataResult { + task, + output: Some(Err(error)), + progress, + }); + return; + } + }; + + let (path, description) = match &task { + UvSyncTask::Workspace(path) => (path.as_path(), "workspace metadata"), + UvSyncTask::Script(task) => (task.request.path(), "script synchronization"), + }; + let span = tracing::debug_span!("uv_metadata", path = %path); + tracing::debug!("Queuing {description} for `{path}`"); + + let job = UvJob { + task, + cancellation, + result_sender: self.results_sender.clone(), + wake_sender: self.wake_sender.clone(), + progress, + span, + }; + if let Err(error) = workers.jobs.send(job) { + let job = error.into_inner(); + self.publish_result(UvMetadataResult { + task: job.task, + output: Some(Err(worker_disconnected())), + progress: job.progress, + }); + } + } + + fn publish_result(&self, result: UvMetadataResult) { + self.results_sender + .send(result) + .expect("the uv synchronization result receiver must remain connected"); + + match self.wake_sender.try_send(()) { + Ok(()) | Err(TrySendError::Full(())) => {} + Err(TrySendError::Disconnected(())) => { + panic!("the uv synchronization wakeup receiver must remain connected"); + } + } + } + + fn worker_pool(&self, system: &dyn System) -> std::io::Result<&UvWorkerPool> { + match self.workers.get_or_init(|| { + let command_executor = system + .command_executor() + .ok_or_else(unsupported_command_execution)?; + let uv = Uv::new(system).map_err(uv_executable_error)?; + UvWorkerPool::new(command_executor, &uv) + }) { + Ok(workers) => Ok(workers), + Err(error) => Err(std::io::Error::new(error.kind(), error.to_string())), + } + } +} + +impl std::panic::RefUnwindSafe for UvMetadataService {} + +/// A standalone script environment that should be synchronized by uv. +#[derive(Debug)] +pub(crate) struct ScriptSyncTask { + /// The script file + pub(crate) file: File, + pub(crate) request: ScriptSyncRequest, +} + +impl ScriptSyncTask { + pub(crate) fn new( + file: File, + path: SystemPathBuf, + python: Option, + cache_key: ScriptEnvironmentCacheKey, + ) -> Self { + Self { + file, + request: ScriptSyncRequest(Arc::new(ScriptSyncRequestData { + path, + python, + cache_key, + })), + } + } +} + +/// The immutable inputs to one uv synchronization. +/// +/// The entry state and worker retain cheap clones of the request. These inputs are owned so jobs +/// can outlive the host operation that scheduled them without retaining its database. +#[derive(Clone, Debug)] +pub(crate) struct ScriptSyncRequest(Arc); + +impl ScriptSyncRequest { + pub(crate) fn path(&self) -> &ruff_db::system::SystemPath { + &self.0.path + } + + fn metadata_target(&self) -> MetadataTarget<'_> { + MetadataTarget::Script { + path: &self.0.path, + python: self.0.python.as_deref(), + } + } + + pub(crate) fn cache_key(&self) -> ScriptEnvironmentCacheKey { + self.0.cache_key + } +} + +#[derive(Debug)] +struct ScriptSyncRequestData { + path: SystemPathBuf, + python: Option, + cache_key: ScriptEnvironmentCacheKey, +} + +/// Identifies a background workspace or script request. +#[derive(Debug)] +pub(crate) enum UvSyncTask { + Workspace(SystemPathBuf), + Script(ScriptSyncTask), +} + +impl UvSyncTask { + fn metadata_target(&self) -> MetadataTarget<'_> { + match self { + Self::Workspace(path) => MetadataTarget::Workspace(path), + Self::Script(task) => task.request.metadata_target(), + } + } +} + +/// The result of a background uv metadata request. +/// +/// This keeps the progress reporter alive until the result is consumed or rescheduled. +pub(crate) struct UvMetadataResult { + pub(crate) task: UvSyncTask, + /// `None` if the worker skipped this request because it was cancelled before execution. + pub(crate) output: Option>, + pub(crate) progress: Option>, +} + +/// Runs a limited number of workspace and script metadata commands concurrently. +struct UvWorkerPool { + /// Disconnects when the pool is dropped so workers abandon buffered jobs. + /// + /// This field precedes `jobs` so shutdown wins if dropping the pool makes both channels + /// ready simultaneously. + _shutdown: Sender<()>, + + /// Sender end of the pool <-> worker communication. + /// + /// Used to submit jobs to the workers. + jobs: Sender, +} + +impl UvWorkerPool { + /// Creates worker threads using a detached command executor and resolved uv executable. + fn new(command_executor: &dyn CommandExecutor, uv: &Uv) -> std::io::Result { + let (jobs, job_receiver) = crossbeam::channel::unbounded(); + let (shutdown, shutdown_receiver) = crossbeam::channel::bounded(0); + + let workers = ruff_db::max_parallelism().get().div_ceil(4); + + tracing::debug!("Starting {workers} uv synchronization workers"); + + for index in 0..workers { + let worker = UvWorker { + executor: command_executor.dyn_clone(), + uv: uv.clone(), + jobs: job_receiver.clone(), + shutdown: shutdown_receiver.clone(), + }; + + let _ = std::thread::Builder::new() + .name(format!("ty-uv-sync-{index}")) + .spawn(move || worker.run())?; + } + + Ok(Self { + jobs, + _shutdown: shutdown, + }) + } +} + +struct UvWorker { + executor: Box, + uv: Uv, + + /// Receiver end of the pool <-> worker channel. + /// + /// Used to retrieve jobs. + jobs: Receiver, + + /// Channel used as a signal when the [`UvWorkerPool`] disconnects. + /// + /// When `jobs` disconnects, the receiver still yields all elements + /// that are already queued, before returning `Disconnect`. We use this + /// always-empty channel to be informed immediately if the worker pool disconnects, + /// to avoid processing any unnecessary items. + shutdown: Receiver<()>, +} + +impl UvWorker { + fn run(self) { + loop { + let mut job = crossbeam::channel::select_biased! { + // The worker pool disconnected, exit immetiatley + recv(self.shutdown) -> _ => return, + recv(self.jobs) -> job => { + let Ok(job) = job else { + return; + }; + job + } + }; + + let _span = job.span.enter(); + let output = if job.cancellation.is_cancelled() { + tracing::debug!("Skipped cancelled uv metadata request"); + None + } else { + let target = job.task.metadata_target(); + match &target { + MetadataTarget::Workspace(path) => { + tracing::info!("Reading workspace metadata for `{path}`"); + } + MetadataTarget::Script { path, .. } => { + tracing::info!("Synchronizing script `{path}`"); + } + } + if let Some(progress) = job.progress.as_mut() { + progress.started(); + } + + let output = self.uv.execute(&*self.executor, &target); + + if let Some(progress) = job.progress.as_mut() { + progress.finished(); + } + + Some(output) + }; + + // Send the result + // The receiver disappears when the owning project is dropped. + if job + .result_sender + .send(UvMetadataResult { + task: job.task, + output, + progress: job.progress, + }) + .is_ok() + { + // Signal that there's a new result. + let _ = job.wake_sender.try_send(()); + } + } + } +} + +struct UvJob { + /// The request to return with the synchronization result. + task: UvSyncTask, + + /// Checked before invoking uv; does not interrupt an already running process. + cancellation: CancellationToken, + + /// The sender end of the channel communicating with the sync service. + result_sender: Sender, + + /// The wake signal that notifies that there's a new result to process. + wake_sender: Sender<()>, + progress: Option>, + span: tracing::Span, +} + +fn worker_disconnected() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "uv synchronization worker terminated unexpectedly", + ) +} diff --git a/crates/ty_project/src/walk.rs b/crates/ty_project/src/walk.rs index 57a89b235b..4b6d224b9e 100644 --- a/crates/ty_project/src/walk.rs +++ b/crates/ty_project/src/walk.rs @@ -1,11 +1,11 @@ +use crate::files::IndexedFile; use crate::glob::IncludeExcludeFilter; -use crate::metadata::script::script_metadata; +use crate::script::script_tag; use crate::{Db, GlobFilterCheckMode, IncludeResult, Project}; use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity}; -use ruff_db::files::{File, system_path_to_file}; +use ruff_db::files::system_path_to_file; use ruff_db::system::walk_directory::{ErrorKind, WalkDirectoryBuilder, WalkState}; use ruff_db::system::{SystemPath, SystemPathBuf, deduplicate_nested_paths}; -use rustc_hash::FxHashSet; use std::collections::BTreeSet; use std::path::PathBuf; use thiserror::Error; @@ -144,7 +144,14 @@ impl ProjectFilesWalker { /// Walks the project paths and collects the paths of all files that /// are included in the project. - pub(crate) fn collect_vec(self, db: &dyn Db) -> (Vec, Vec) { + /// + /// The file-walk callback uses a cloned database. Salsa queries called inside it do not + /// record dependencies for the query that started the walk. + /// + /// [`Project::files`] instead depends on the project's `file_set` input, which + /// [`crate::ProjectDatabase::apply_changes`] updates when file or script membership changes. + /// Queries that read the collected files' contents record those dependencies themselves. + pub(crate) fn collect_vec(self, db: &dyn Db) -> (Vec, Vec) { let project = db.project(); let root_paths = project.included_paths_or_root(db); @@ -180,144 +187,146 @@ impl ProjectFilesWalker { let diagnostics = &diagnostics; let force_exclude = filter.force_exclude(); + // The memory walker runs on the caller's thread, where a different database may + // already be attached. The caller tracks the file-set input, not this visitor's reads. Box::new(move |entry| { - db.unwind_if_revision_cancelled(); - - match entry { - Ok(entry) => { - if incremental_paths.as_ref().is_some_and(|incremental_paths| { - !should_visit_incremental_path(entry.path(), incremental_paths) - }) { - return WalkState::Skip; - } - - // Skip excluded directories unless they were explicitly passed to the walker - // (which is the case passed to `ty check `). - if entry.file_type().is_directory() { - if entry.depth() > 0 || force_exclude { - let directory_included = filter.is_directory_included( - entry.path(), - GlobFilterCheckMode::TopDown, - ); - return match directory_included { - IncludeResult::Included { .. } => WalkState::Continue, - IncludeResult::Excluded => { - tracing::debug!( - "Skipping directory '{path}' because it is excluded by \ - a default or `src.exclude` pattern", - path = entry.path() - ); - WalkState::Skip - } - IncludeResult::NotIncluded => { - tracing::debug!( - "Skipping directory `{path}` because it doesn't match \ - any `src.include` pattern or path specified on the CLI", - path = entry.path() - ); - WalkState::Skip - } - }; + salsa::attach_allow_change(&*db, || { + db.unwind_if_revision_cancelled(); + + match entry { + Ok(entry) => { + if incremental_paths.as_ref().is_some_and(|incremental_paths| { + !should_visit_incremental_path(entry.path(), incremental_paths) + }) { + return WalkState::Skip; } - } else { - // For all files, except the ones that were explicitly passed to the walker (CLI), - // check if they're included in the project. - if entry.depth() > 0 || force_exclude { - let match_mode = if entry.depth() == 0 && force_exclude { - GlobFilterCheckMode::Adhoc - } else { - GlobFilterCheckMode::TopDown - }; - match filter.is_file_included(entry.path(), match_mode) { - include_result @ IncludeResult::Included { .. } => { - // Ignore any non python files to avoid creating too many entries in `Files`. - // Unless the file is explicitly passed on the CLI or a literal match in the `include`, we then always assume it's a file ty can analyze - if entry.depth() > 0 - && !include_result - .should_index_file(db.system(), entry.path()) - { + + // Skip excluded directories unless they were explicitly passed to the walker + // (which is the case passed to `ty check `). + if entry.file_type().is_directory() { + if entry.depth() > 0 || force_exclude { + let directory_included = filter.is_directory_included( + entry.path(), + GlobFilterCheckMode::TopDown, + ); + return match directory_included { + IncludeResult::Included { .. } => WalkState::Continue, + IncludeResult::Excluded => { + tracing::debug!( + "Skipping directory '{path}' because it is excluded by \ + a default or `src.exclude` pattern", + path = entry.path() + ); + WalkState::Skip + } + IncludeResult::NotIncluded => { + tracing::debug!( + "Skipping directory `{path}` because it doesn't match \ + any `src.include` pattern or path specified on the CLI", + path = entry.path() + ); + WalkState::Skip + } + }; + } + } else { + // For all files, except the ones that were explicitly passed to the walker (CLI), + // check if they're included in the project. + if entry.depth() > 0 || force_exclude { + let match_mode = if entry.depth() == 0 && force_exclude { + GlobFilterCheckMode::Adhoc + } else { + GlobFilterCheckMode::TopDown + }; + match filter.is_file_included(entry.path(), match_mode) { + include_result @ IncludeResult::Included { .. } => { + // Ignore any non python files to avoid creating too many entries in `Files`. + // Unless the file is explicitly passed on the CLI or a literal match in the `include`, we then always assume it's a file ty can analyze + if entry.depth() > 0 + && !include_result + .should_index_file(db.system(), entry.path()) + { + return WalkState::Skip; + } + } + IncludeResult::Excluded => { + tracing::debug!( + "Ignoring file `{path}` because it is excluded by \ + a default or `src.exclude` pattern.", + path = entry.path() + ); + return WalkState::Skip; + } + IncludeResult::NotIncluded => { + tracing::debug!( + "Ignoring file `{path}` because it doesn't match any \ + `src.include` pattern or path specified on the CLI.", + path = entry.path() + ); return WalkState::Skip; } } - IncludeResult::Excluded => { - tracing::debug!( - "Ignoring file `{path}` because it is excluded by \ - a default or `src.exclude` pattern.", - path = entry.path() - ); - return WalkState::Skip; - } - IncludeResult::NotIncluded => { + } + + // A file another language owns is never handed to the type checker, + // not even when it was named on the command line — the branch above + // takes an explicitly passed path to be something ty can analyze, + // and a Django template read as Python is a page of syntax errors. + // It is still checked, by the checker that owns it. + if db + .project_checker() + .is_some_and(|checker| checker.owns(&*db, entry.path())) + { + return WalkState::Skip; + } + + // If this returns `Err`, then the file was deleted between now and when the walk callback was called. + // We can ignore this. + if let Ok(file) = system_path_to_file(&*db, entry.path()) { + let is_script = script_tag(&*db, file).is_some(); + if entry.depth() > 0 && exclude_scripts && is_script { tracing::debug!( - "Ignoring file `{path}` because it doesn't match any \ - `src.include` pattern or path specified on the CLI.", + "Ignoring implicitly discovered PEP 723 script `{path}` \ + because `exclude-scripts` is enabled.", path = entry.path() ); return WalkState::Skip; } - } - } - - // A file another language owns is never handed to the type checker, - // not even when it was named on the command line — the branch above - // takes an explicitly passed path to be something ty can analyze, - // and a Django template read as Python is a page of syntax errors. - // It is still checked, by the checker that owns it. - if db - .project_checker() - .is_some_and(|checker| checker.owns(&*db, entry.path())) - { - return WalkState::Skip; - } - // If this returns `Err`, then the file was deleted between now and when the walk callback was called. - // We can ignore this. - if let Ok(file) = system_path_to_file(&*db, entry.path()) { - if entry.depth() > 0 - && exclude_scripts - && script_metadata(&*db, file).is_some() - { - tracing::debug!( - "Ignoring implicitly discovered PEP 723 script `{path}` \ - because `exclude-scripts` is enabled.", - path = entry.path() - ); - return WalkState::Skip; + files.lock().unwrap().push(IndexedFile { file, is_script }); } - - files.lock().unwrap().push(file); } } - } - Err(error) => { - let error = match error.kind() { - ErrorKind::Loop { .. } => { - unreachable!( - "Loops shouldn't be possible without following symlinks." - ) - } - ErrorKind::Io { path, err } => { - if let Some(path) = path { - WalkError::IOPathError { - path: path.clone(), - error: err.to_string(), - } - } else { - WalkError::IOError { - error: err.to_string(), + Err(error) => { + let error = match error.kind() { + ErrorKind::Loop { .. } => { + unreachable!( + "Loops shouldn't be possible without following symlinks." + ) + } + ErrorKind::Io { path, err } => { + if let Some(path) = path { + WalkError::IOPathError { + path: path.clone(), + error: err.to_string(), + } + } else { + WalkError::IOError { + error: err.to_string(), + } } } - } - ErrorKind::NonUtf8Path { path } => { - WalkError::NonUtf8Path { path: path.clone() } - } - }; + ErrorKind::NonUtf8Path { path } => { + WalkError::NonUtf8Path { path: path.clone() } + } + }; - diagnostics.lock().unwrap().push(error.to_diagnostic()); + diagnostics.lock().unwrap().push(error.to_diagnostic()); + } } - } - WalkState::Continue + WalkState::Continue + }) }) }); @@ -326,11 +335,6 @@ impl ProjectFilesWalker { diagnostics.into_inner().unwrap(), ) } - - pub(crate) fn collect_set(self, db: &dyn Db) -> (FxHashSet, Vec) { - let (files, diagnostics) = self.collect_vec(db); - (files.into_iter().collect(), diagnostics) - } } pub(crate) fn create_walker_builder(db: &dyn Db, paths: I) -> Option diff --git a/crates/ty_project/src/watch.rs b/crates/ty_project/src/watch.rs index b22596b99a..b622029ab0 100644 --- a/crates/ty_project/src/watch.rs +++ b/crates/ty_project/src/watch.rs @@ -22,7 +22,10 @@ mod watcher; /// event instead of emitting an event for each file or subdirectory in that path. #[derive(Debug, PartialEq, Eq)] pub enum ChangeEvent { - /// The file corresponding to the given path was opened in an editor. + /// A new or existing file was opened in an editor. + /// + /// Refresh its metadata and add it to an existing project index if it is included. + /// The editor may open a file before its filesystem creation notification arrives. Opened(SystemPathBuf), /// A new path was created @@ -88,7 +91,7 @@ impl ChangeEvent { matches!(self, ChangeEvent::Rescan) } - pub const fn is_created(&self) -> bool { + pub(crate) const fn is_created(&self) -> bool { matches!(self, ChangeEvent::Created { .. }) } diff --git a/crates/ty_python_core/Cargo.toml b/crates/ty_python_core/Cargo.toml index ca9f0e95fe..5bc4768567 100644 --- a/crates/ty_python_core/Cargo.toml +++ b/crates/ty_python_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_core" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_python_core/README.md b/crates/ty_python_core/README.md index c25f0aed08..2edb1f0df2 100644 --- a/crates/ty_python_core/README.md +++ b/crates/ty_python_core/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_python_core). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_python_core). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 1950fe7414..de30eb74c0 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -1,7 +1,7 @@ use std::cell::{OnceCell, RefCell}; use std::sync::Arc; -use except_handlers::TryNodeContextStackManager; +use except_handlers::{ExceptionContextStackManager, ExceptionHandlers}; use itertools::Itertools; use ruff_python_ast::helpers::{ BindingKeyword, Truthiness, any_over_expr, binding_keyword, is_dotted_name, @@ -24,7 +24,9 @@ use ruff_python_parser::semantic_errors::{ }; use ruff_text_size::{Ranged, TextRange}; use smallvec::SmallVec; -use ty_module_resolver::{ImportingFile, ModuleName, ResolverEnvironment, resolve_module}; +use ty_module_resolver::{ + ImportingFile, ModuleName, ResolverEnvironment, resolve_module_for_import_from, +}; use crate::BlockScopedDeclaration; use crate::HasTrackedScope; @@ -33,7 +35,7 @@ use crate::ast_ids::node_key::ExpressionNodeKey; use crate::ast_ids::{AstIdsBuilder, ScopedUseId}; use crate::ast_node_ref::AstNodeRef; use crate::definition::{ - AnnotatedAssignmentDefinitionNodeRef, AssignmentDefinitionNodeRef, + AnnotatedAssignmentDefinitionNodeRef, AssignmentDefinitionNodeRef, BindingsOwner, ComprehensionDefinitionNodeRef, Definition, DefinitionCategory, DefinitionKind, DefinitionNodeKey, DefinitionNodeRef, Definitions, DictKeyAssignmentNodeRef, ExceptHandlerDefinitionNodeRef, ForStmtDefinitionNodeRef, ImportDefinitionNodeRef, @@ -73,6 +75,7 @@ use crate::unpack::{Unpack, UnpackKind, UnpackPosition, UnpackValue}; use crate::use_def::{ EnclosingSnapshotKey, FlowSnapshot, FutureDefinitions, LiveBinding, LiveBindingStatus, PreviousDefinitions, ScopedDefinitionId, ScopedEnclosingSnapshotId, UseDefMapBuilder, + UseDefMapInterner, }; use crate::{Db, Statement, StatementNodeKey}; use crate::{ @@ -245,6 +248,39 @@ impl ConditionFlowSnapshot { } } +/// Whether evaluation produces a result object or chooses a control-flow path. +/// +/// In `Value` context, the enclosing code receives the expression's result object. For example, +/// `result = x and y` produces `x` if `x` is falsy, or `y` otherwise. This also applies to expressions +/// that return `bool`: the comparison in `result = x > 0` has value context. +/// +/// In `Condition` context, the enclosing code only needs to know which branch to take. For example, +/// CPython evaluates `if x and y` by testing `x` and, only if `x` is truthy, testing `y`. If `x` tests +/// falsy, that one truthiness check is enough to skip the body: `x` is not tested again as the +/// result of `x and y`. +/// +/// This distinction matters when an operand's `__bool__` can change between calls: +/// +/// ```python +/// if x and False: # A falsy x skips the body; a truthy x reaches False. +/// ... # Unreachable in either case. +/// saved = x and False # Can produce x after checking that it is falsy. +/// if saved: # Can call x.__bool__ again, which may now return True. +/// ... # Reachable. +/// ``` +/// +/// The context propagates through `and`, `or`, `not`, and the branches of conditional expressions. +/// Condition context does not propagate through calls or assignment expressions: in +/// `if f(x and False)`, the call's result controls the branch, but its argument is evaluated in +/// value context. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExpressionContext { + /// Produce the expression's result object for the enclosing code to use. + Value, + /// Choose the truthy or falsy control-flow path without preserving the result object. + Condition, +} + #[expect( clippy::struct_excessive_bools, reason = "independent walk flags and one cached setting, not a state machine" @@ -269,8 +305,8 @@ pub(super) struct SemanticIndexBuilder<'db, 'ast> { /// The name of the first function parameter of the innermost function that we're currently visiting. current_first_parameter_name: Option<&'ast str>, - /// Per-scope contexts regarding nested `try`/`except` statements - try_node_context_stack_manager: TryNodeContextStackManager, + /// Per-scope exception contexts for nested `try` and `with` statements. + exception_context_stack_manager: ExceptionContextStackManager, /// Flags about the file's global scope has_future_annotations: bool, @@ -282,7 +318,9 @@ pub(super) struct SemanticIndexBuilder<'db, 'ast> { python_version: PythonVersion, source_text: OnceCell, semantic_checker: SemanticSyntaxChecker, - in_try: bool, + /// Whether the current statement is inside a `try` statement, including its `except`, `else`, + /// and `finally` suites. Used for semantic syntax checks independently of handler activity. + in_try_statement: bool, // Semantic Index fields scopes: IndexVec, @@ -415,7 +453,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { current_match_case: None, current_statement_expressions: Vec::new(), current_first_parameter_name: None, - try_node_context_stack_manager: TryNodeContextStackManager::default(), + exception_context_stack_manager: ExceptionContextStackManager::default(), has_future_annotations: false, in_type_checking_block: false, @@ -450,7 +488,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { python_version: file.python_version(db), source_text: OnceCell::new(), semantic_checker: SemanticSyntaxChecker::default(), - in_try: false, + in_try_statement: false, semantic_syntax_errors: RefCell::default(), narrowing_aliases: FxHashMap::default(), alias_predicates: FxHashMap::default(), @@ -618,13 +656,13 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let node_with_kind = node.to_kind(self.module); let scope = Scope::new(parent, node_with_kind, children_start..children_start); - let is_class_scope = scope.kind().is_class(); - self.try_node_context_stack_manager.enter_nested_scope(); + let scope_kind = scope.kind(); + self.exception_context_stack_manager.enter_nested_scope(); let file_scope_id = self.scopes.push(scope); self.place_tables.push(PlaceTableBuilder::default()); self.use_def_maps - .push(Box::new(UseDefMapBuilder::new(is_class_scope))); + .push(Box::new(UseDefMapBuilder::new(scope_kind))); let ast_id_scope = self.ast_ids.push(AstIdsBuilder::default()); let scope_id = ScopeId::new(self.db, self.file, file_scope_id); @@ -1010,7 +1048,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { /// scope, including those contributed by `global` and `nonlocal` keywords in the popped scope, /// but excluding nested `nonlocal`s that resolved to the popped scope. fn pop_scope(&mut self) -> NestedGlobalOrNonlocalDeclarations { - self.try_node_context_stack_manager.exit_scope(); + self.exception_context_stack_manager.exit_scope(); let ScopeInfo { file_scope_id: popped_scope_id, @@ -1377,9 +1415,13 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let Some(name) = leaf.as_name_expr() else { return; }; - let Some(alias) = self.narrowing_aliases.get(&name.id).cloned() else { + let Some(alias) = self.narrowing_aliases.get(&name.id) else { return; }; + if self.current_ast_ids().try_use_id(leaf).is_none() { + return; + } + let aliased_expression = Expression::new( self.db, self.scope_ids_by_scope[alias.expression_scope], @@ -1439,6 +1481,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } fn flow_snapshot_for_condition(&mut self, condition: &ast::Expr) -> ConditionFlowSnapshot { + self.record_exception_checkpoint_if(!Self::condition_evaluation_is_known_safe(condition)); + if let Some(snapshots) = self.take_condition_flow_snapshots(condition) { ConditionFlowSnapshot::Branches(snapshots) } else { @@ -1675,7 +1719,11 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { fn record_place_definition(&mut self, place_id: ScopedPlaceId, expr: &'ast ast::Expr) { match self.current_assignment() { - Some(CurrentAssignment::Assign { node, unpack }) => { + Some(CurrentAssignment::Assign { + node, + unpack, + owner, + }) => { let assignment = self.add_definition( place_id, AssignmentDefinitionNodeRef { @@ -1684,17 +1732,25 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { value: &node.value, target: expr, sole_target: node.targets.len() == 1, + owner, }, ); self.add_dict_key_assignment_definitions(&node.targets, &node.value, assignment); } - Some(CurrentAssignment::AnnAssign(ann_assign)) => { + Some(CurrentAssignment::AnnAssign { + node: ann_assign, + pending, + }) => { self.add_standalone_type_expression(&ann_assign.annotation); - let assignment = self.add_definition( - place_id, - AnnotatedAssignmentDefinitionNodeRef { node: ann_assign }, - ); + let assignment = if let Some(pending) = pending { + self.finish_annotated_assignment(pending) + } else { + self.add_definition( + place_id, + AnnotatedAssignmentDefinitionNodeRef { node: ann_assign }, + ) + }; if let Some(value) = ann_assign.value.as_deref() { self.add_dict_key_assignment_definitions( @@ -1771,7 +1827,19 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { place: ScopedPlaceId, definition_node: impl Into> + std::fmt::Debug + Copy, ) -> Definition<'db> { - let (definition, num_definitions) = self.push_additional_definition(place, definition_node); + let definition = self.create_definition(place, definition_node); + self.record_definition(place, definition, None); + definition + } + + /// Create a definition without making its declaration or binding visible in control flow. + fn create_definition( + &mut self, + place: ScopedPlaceId, + definition_node: impl Into> + std::fmt::Debug + Copy, + ) -> Definition<'db> { + let (definition, num_definitions) = + self.create_additional_definition(place, definition_node); debug_assert_eq!( num_definitions, 1, "Attempted to create multiple `Definition`s associated with AST node {definition_node:?}" @@ -1801,10 +1869,6 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { /// Push a new [`Definition`] onto the list of definitions /// associated with the `definition_node` AST node. /// - /// Returns a 2-element tuple, where the first element is the newly created [`Definition`] - /// and the second element is the number of definitions that are now associated with - /// `definition_node`. - /// /// Most AST nodes can only be associated with at most one [`Definition`]. Generally prefer /// `add_definition` above, which enforces that. This method should currently only be used with /// `*` imports and loop headers. @@ -1812,6 +1876,20 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { &mut self, place: ScopedPlaceId, definition_node: impl Into>, + ) { + let (definition, _) = self.create_additional_definition(place, definition_node); + self.record_definition(place, definition, None); + } + + /// Create a [`Definition`] without recording it in control flow. + /// + /// Returns the new definition and the number of definitions now associated with its AST + /// node. Loop headers are not stored by AST node, so their count is zero. Prefer + /// [`Self::create_definition`] when the node must have exactly one definition. + fn create_additional_definition( + &mut self, + place: ScopedPlaceId, + definition_node: impl Into>, ) -> (Definition<'db>, usize) { let definition_node: DefinitionNodeRef<'ast, 'db> = definition_node.into(); @@ -1835,8 +1913,6 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { definitions.len() }; - self.record_definition(place, definition, None); - (definition, num_definitions) } @@ -1853,8 +1929,83 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { previous_definitions: Option, ) { let kind = definition.kind(self.db); - let is_loop_header = kind.is_loop_header(); let category = kind.category(self.source_type.is_stub(), self.module); + match category { + DefinitionCategory::Declaration => { + self.mark_place_declared(place); + self.current_use_def_map_mut() + .record_declaration(place, definition); + } + DefinitionCategory::DeclarationAndBinding => { + self.mark_place_declared(place); + self.record_binding_with(definition, |use_def, place| { + use_def.record_combined_definition(place, definition, category); + }); + } + DefinitionCategory::Binding => { + let previous = previous_definitions.unwrap_or(if kind.is_loop_header() { + PreviousDefinitions::AreKept + } else { + PreviousDefinitions::AreShadowed + }); + self.record_binding_with(definition, |use_def, place| { + use_def.record_binding( + place, + definition, + previous, + FutureDefinitions::ShadowThisOne, + ); + }); + } + } + } + + /// Declare an annotated name assignment whose value will be bound after visiting its RHS. + /// Other targets and annotations without a RHS are recorded in full by `add_definition`. + fn begin_annotated_assignment( + &mut self, + node: &'ast ast::StmtAnnAssign, + ) -> Option> { + let ast::Expr::Name(name) = &*node.target else { + return None; + }; + node.value.as_ref()?; + + let place = self.add_symbol(name.id.clone()).into(); + let definition = + self.create_definition(place, AnnotatedAssignmentDefinitionNodeRef { node }); + self.mark_place_declared(place); + self.current_use_def_map_mut().record_combined_definition( + place, + definition, + DefinitionCategory::Declaration, + ); + Some(PendingAnnotatedAssignment { definition }) + } + + /// Bind the value of an annotated assignment whose declaration was recorded before its RHS. + fn finish_annotated_assignment( + &mut self, + pending: PendingAnnotatedAssignment<'db>, + ) -> Definition<'db> { + let definition = pending.definition; + self.record_binding_with(definition, |use_def, place| { + use_def.record_combined_definition(place, definition, DefinitionCategory::Binding); + }); + definition + } + + /// Record one binding while keeping aliases, captures, and lazy snapshots in sync. + /// The callback receives the definition's place and must append that binding to the current + /// use-def map. + fn record_binding_with( + &mut self, + definition: Definition<'db>, + record: impl FnOnce(&mut UseDefMapBuilder<'db>, ScopedPlaceId), + ) { + let place = definition.place(self.db); + let kind = definition.kind(self.db); + let is_loop_header = kind.is_loop_header(); // We need to avoid marking places as bound as soon as we encounter a loop header // definition for them, because that would lead to false-positive semantic syntax errors in @@ -1874,7 +2025,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // why only the plain assignment form is set apart here let binds_by_block_assignment = matches!(kind, DefinitionKind::Assignment(_)) && self.in_trailing_lambda_block(); - if category.is_binding() && !is_loop_header { + if !is_loop_header { if binds_by_case_name { self.mark_place_bound_by_case_name(place); } else if binds_by_block_assignment { @@ -1884,46 +2035,18 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } self.invalidate_narrowing_aliases_for(place); } - if category.is_declaration() { - self.mark_place_declared(place); - } let definition_id = self.current_use_def_map().next_definition_id(); - let use_def = self.current_use_def_map_mut(); - match category { - DefinitionCategory::DeclarationAndBinding => { - use_def.record_declaration_and_binding(place, definition); - self.delete_associated_bindings(place); - } - DefinitionCategory::Declaration => use_def.record_declaration(place, definition), - DefinitionCategory::Binding => { - let previous = previous_definitions.unwrap_or(if is_loop_header { - PreviousDefinitions::AreKept - } else { - PreviousDefinitions::AreShadowed - }); - use_def.record_binding( - place, - definition, - previous, - FutureDefinitions::ShadowThisOne, - ); - if !is_loop_header { - self.delete_associated_bindings(place); - } - } + record(self.current_use_def_map_mut(), place); + + if !is_loop_header { + self.delete_associated_bindings(place); } - if category.is_binding() - && let Some(id) = place.as_symbol() - { + if let Some(id) = place.as_symbol() { self.record_pending_capture_binding(id, definition_id); self.update_lazy_snapshots(id); } - - let mut try_node_stack_manager = std::mem::take(&mut self.try_node_context_stack_manager); - try_node_stack_manager.record_definition(self); - self.try_node_context_stack_manager = try_node_stack_manager; } // Creates a definition for each key-value assignment in the dictionary. @@ -2016,18 +2139,31 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } } - /// Create loop header definitions for all places that are bound within a loop. Return the - /// `LoopHeaderId` referenced by those definitions, the set of bound place IDs, and the lower - /// bound `ScopedDefinitionId` for definitions created within the loop. + /// Create loop header definitions for places that are bound or invalidated within a loop. + /// Return the `LoopHeaderId` referenced by those definitions, the set of place IDs, and the + /// lower bound `ScopedDefinitionId` for definitions created within the loop. fn synthesize_loop_header_definitions( &mut self, loop_stmt: LoopStmtRef<'ast>, bound_places: Vec, ) -> (LoopHeaderId, FxHashSet, ScopedDefinitionId) { let loop_header_id = self.current_use_def_map_mut().reserve_loop_header(); + let bound_places: Vec<_> = bound_places + .into_iter() + .map(|place| self.add_place(place)) + .collect(); + + // Rebinding `x` also invalidates `x.attr` and `x[index]`. These places need their own + // headers so that the invalidation reaches uses before the assignment on later + // iterations. Register all explicit targets first to include their associated places. + let associated_places: Vec<_> = bound_places + .iter() + .flat_map(|place| self.current_place_table().associated_place_ids(*place)) + .copied() + .map(ScopedPlaceId::from) + .collect(); let mut bound_place_ids: FxHashSet = FxHashSet::default(); - for place_expr in bound_places { - let place_id = self.add_place(place_expr); + for place_id in bound_places.into_iter().chain(associated_places) { if bound_place_ids.insert(place_id) { let loop_header_ref = LoopHeaderDefinitionNodeRef { loop_stmt, @@ -2589,12 +2725,16 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { &mut self, predicate_node: &'ast ast::Expr, ) -> (PredicateOrLiteral<'db>, ScopedPredicateId) { - let predicate = self.build_predicate(predicate_node); + let predicate = self.build_predicate(predicate_node, ExpressionContext::Condition); let predicate_id = self.record_narrowing_constraint(predicate); (predicate, predicate_id) } - fn build_predicate(&mut self, predicate_node: &'ast ast::Expr) -> PredicateOrLiteral<'db> { + fn build_predicate( + &mut self, + predicate_node: &'ast ast::Expr, + context: ExpressionContext, + ) -> PredicateOrLiteral<'db> { // Some commonly used test expressions are eagerly evaluated as `true` // or `false` here for performance reasons. This list does not need to // be exhaustive. More complex expressions will still evaluate to the @@ -2608,6 +2748,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .. }) => Some(*n != 0), ast::Expr::EllipsisLiteral(_) => Some(true), + ast::Expr::Lambda(_) | ast::Expr::Generator(_) => Some(true), ast::Expr::NoneLiteral(_) => Some(false), ast::Expr::UnaryOp(ast::ExprUnaryOp { op: ast::UnaryOp::Not, @@ -2625,7 +2766,23 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { match resolve_to_literal(predicate_node) { Some(literal) => PredicateOrLiteral::Literal(literal), None => PredicateOrLiteral::Predicate(Predicate { - node: PredicateNode::Expression(expression), + node: match (context, predicate_node) { + ( + ExpressionContext::Condition, + ast::Expr::BoolOp(_) + | ast::Expr::If(_) + | ast::Expr::UnaryOp(ast::ExprUnaryOp { + op: ast::UnaryOp::Not, + .. + }), + ) => PredicateNode::Condition(expression), + (ExpressionContext::Condition, ast::Expr::Compare(compare)) + if compare.ops.len() > 1 => + { + PredicateNode::ChainedComparisonCondition(expression) + } + _ => PredicateNode::Expression(expression), + }, is_positive: true, }), } @@ -2784,7 +2941,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let place_table = self.current_place_table(); match pred.node { - PredicateNode::Expression(expression) => { + PredicateNode::Expression(expression) + | PredicateNode::Condition(expression) + | PredicateNode::ChainedComparisonCondition(expression) => { let expression_node = expression.node_ref(self.db).node(self.module); let mut places = PossiblyNarrowedPlacesBuilder::new(self.db, place_table) .expression(expression_node); @@ -2807,6 +2966,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } PredicateNode::SubjectElementPattern(_) | PredicateNode::IsNonTerminalCall(_) + | PredicateNode::ContextManagerSuppresses { .. } + | PredicateNode::FinallyNormalPathImpossible { .. } | PredicateNode::IsNonEmptyIterable(_) | PredicateNode::OrPatternAlternative(_) | PredicateNode::StarImportPlaceholder(_) @@ -2851,9 +3012,172 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { /// Records that the current state can enter any active `finally` suites before the current /// terminal control-flow transfer reaches its destination. fn record_terminal_finally_entry(&mut self) { - let mut try_node_stack_manager = std::mem::take(&mut self.try_node_context_stack_manager); - try_node_stack_manager.record_terminal_finally_entry(self); - self.try_node_context_stack_manager = try_node_stack_manager; + let mut exception_context_stack_manager = + std::mem::take(&mut self.exception_context_stack_manager); + exception_context_stack_manager.record_terminal_finally_entry(self); + self.exception_context_stack_manager = exception_context_stack_manager; + } + + /// Returns whether an exception raised while evaluating `scope` can propagate directly to its + /// enclosing scope. + /// + /// Generator expressions follow the eager comprehension-scope convention used throughout our + /// flow model. Although generators are lazy at runtime, their bodies are assumed to execute + /// immediately, since in practice they are almost always eagerly iterated over. + /// + /// ```python + /// try: + /// (may_raise() for _ in [0]) + /// except Exception: + /// ... + /// ``` + fn exception_checkpoint_crosses_scope_boundary(&self, scope_id: FileScopeId) -> bool { + self.scopes[scope_id].is_eager() + } + + /// Records the current flow state immediately before an operation that may raise an exception. + /// + /// This models exceptions from ordinary operations, not every possible interruption. In + /// particular, we do not add arbitrary exception points for asynchronously raised exceptions + /// such as those originating in signal handlers. + /// + /// Child expressions must already have been visited, so their completed assignments are + /// visible if the parent operation fails: + /// + /// ```python + /// state = 0 + /// try: + /// may_raise(state := 1) + /// except Exception: + /// reveal_type(state) # Literal[1] + /// ``` + /// + /// Skips snapshot construction when no enclosing `try` or `with` context can handle exceptions. + fn record_exception_checkpoint(&mut self) { + if !self + .exception_context_stack_manager + .has_active_exception_handler(self) + { + return; + } + + let mut exception_context_stack_manager = + std::mem::take(&mut self.exception_context_stack_manager); + exception_context_stack_manager.record_exception_checkpoint(self); + self.exception_context_stack_manager = exception_context_stack_manager; + } + + fn record_exception_checkpoint_if(&mut self, can_raise: bool) { + if can_raise { + self.record_exception_checkpoint(); + } + } + + /// Returns whether accessing a name, attribute, or subscript can raise. + /// + /// Only a definitely bound name in the current flow state is known to be safe. In particular, + /// a builtin-looking name may be shadowed by a local binding that has not been visited yet. + fn place_access_can_raise(&mut self, expr: &ast::Expr, is_use: bool) -> bool { + let ast::Expr::Name(name) = expr else { + return true; + }; + + is_use + && self + .exception_context_stack_manager + .has_active_exception_handler(self) + && self + .current_place_table() + .symbol_id(name.id.as_str()) + .is_none_or(|symbol| { + self.current_use_def_map_mut() + .symbol_live_binding_status(symbol) + != LiveBindingStatus::Bound + }) + } + + /// Returns whether evaluating and truth-testing `expr` cannot invoke Python user code. + /// + /// Identity comparisons are safe, but testing an arbitrary value may call `__bool__`: + /// + /// ```python + /// if value is None: ... # safe + /// if value: ... # can raise + /// ``` + fn condition_evaluation_is_known_safe(expr: &ast::Expr) -> bool { + if expr.is_literal_expr() || matches!(expr, ast::Expr::Lambda(_)) { + return true; + } + + match expr { + ast::Expr::Named(named) if named.target.is_name_expr() => { + Self::condition_evaluation_is_known_safe(&named.value) + } + ast::Expr::List(_) | ast::Expr::Tuple(_) => { + Self::expression_evaluation_is_known_safe(expr) + } + ast::Expr::BoolOp(ast::ExprBoolOp { values, .. }) => { + values.iter().all(Self::condition_evaluation_is_known_safe) + } + ast::Expr::UnaryOp(ast::ExprUnaryOp { + op: ast::UnaryOp::Not, + operand, + .. + }) => Self::condition_evaluation_is_known_safe(operand), + ast::Expr::Compare(ast::ExprCompare { + left, + ops, + comparators, + .. + }) => { + ops.iter() + .all(|op| matches!(op, ast::CmpOp::Is | ast::CmpOp::IsNot)) + && Self::expression_evaluation_is_known_safe(left) + && comparators + .iter() + .all(Self::expression_evaluation_is_known_safe) + } + _ => false, + } + } + + /// Returns whether evaluating `expr` cannot invoke Python user code. + /// + /// Unlike [`Self::condition_evaluation_is_known_safe`], this does not truth-test the resulting + /// value, so loading a name is safe even when that value's `__bool__` method could raise. + fn expression_evaluation_is_known_safe(expr: &ast::Expr) -> bool { + if expr.is_literal_expr() || matches!(expr, ast::Expr::Name(_) | ast::Expr::Lambda(_)) { + return true; + } + + match expr { + ast::Expr::Named(named) if named.target.is_name_expr() => { + Self::expression_evaluation_is_known_safe(&named.value) + } + ast::Expr::List(ast::ExprList { elts, .. }) + | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { + elts.iter().all(Self::expression_evaluation_is_known_safe) + } + ast::Expr::Compare(_) => Self::condition_evaluation_is_known_safe(expr), + _ => false, + } + } + + /// Returns whether iterating `expr` uses an exact builtin iterator that cannot raise anything + /// other than `StopIteration` (ignoring ambient failures such as `MemoryError`). + /// + /// ```python + /// for value in [1, 2]: ... # safe + /// for value in values: ... # can invoke user-defined iteration + /// ``` + fn iteration_is_known_safe(expr: &ast::Expr) -> bool { + matches!( + expr, + ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::List(_) + | ast::Expr::Tuple(_) + ) && Self::expression_evaluation_is_known_safe(expr) } /// Records a reachability constraint that always evaluates to "ambiguous". @@ -2964,6 +3288,24 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } } + /// Returns whether matching a pattern can invoke Python user code. + fn pattern_can_raise(pattern: &ast::Pattern) -> bool { + match pattern { + ast::Pattern::MatchValue(_) + | ast::Pattern::MatchSequence(_) + | ast::Pattern::MatchMapping(_) + | ast::Pattern::MatchClass(_) => true, + ast::Pattern::MatchSingleton(_) | ast::Pattern::MatchStar(_) => false, + ast::Pattern::MatchAs(pattern) => pattern + .pattern + .as_deref() + .is_some_and(Self::pattern_can_raise), + ast::Pattern::MatchOr(pattern) => pattern.patterns.iter().any(Self::pattern_can_raise), + // basedpython `case P and Q:` matches every sub-pattern against the same subject + ast::Pattern::MatchAnd(pattern) => pattern.patterns.iter().any(Self::pattern_can_raise), + } + } + /// The pattern structure type checking needs, and the bare `case A:` names /// [context-sensitive resolution](CaseNamePredicateKind) is offered. /// @@ -3202,7 +3544,13 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { ) -> (FlowSnapshot, PredicateOrLiteral<'db>, ScopedPredicateId) { let pattern = pattern.map(|pattern| (pattern, self.add_standalone_expression(test))); - self.visit_expr(test); + if pattern.is_some() { + // basedpython `if let P := subject`: the pattern is matched against the + // subject's result object, so the subject is evaluated for its value + self.visit_expr(test); + } else { + self.visit_expr_with_context(test, ExpressionContext::Condition); + } let pattern = pattern.map(|(pattern, subject)| (pattern, subject, self.match_subject_targets(test))); @@ -3674,6 +4022,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // nodes are evaluated in the inner scope. let value = self.add_standalone_expression(&generator.iter); self.visit_expr(&generator.iter); + let first_iteration_can_raise = + generator.is_async || !Self::iteration_is_known_safe(&generator.iter); + self.record_exception_checkpoint_if(first_iteration_can_raise); + let mut loopback_can_raise = first_iteration_can_raise || !generator.target.is_name_expr(); // Clear the assignment stack before entering the comprehension scope. // If the comprehension appears inside an assignment target (e.g., error-recovered @@ -3705,6 +4057,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { for generator in generators_iter { let value = self.add_standalone_expression(&generator.iter); self.visit_expr(&generator.iter); + let iteration_can_raise = + generator.is_async || !Self::iteration_is_known_safe(&generator.iter); + self.record_exception_checkpoint_if(iteration_can_raise); + loopback_can_raise |= iteration_can_raise || !generator.target.is_name_expr(); self.add_unpackable_assignment( &Unpackable::Comprehension { @@ -3724,8 +4080,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { for filtered_out_path in filtered_out_paths { self.flow_merge(filtered_out_path); } + self.record_exception_checkpoint_if(loopback_can_raise); let nested_bindings = self.pop_scope(); self.synthesize_comprehension_binding_definitions(nested_bindings); + self.record_exception_checkpoint(); self.current_assignments = saved_assignments; @@ -3742,7 +4100,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { /// print(last) /// ``` fn visit_comprehension_filter(&mut self, if_expr: &'ast ast::Expr) -> FlowSnapshot { - self.visit_expr(if_expr); + self.visit_expr_with_context(if_expr, ExpressionContext::Condition); let condition_flow_snapshot = self.flow_snapshot_for_condition(if_expr); let filtered_out = if let Some(snapshots) = condition_flow_snapshot.into_branches() { self.flow_restore(snapshots.truthy); @@ -3752,7 +4110,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { }; let (predicate, narrowing_id) = self.record_expression_narrowing_constraint(if_expr); - let reachability_constraint = self.record_reachability_constraint(predicate); + let reachability_constraint = self.record_reachability_constraint_id(narrowing_id); let included_path = self.flow_snapshot(); self.flow_restore(filtered_out); @@ -3890,6 +4248,11 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { target: &'ast ast::Expr, value: Expression<'db>, ) { + self.record_exception_checkpoint_if(matches!( + target, + ast::Expr::List(_) | ast::Expr::Tuple(_) + )); + let current_assignment = match target { ast::Expr::List(_) | ast::Expr::Tuple(_) => { if matches!(unpackable, Unpackable::Comprehension { .. }) { @@ -4088,6 +4451,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .collect(), ); + let mut use_def_map_interner = UseDefMapInterner::default(); + SemanticIndex { place_tables: self .place_tables @@ -4106,7 +4471,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { use_def_maps: self .use_def_maps .into_iter() - .map(|builder| Arc::new(builder.finish())) + .map(|builder| use_def_map_interner.intern(builder.finish())) .collect(), enclosing_lambda_statements: FrozenMap::from(self.enclosing_lambda_statements), fluid_candidates_by_use: FrozenMap::from(self.fluid_candidates_by_use), @@ -4147,228 +4512,669 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .get_or_init(|| source_text(self.db, self.file.file(self.db))) } - fn visit_stmt_impl(&mut self, stmt: &'ast ast::Stmt) { - self.with_semantic_checker(|semantic, context| semantic.visit_stmt(stmt, context)); - - let in_type_checking_block = self.in_type_checking_block; - self.current_use_def_map_mut() - .record_range_reachability(stmt.range(), in_type_checking_block); + fn visit_expr_with_context(&mut self, expr: &'ast ast::Expr, context: ExpressionContext) { + self.with_semantic_checker(|semantic, builder| semantic.visit_expr(expr, builder)); - match stmt { - ast::Stmt::FunctionDef(function_def) => { - let ast::StmtFunctionDef { - decorator_list, - parameters, - type_params, - name, - returns, - raises, - body, - is_async: _, - is_trailing_lambda, - is_asserts_return: _, - range: _, - node_index: _, - } = function_def; - for decorator in decorator_list { - self.visit_decorator(decorator); - } + self.scopes_by_expression + .record_expression(expr, self.current_scope()); - // basedpython: a trailing lambda's synthetic decorator holds the - // called expression. its callee is a standalone expression so - // the lambda's implicit `it` parameter can read the callee's - // type without depending on the enclosing definition inference - if let Some(callee) = function_def.trailing_lambda_callee() { - self.add_standalone_expression(callee); - } + match expr { + ast::Expr::Name(ast::ExprName { ctx, .. }) + | ast::Expr::Attribute(ast::ExprAttribute { ctx, .. }) + | ast::Expr::Subscript(ast::ExprSubscript { ctx, .. }) => { + // Record place effects after walking the expression. For names, this is + // equivalent because `walk_expr` is a no-op; for attribute/subscript places, + // child evaluation can introduce bindings (for example via walrus operators), + // and those bindings need to exist before we register parent/member associations. + let mut deferred_effects = None; + if let Some(mut place_expr) = PlaceExpr::try_from_expr(expr) { + if let Some(method_scope_id) = self.is_method_or_eagerly_executed_in_method() + && let PlaceExpr::Member(member) = &mut place_expr + && member.is_instance_attribute_candidate() + && let Some(attribute) = expr.as_attribute_expr() + { + // We specifically mark direct attribute assignments to the first + // parameter of a method, i.e. typically `self` or `cls`. + // However, we must check that the symbol hasn't been shadowed by an + // intermediate scope (e.g., a comprehension variable: `for self in [...]`) + // and that the AST base is still the original name rather than a + // rebinding expression such as `(self := other).x`. + let accessed_object_refers_to_first_parameter = + self.current_first_parameter_name.is_some_and(|first| { + attribute + .value + .as_name_expr() + .is_some_and(|name| name.id == first) + && !self.is_symbol_bound_in_intermediate_eager_scopes( + first, + method_scope_id, + ) + }); - // Evaluate default args before we visit the body. If the default expression ends - // up looking at locally bound variables, `nonlocal` or `global` assignments in the - // body shouldn't affect their inferred values. For example: - // ``` - // x = 1 - // def f(y=reveal_type(x)): # Literal[1] - // global x - // x = 2 - // reveal_type(x) # Literal[1, 2] - // ``` - for default in parameters - .iter_non_variadic_params() - .filter_map(|param| param.default.as_deref()) - { - self.visit_expr(default); - } - - let (nested_bindings, block_scope) = self.with_type_params( - NodeWithScopeRef::FunctionTypeParameters(function_def), - type_params.as_deref(), - |builder| { - builder.visit_parameters(parameters); - if let Some(returns) = returns { - builder.visit_annotation(returns); - } - // basedpython: the `raises` clause is a type expression, and - // sits in the same scope as the return annotation - if let Some(raises) = raises { - builder.visit_annotation(raises); + if accessed_object_refers_to_first_parameter { + member.mark_instance_attribute(); } + } - builder.push_scope(NodeWithScopeRef::Function(function_def)); - let block_scope = builder.current_scope(); + let (is_use, is_definition) = match (ctx, self.current_assignment()) { + (ast::ExprContext::Store, Some(CurrentAssignment::AugAssign(_))) => { + // Record the target load now; the definition is recorded separately + // after visiting the right-hand side. + (true, false) + } + (ast::ExprContext::Load, _) => (true, false), + (ast::ExprContext::Store, _) => (false, true), + (ast::ExprContext::Del, _) => (true, true), + (ast::ExprContext::Invalid, _) => (false, false), + }; + deferred_effects = Some((place_expr, is_use, is_definition)); + } - builder.declare_parameters(parameters); + walk_expr(self, expr); - let mut first_parameter_name = parameters - .iter_non_variadic_params() - .next() - .map(|first_param| first_param.parameter.name.id().as_str()); - std::mem::swap( - &mut builder.current_first_parameter_name, - &mut first_parameter_name, - ); + let is_use = deferred_effects + .as_ref() + .is_some_and(|(_, is_use, _)| *is_use); + let can_raise = self.place_access_can_raise(expr, is_use); + self.record_exception_checkpoint_if(can_raise); - builder.visit_body(body); + if let Some((place_expr, is_use, is_definition)) = deferred_effects { + let place_id = self.add_place(place_expr); - builder.current_first_parameter_name = first_parameter_name; - (builder.pop_scope(), block_scope) - }, - ); + if is_use { + self.record_place_use(place_id, expr); - // The nested bindings returned by `pop_scope` are exactly the ones that are - // potentially visible at this point. That is, they include `global` and `nonlocal` - // declarations in the popped functions body and any nested bodies, but they omit - // the ones that resolved to the popped body. Synthesize a definition to record - // them. This definition type has special shadowing behavior, so it doesn't shadow - // prior bindings, and it remains visible after subsequent bindings. This - // represents the fact that the nested function could be called at any time. - // - // NOTE: This is deliberately somewhat unsound. For example, bindings from parent - // functions and sibling functions can also be visible at any point, depending on - // when different functions get invoked. However, we really want examples like this - // to do what users expect, so we accept the unsoundness here: - // - // def f(): - // x = 1 - // def g(): - // nonlocal x - // x = 2 - // def h(): - // nonlocal x - // x = 3 - // # Technically `g` could get called at any time, including right - // # here. But inferring `Literal[2, 3]` here would be confusing. - // reveal_type(x) # revealed: Literal[3] - // x = 4 - // # On the other hand, users probably want to see 2 and 3 here, because - // # they're nested within this scope? Hopefully it's not too confusing. - // reveal_type(x) # revealed: Literal[2, 3, 4] - // - // In other cases it can also be unsound that we only consider nested bindings to - // be visible after their function definition, when in practice they could be - // visible "before" (because nested functions can escape their lexical scope and - // get called more than once). For more discussion of all these behaviors, see the - // mdtest case "Visibility of `nonlocal` bindings from nested and sibling scopes" - // and its `global` counterpart. - self.synthesize_nested_binding_definitions(nested_bindings); + // Keep track of any uses of fluid specialization candidates. + if let Some(candidate_def) = self.fluid_candidate_binding(expr) { + let loops: Box<[TextRange]> = self.loop_ranges.as_slice().into(); + if let Some(current_statement) = self.current_statements.last_mut() { + current_statement.fluid_uses.push(( + candidate_def, + expr.into(), + expr.range(), + loops, + )); + } + } + } - // basedpython: a trailing-lambda block (`f:` + suite) runs inline at - // its call site, so an assignment to an enclosing name writes through - // to that binding (the lowering inserts the matching `global` / - // `nonlocal`), reflected in `reveal_type` after the block. a `once` - // block runs exactly once, so an unconditional write shadows; a - // non-`once` block may run any number of times, so it unions. - if *is_trailing_lambda { - let is_once = function_def - .trailing_lambda_callee() - .is_some_and(|callee| self.trailing_lambda_callee_is_once(callee)); - self.synthesize_trailing_lambda_writebacks(block_scope, body, is_once); + if is_definition { + self.record_place_definition(place_id, expr); + } - // a `once` block runs exactly once; if it always returns, the - // enclosing function returns through it (the lowering - // propagates the return), so code after the block is - // unreachable — just like a `return` here - if is_once && Self::always_returns(body) { - self.record_terminal_finally_entry(); - self.mark_unreachable(); + if let Some(unpack_position) = self + .current_assignment_mut() + .and_then(CurrentAssignment::unpack_position_mut) + { + *unpack_position = UnpackPosition::Other; } } - - // The symbol for the function name itself has to be evaluated - // at the end to match the runtime evaluation of parameter defaults - // and return-type annotations. - let symbol = self.add_symbol(name.id.clone()); - - // Record a use of the function name in the scope that it is defined in, so that it - // can be used to find previously defined functions with the same name. This is - // used to collect all the overloaded definitions of a function. This needs to be - // done on the `Identifier` node as opposed to `ExprName` because that's what the - // AST uses. - let use_id = self.current_ast_ids_mut().record_use(name); - self.current_use_def_map_mut() - .record_use(symbol.into(), use_id); - - self.add_definition(symbol.into(), function_def); - self.mark_symbol_used(symbol); } - ast::Stmt::ClassDef(class) => { - for decorator in &class.decorator_list { - self.visit_decorator(decorator); + ast::Expr::Named(node) => { + // basedpython: anonymous named tuples and Parameters specs use + // `Expr::Named` to represent `name: type` field labels. these + // aren't walrus assignments — the inner Name has + // `ExprContext::Invalid` to suppress place-effects — so we + // skip the assignment scope entirely. without this, ty's + // scope inference would call `expect_single_definition` on + // the named expr and panic + if matches!(node.target.as_ref(), ast::Expr::Name(n) if matches!(n.ctx, ast::ExprContext::Invalid)) + { + self.visit_expr(&node.value); + return; } + self.visit_expr(&node.value); - let nested_bindings = self.with_type_params( - NodeWithScopeRef::ClassTypeParameters(class), - class.type_params.as_deref(), - |builder| { - if let Some(arguments) = &class.arguments { - builder.visit_arguments(arguments); - } - - builder.push_scope(NodeWithScopeRef::Class(class)); - builder.visit_body(&class.body); + // See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements + if node.target.is_name_expr() { + self.push_assignment(CurrentAssignment::Named(node)); + self.visit_expr(&node.target); + self.pop_assignment(); + } else { + self.visit_expr(&node.target); + } + } + ast::Expr::Lambda(lambda) => { + self.current_statement_mut() + .expect("every lambda expression is part of a statement") + .lambda_expressions + .push(lambda); - builder.pop_scope() - }, - ); + if let Some(parameters) = &lambda.parameters { + // The default value of the parameters needs to be evaluated in the + // enclosing scope. + for default in parameters + .iter_non_variadic_params() + .filter_map(|param| param.default.as_deref()) + { + self.visit_expr(default); + } + self.visit_parameters(parameters); + } + // return type annotation evaluated in enclosing scope, matching function defs + if let Some(returns) = &lambda.returns { + self.visit_annotation(returns); + } + self.push_scope(NodeWithScopeRef::Lambda(lambda)); - // We currently treat nested `global` and `nonlocal` bindings from class bodies the - // same way as ones from function bodies above. That's correct in the common case - // where they actually come from a function within the class. But when they appear - // directly within a class body, this isn't quite correct, because these synthetic - // definitions behave lazily, while class bodies are actually evaluated eagerly. - self.synthesize_nested_binding_definitions(nested_bindings); + // Add symbols and definitions for the parameters to the lambda scope. + if let Some(parameters) = lambda.parameters.as_ref() { + self.declare_lambda_parameters(parameters, lambda); + } - // In Python runtime semantics, a class is registered after its scope is evaluated. - // an `extension list:` block references the extended type rather than - // declaring it, so it binds a mangled, per-statement symbol — invisible - // to name resolution (`<` cannot appear in an identifier) but still - // enumerable, so `extensions_in_module` can find every extension - let symbol_name = if class.is_extension() { - Name::new(format!( - "", - class.name.id, - class.range.start().to_u32() - )) - } else { - class.name.id.clone() - }; - let symbol = self.add_symbol(symbol_name); - self.add_definition(symbol.into(), class); + self.visit_expr(lambda.body.as_ref()); + self.pop_scope(); } - ast::Stmt::TypeAlias(type_alias) => { - let symbol = self.add_symbol( - type_alias - .name - .as_name_expr() - .map(|name| name.id.clone()) - .unwrap_or("".into()), - ); - self.add_definition(symbol.into(), type_alias); - self.visit_expr(&type_alias.name); - - self.with_type_params( - NodeWithScopeRef::TypeAliasTypeParameters(type_alias), - type_alias.type_params.as_deref(), - |builder| { + ast::Expr::If(node) => self.visit_if_expression(node, context), + ast::Expr::ListComp( + list_comprehension @ ast::ExprListComp { + elt, generators, .. + }, + ) => { + let scope = self.with_generators_scope( + NodeWithScopeRef::ListComprehension(list_comprehension), + generators, + |builder| builder.visit_expr(elt), + ); + if self.async_comprehensions.contains(&scope) { + self.mark_current_comprehension_async(); + } + } + ast::Expr::SetComp( + set_comprehension @ ast::ExprSetComp { + elt, generators, .. + }, + ) => { + let scope = self.with_generators_scope( + NodeWithScopeRef::SetComprehension(set_comprehension), + generators, + |builder| builder.visit_expr(elt), + ); + if self.async_comprehensions.contains(&scope) { + self.mark_current_comprehension_async(); + } + } + ast::Expr::Generator( + generator @ ast::ExprGenerator { + elt, generators, .. + }, + ) => { + self.with_generators_scope( + NodeWithScopeRef::GeneratorExpression(generator), + generators, + |builder| builder.visit_expr(elt), + ); + } + ast::Expr::DictComp( + dict_comprehension @ ast::ExprDictComp { + key, + value, + generators, + .. + }, + ) => { + let scope = self.with_generators_scope( + NodeWithScopeRef::DictComprehension(dict_comprehension), + generators, + |builder| { + if let Some(key) = key { + builder.visit_expr(key); + } + builder.visit_expr(value); + }, + ); + if self.async_comprehensions.contains(&scope) { + self.mark_current_comprehension_async(); + } + } + // basedpython: `a ?? b` evaluates `b` only when `a` is `None`, so `b` + // is a branch — a binding it makes is only possibly bound afterwards, + // and a `raise` or `return` in it does not end the enclosing flow + ast::Expr::BinOp(ast::ExprBinOp { + left, + op: ast::Operator::Coalesce, + right, + .. + }) => self.visit_coalesce_expression(left, right), + ast::Expr::Call(_) | ast::Expr::BinOp(_) => { + walk_expr(self, expr); + self.record_exception_checkpoint(); + } + ast::Expr::UnaryOp(unary) => { + self.visit_expr_with_context( + &unary.operand, + if unary.op == ast::UnaryOp::Not { + context + } else { + ExpressionContext::Value + }, + ); + self.record_exception_checkpoint_if( + unary.op != ast::UnaryOp::Not + || !Self::condition_evaluation_is_known_safe(&unary.operand), + ); + } + ast::Expr::Compare(ast::ExprCompare { + left, + ops, + comparators, + .. + }) => { + self.visit_expr(left); + for (op, comparator) in ops.iter().zip(comparators) { + self.visit_expr(comparator); + self.record_exception_checkpoint_if(!matches!( + op, + ast::CmpOp::Is | ast::CmpOp::IsNot + )); + } + } + ast::Expr::BoolOp(node) => self.visit_bool_expression(node, context), + ast::Expr::StringLiteral(_) => { + walk_expr(self, expr); + } + ast::Expr::Yield(_) | ast::Expr::YieldFrom(_) => { + let scope = self.current_scope(); + if self.scopes[scope].kind() == ScopeKind::Function { + self.generator_functions.insert(scope); + } + walk_expr(self, expr); + self.record_exception_checkpoint(); + } + ast::Expr::Await(_) => { + self.mark_current_comprehension_async(); + walk_expr(self, expr); + self.record_exception_checkpoint(); + } + // basedpython: a statement expression's wrapped statement is visited + // as an ordinary statement, so everything it binds and narrows is + // recorded in the enclosing scope. Its *value* is modelled as a + // synthetic place written at each of the statement's value positions + // and read at the expression itself, which gives exhaustiveness and + // the union of branch types from the existing flow analysis. + ast::Expr::Statement(statement) => self.visit_statement_expression(expr, statement), + _ => { + walk_expr(self, expr); + } + } + + // basedpython: this expression may produce the value of the statement + // expression currently being visited + if let Some(current) = self.current_statement_expressions.last() + && current.values.contains(&ExpressionNodeKey::from(expr)) + { + self.record_statement_expression_value(expr, current.place); + } + } + + /// Visits a conditional expression without reserving its flow snapshots in every recursive + /// expression-visitor frame. This matters for deeply nested expressions in unoptimized builds. + fn visit_if_expression(&mut self, node: &'ast ast::ExprIf, context: ExpressionContext) { + let ast::ExprIf { + body, test, orelse, .. + } = node; + self.visit_expr_with_context(test, ExpressionContext::Condition); + let condition_flow_snapshot = self.flow_snapshot_for_condition(test); + let falsy = if let Some(snapshots) = condition_flow_snapshot.into_branches() { + self.flow_restore(snapshots.truthy); + snapshots.falsy + } else { + self.flow_snapshot() + }; + let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); + let reachability_constraint = self.record_reachability_constraint_id(predicate_id); + let in_type_checking_block = self.in_type_checking_block; + self.current_use_def_map_mut() + .record_range_reachability(body.range(), in_type_checking_block); + self.visit_expr_with_context(body, context); + let post_body = self.flow_snapshot(); + self.flow_restore(falsy); + + self.record_negated_narrowing_constraint(predicate, predicate_id); + self.record_negated_reachability_constraint(reachability_constraint); + let in_type_checking_block = self.in_type_checking_block; + self.current_use_def_map_mut() + .record_range_reachability(orelse.range(), in_type_checking_block); + self.visit_expr_with_context(orelse, context); + self.flow_merge(post_body); + } + + /// Keeps short-circuit flow snapshots out of the common recursive expression-visitor frame. + fn visit_bool_expression(&mut self, node: &'ast ast::ExprBoolOp, context: ExpressionContext) { + let ast::ExprBoolOp { values, op, .. } = node; + let mut snapshots = vec![]; + let mut reachability_constraints = vec![]; + let mut last_condition_flow_snapshots = None; + + for (index, value) in values.iter().enumerate() { + for id in &reachability_constraints { + self.current_use_def_map_mut() + .record_reachability_constraint(*id); // TODO: nicer API + } + + let in_type_checking_block = self.in_type_checking_block; + self.current_use_def_map_mut() + .record_range_reachability(value.range(), in_type_checking_block); + self.visit_expr_with_context(value, context); + + // Only non-final values can short-circuit this boolean operation. The final + // value can still have its own outcome-specific flow if it is nested. + if index < values.len() - 1 { + self.record_exception_checkpoint_if(!Self::condition_evaluation_is_known_safe( + value, + )); + let condition_flow_snapshots = self.take_condition_flow_snapshots(value); + let predicate = self.build_predicate(value, context); + let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); + let predicate_id = match op { + ast::BoolOp::And => self.add_predicate(predicate), + ast::BoolOp::Or => self.add_negated_predicate(predicate), + }; + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); + + let continuation = if let Some(condition_flow_snapshots) = condition_flow_snapshots + { + let (short_circuit, continuation) = + condition_flow_snapshots.into_short_circuit_and_continuation(*op); + self.flow_restore(short_circuit); + continuation + } else { + self.flow_snapshot() + }; + + // We first model the short-circuiting behavior. We take the short-circuit + // path here if all of the previous short-circuit paths were not taken, so + // we record all previously existing reachability constraints, and negate the + // one for the current expression. + + self.record_negated_reachability_constraint(reachability_constraint); + snapshots.push(self.flow_snapshot()); + + // Then we model the non-short-circuiting behavior. Here, we need to delay + // the application of the reachability constraint until after the expression + // has been evaluated, so we only push it onto the stack here. + self.flow_restore(continuation); + self.record_narrowing_constraint_id_for_places(predicate_id, &possibly_narrowed); + reachability_constraints.push(reachability_constraint); + } else { + last_condition_flow_snapshots = self.take_condition_flow_snapshots(value); + } + } + + let has_specialized_last = last_condition_flow_snapshots.is_some(); + let (last_short_circuit, no_short_circuit) = + if let Some(condition_flow_snapshots) = last_condition_flow_snapshots { + let (short_circuit, no_short_circuit) = + condition_flow_snapshots.into_short_circuit_and_continuation(*op); + (Some(short_circuit), Some(no_short_circuit)) + } else { + ( + None, + values + .iter() + .any(|value| any_over_expr(value, &ast::Expr::is_named_expr)) + .then(|| self.flow_snapshot()), + ) + }; + + if let Some(last_short_circuit) = last_short_circuit { + self.flow_restore(last_short_circuit); + } + + for snapshot in snapshots { + self.flow_merge(snapshot); + } + + if let Some(no_short_circuit) = no_short_circuit { + let bool_op_key = ExpressionNodeKey::from(ast::ExprRef::BoolOp(node)); + let maybe_short_circuit = self.flow_snapshot(); + + if has_specialized_last { + // Restore the merged post-expression flow after constructing the two + // outcome-specific snapshots. + self.flow_merge(no_short_circuit.clone()); + } + + let (truthy, falsy) = match op { + ast::BoolOp::And => (no_short_circuit, maybe_short_circuit), + ast::BoolOp::Or => (maybe_short_circuit, no_short_circuit), + }; + + self.condition_flow_snapshots_by_node + .insert(bool_op_key, ConditionFlowSnapshots { truthy, falsy }); + } + } + + fn visit_stmt_impl(&mut self, stmt: &'ast ast::Stmt) { + self.with_semantic_checker(|semantic, context| semantic.visit_stmt(stmt, context)); + + let in_type_checking_block = self.in_type_checking_block; + self.current_use_def_map_mut() + .record_range_reachability(stmt.range(), in_type_checking_block); + + match stmt { + ast::Stmt::FunctionDef(function_def) => { + let ast::StmtFunctionDef { + decorator_list, + parameters, + type_params, + name, + returns, + raises, + body, + is_async: _, + is_trailing_lambda, + is_asserts_return: _, + range: _, + node_index: _, + } = function_def; + for decorator in decorator_list { + self.visit_decorator(decorator); + } + + // basedpython: a trailing lambda's synthetic decorator holds the + // called expression. its callee is a standalone expression so + // the lambda's implicit `it` parameter can read the callee's + // type without depending on the enclosing definition inference + if let Some(callee) = function_def.trailing_lambda_callee() { + self.add_standalone_expression(callee); + } + + // Evaluate default args before we visit the body. If the default expression ends + // up looking at locally bound variables, `nonlocal` or `global` assignments in the + // body shouldn't affect their inferred values. For example: + // ``` + // x = 1 + // def f(y=reveal_type(x)): # Literal[1] + // global x + // x = 2 + // reveal_type(x) # Literal[1, 2] + // ``` + for default in parameters + .iter_non_variadic_params() + .filter_map(|param| param.default.as_deref()) + { + self.visit_expr(default); + } + + let (nested_bindings, block_scope) = self.with_type_params( + NodeWithScopeRef::FunctionTypeParameters(function_def), + type_params.as_deref(), + |builder| { + builder.visit_parameters(parameters); + if let Some(returns) = returns { + builder.visit_annotation(returns); + } + // basedpython: the `raises` clause is a type expression, and + // sits in the same scope as the return annotation + if let Some(raises) = raises { + builder.visit_annotation(raises); + } + + builder.push_scope(NodeWithScopeRef::Function(function_def)); + let block_scope = builder.current_scope(); + + builder.declare_parameters(parameters); + + let mut first_parameter_name = parameters + .iter_non_variadic_params() + .next() + .map(|first_param| first_param.parameter.name.id().as_str()); + std::mem::swap( + &mut builder.current_first_parameter_name, + &mut first_parameter_name, + ); + + builder.visit_body(body); + + builder.current_first_parameter_name = first_parameter_name; + (builder.pop_scope(), block_scope) + }, + ); + + // The nested bindings returned by `pop_scope` are exactly the ones that are + // potentially visible at this point. That is, they include `global` and `nonlocal` + // declarations in the popped functions body and any nested bodies, but they omit + // the ones that resolved to the popped body. Synthesize a definition to record + // them. This definition type has special shadowing behavior, so it doesn't shadow + // prior bindings, and it remains visible after subsequent bindings. This + // represents the fact that the nested function could be called at any time. + // + // NOTE: This is deliberately somewhat unsound. For example, bindings from parent + // functions and sibling functions can also be visible at any point, depending on + // when different functions get invoked. However, we really want examples like this + // to do what users expect, so we accept the unsoundness here: + // + // def f(): + // x = 1 + // def g(): + // nonlocal x + // x = 2 + // def h(): + // nonlocal x + // x = 3 + // # Technically `g` could get called at any time, including right + // # here. But inferring `Literal[2, 3]` here would be confusing. + // reveal_type(x) # revealed: Literal[3] + // x = 4 + // # On the other hand, users probably want to see 2 and 3 here, because + // # they're nested within this scope? Hopefully it's not too confusing. + // reveal_type(x) # revealed: Literal[2, 3, 4] + // + // In other cases it can also be unsound that we only consider nested bindings to + // be visible after their function definition, when in practice they could be + // visible "before" (because nested functions can escape their lexical scope and + // get called more than once). For more discussion of all these behaviors, see the + // mdtest case "Visibility of `nonlocal` bindings from nested and sibling scopes" + // and its `global` counterpart. + self.synthesize_nested_binding_definitions(nested_bindings); + + // basedpython: a trailing-lambda block (`f:` + suite) runs inline at + // its call site, so an assignment to an enclosing name writes through + // to that binding (the lowering inserts the matching `global` / + // `nonlocal`), reflected in `reveal_type` after the block. a `once` + // block runs exactly once, so an unconditional write shadows; a + // non-`once` block may run any number of times, so it unions. + if *is_trailing_lambda { + let is_once = function_def + .trailing_lambda_callee() + .is_some_and(|callee| self.trailing_lambda_callee_is_once(callee)); + self.synthesize_trailing_lambda_writebacks(block_scope, body, is_once); + + // a `once` block runs exactly once; if it always returns, the + // enclosing function returns through it (the lowering + // propagates the return), so code after the block is + // unreachable — just like a `return` here + if is_once && Self::always_returns(body) { + self.record_terminal_finally_entry(); + self.mark_unreachable(); + } + } + + // Decorator application can raise after defaults and annotations are evaluated. + self.record_exception_checkpoint_if(!decorator_list.is_empty()); + + // The symbol for the function name itself has to be evaluated + // at the end to match the runtime evaluation of parameter defaults + // and return-type annotations. + let symbol = self.add_symbol(name.id.clone()); + + // Record a use of the function name in the scope that it is defined in, so that it + // can be used to find previously defined functions with the same name. This is + // used to collect all the overloaded definitions of a function. This needs to be + // done on the `Identifier` node as opposed to `ExprName` because that's what the + // AST uses. + let use_id = self.current_ast_ids_mut().record_use(name); + self.current_use_def_map_mut() + .record_use(symbol.into(), use_id); + + self.add_definition(symbol.into(), function_def); + self.mark_symbol_used(symbol); + } + ast::Stmt::ClassDef(class) => { + for decorator in &class.decorator_list { + self.visit_decorator(decorator); + } + + let nested_bindings = self.with_type_params( + NodeWithScopeRef::ClassTypeParameters(class), + class.type_params.as_deref(), + |builder| { + if let Some(arguments) = &class.arguments { + builder.visit_arguments(arguments); + } + + builder.push_scope(NodeWithScopeRef::Class(class)); + builder.visit_body(&class.body); + + builder.pop_scope() + }, + ); + + // We currently treat nested `global` and `nonlocal` bindings from class bodies the + // same way as ones from function bodies above. That's correct in the common case + // where they actually come from a function within the class. But when they appear + // directly within a class body, this isn't quite correct, because these synthetic + // definitions behave lazily, while class bodies are actually evaluated eagerly. + self.synthesize_nested_binding_definitions(nested_bindings); + + // Class construction and decorator application can raise after the body executes. + self.record_exception_checkpoint(); + + // In Python runtime semantics, a class is registered after its scope is evaluated. + // an `extension list:` block references the extended type rather than + // declaring it, so it binds a mangled, per-statement symbol — invisible + // to name resolution (`<` cannot appear in an identifier) but still + // enumerable, so `extensions_in_module` can find every extension + let symbol_name = if class.is_extension() { + Name::new(format!( + "", + class.name.id, + class.range.start().to_u32() + )) + } else { + class.name.id.clone() + }; + let symbol = self.add_symbol(symbol_name); + self.add_definition(symbol.into(), class); + } + ast::Stmt::TypeAlias(type_alias) => { + let symbol = self.add_symbol( + type_alias + .name + .as_name_expr() + .map(|name| name.id.clone()) + .unwrap_or("".into()), + ); + self.add_definition(symbol.into(), type_alias); + self.visit_expr(&type_alias.name); + + self.with_type_params( + NodeWithScopeRef::TypeAliasTypeParameters(type_alias), + type_alias.type_params.as_deref(), + |builder| { builder.push_scope(NodeWithScopeRef::TypeAlias(type_alias)); builder.visit_expr(&type_alias.value); builder.visit_type_match_cases(&type_alias.cases); @@ -4378,6 +5184,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } ast::Stmt::Import(node) => { for (alias_index, alias) in node.names.iter().enumerate() { + self.record_exception_checkpoint(); + // Mark the imported module, and all of its parents, as being imported in this // file. // @@ -4411,6 +5219,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } } ast::Stmt::ImportFrom(node) => { + self.record_exception_checkpoint(); + // If we see: // // * `from .x.y import z` (or `from whatever.thispackage.x.y`) @@ -4486,6 +5296,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let mut found_star = false; for (alias_index, alias) in node.names.iter().enumerate() { + // Loading each imported name can fail after the module import and any earlier + // names or package-submodule side effects have completed. + self.record_exception_checkpoint(); + if &alias.name == "*" { // The following line maintains the invariant that every AST node that // implements `Into` must have an entry in the @@ -4513,7 +5327,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { continue; } - let Ok(module_name) = ModuleName::from_import_statement( + let Some(module) = resolve_module_for_import_from( self.db, ImportingFile::File(source_file, resolver_environment), node, @@ -4521,14 +5335,6 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { continue; }; - let Some(module) = resolve_module( - self.db, - ImportingFile::File(source_file, resolver_environment), - &module_name, - ) else { - continue; - }; - let Some(referenced_file) = module.file(self.db) else { continue; }; @@ -4659,11 +5465,15 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // `msg` branch back into the following flow, since there is no way of getting out // of that branch. Code after the assertion starts from the condition's truthy flow. - self.visit_expr(test); + self.visit_expr_with_context(test, ExpressionContext::Condition); let condition_flow_snapshot = self.flow_snapshot_for_condition(test); - let predicate = self.build_predicate(test); + let predicate = self.build_predicate(test, ExpressionContext::Condition); - if let Some(msg) = msg { + if msg.is_some() + || self + .exception_context_stack_manager + .has_active_exception_handler(self) + { let truthy = if let Some(snapshots) = condition_flow_snapshot.into_branches() { self.flow_restore(snapshots.falsy); snapshots.truthy @@ -4671,18 +5481,19 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { self.flow_snapshot() }; let negated_predicate = predicate.negated(); - self.record_narrowing_constraint(negated_predicate); - self.record_reachability_constraint(negated_predicate); - self.visit_expr(msg); - self.flow_restore(truthy); - } else { - if let Some(truthy) = condition_flow_snapshot.into_truthy() { - self.flow_restore(truthy); + let predicate_id = self.record_narrowing_constraint(negated_predicate); + self.record_reachability_constraint_id(predicate_id); + if let Some(msg) = msg { + self.visit_expr(msg); } + self.record_exception_checkpoint(); + self.flow_restore(truthy); + } else if let Some(truthy) = condition_flow_snapshot.into_truthy() { + self.flow_restore(truthy); } - self.record_narrowing_constraint(predicate); - self.record_reachability_constraint(predicate); + let predicate_id = self.record_narrowing_constraint(predicate); + self.record_reachability_constraint_id(predicate_id); } ast::Stmt::Assign(node) => { @@ -4717,7 +5528,11 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { if let [target] = &node.targets[..] && target.is_name_expr() { - self.push_assignment(CurrentAssignment::Assign { node, unpack: None }); + self.push_assignment(CurrentAssignment::Assign { + node, + unpack: None, + owner: BindingsOwner::Definition, + }); self.visit_expr(target); self.pop_assignment(); @@ -4737,6 +5552,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { for decorator in &node.decorator_list { self.visit_decorator(decorator); } + // For an assignment with a value, an exception from the annotation or RHS must + // not discard the declared type. The value is still bound only after the RHS + // completes, so a handler can observe an earlier binding (or an unbound name). + let pending = self.begin_annotated_assignment(node); self.visit_expr(&node.annotation); if let Some(value) = &node.value { self.visit_expr(value); @@ -4782,7 +5601,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { *node.target, ast::Expr::Attribute(_) | ast::Expr::Subscript(_) | ast::Expr::Name(_) ) { - self.push_assignment(CurrentAssignment::AnnAssign(node)); + self.push_assignment(CurrentAssignment::AnnAssign { node, pending }); self.visit_expr(&node.target); self.pop_assignment(); @@ -4801,32 +5620,46 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { }, ) => { debug_assert_eq!(&self.current_assignments, &[]); + + // An augmented assignment loads its target before evaluating the right-hand side, + // but only defines the target after the operation succeeds. + let is_place_target = matches!( + &**target, + ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) + ); + if is_place_target { + self.push_assignment(CurrentAssignment::AugAssign(aug_assign)); + self.visit_expr(target); + self.pop_assignment(); + } else { + self.visit_expr(target); + } + self.visit_expr(value); - match &**target { - ast::Expr::Name(ast::ExprName { id, .. }) - if id == "__all__" && op.is_add() && self.in_module_scope() => - { - if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = - &**value - { - if attr == "__all__" { - self.add_standalone_expression(value); - } - } + if let ast::Expr::Name(ast::ExprName { id, .. }) = &**target + && id == "__all__" + && op.is_add() + && self.in_module_scope() + && let ast::Expr::Attribute(ast::ExprAttribute { + value: module, + attr, + .. + }) = &**value + && attr == "__all__" + { + self.add_standalone_expression(module); + } - self.push_assignment(CurrentAssignment::AugAssign(aug_assign)); - self.visit_expr(target); - self.pop_assignment(); - } - ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.push_assignment(CurrentAssignment::AugAssign(aug_assign)); - self.visit_expr(target); - self.pop_assignment(); - } - _ => { - self.visit_expr(target); - } + self.record_exception_checkpoint(); + + if is_place_target + && let Some(place_expr) = PlaceExpr::try_from_expr(target.as_ref()) + { + let place_id = self.add_place(place_expr); + self.push_assignment(CurrentAssignment::AugAssign(aug_assign)); + self.record_place_definition(place_id, target); + self.pop_assignment(); } } // basedpython `let := [else: ...]`: a single @@ -4899,7 +5732,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let (mut falsy, mut last_predicate, mut last_narrowing_id) = self.visit_if_condition(node.pattern.as_deref(), &node.test); let mut last_reachability_constraint = - self.record_reachability_constraint(last_predicate); + self.record_reachability_constraint_id(last_narrowing_id); let is_outer_block_in_type_checking = self.in_type_checking_block; @@ -4952,7 +5785,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { self.visit_if_condition(clause_pattern, elif_test); last_reachability_constraint = - self.record_reachability_constraint(last_predicate); + self.record_reachability_constraint_id(last_narrowing_id); Some(next_falsy) } else { @@ -4981,212 +5814,57 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { self.in_type_checking_block = is_outer_block_in_type_checking || clause_in_type_checking; - self.visit_block_body(clause_body); - - let Some(next_falsy) = next_falsy else { - break; - }; - falsy = next_falsy; - } - - for post_clause_state in post_clauses { - self.flow_merge(post_clause_state); - } - - self.in_type_checking_block = is_outer_block_in_type_checking; - } - ast::Stmt::While( - while_stmt @ ast::StmtWhile { - test, - body, - orelse, - range: _, - node_index: _, - }, - ) => { - // Pre-walk the loop to collect all the bound places, then create a loop header - // definition for each bound place. See `struct LoopHeader` for more on this. Loop - // header definitions store the ID of a reserved `LoopHeader` that we populate - // after walking the body. - let bound_places = loop_bindings_visitor::collect_while_loop_bindings(while_stmt); - let mut maybe_loop_header_info = None; - // Avoid allocating a `LoopHeader` if there are no bound places in this loop. - if !bound_places.is_empty() { - maybe_loop_header_info = Some(self.synthesize_loop_header_definitions( - LoopStmtRef::While(while_stmt), - bound_places, - )); - } - - // Visit the test expression after creating loop headers, so that loop-back values - // are visible. - self.visit_expr(test); - let condition_flow_snapshot = self.flow_snapshot_for_condition(test); - - // Take the pre_loop snapshot from the post-test fallback flow before restoring the - // condition's truthy flow for the body. This preserves the zero-iteration path for - // the loop exit merge below. - let pre_loop = self.flow_snapshot(); - if let Some(truthy) = condition_flow_snapshot.into_truthy() { - self.flow_restore(truthy); - } - let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); - self.record_reachability_constraint(predicate); - - let outer_loop = self.push_loop(); - self.visit_block_body(body); - let this_loop = self.pop_loop(outer_loop); - - // Loop-back bindings include everything that's visible if/when control reaches the - // end of the loop body, and they also include everything that's visible to a - // `continue` statement. Merge the `continue` states before collecting bindings. - for continue_state in this_loop.continue_states { - self.flow_merge(continue_state); - } - - // Collect all the loop-back bindings (including the `continue` states we just - // merged) and populate the `LoopHeader`. - if let Some((header_id, bound_place_ids, loop_min_definition_id)) = - maybe_loop_header_info - { - self.populate_loop_header(&bound_place_ids, header_id, loop_min_definition_id); - } - - // We execute the `else` branch once the condition evaluates to false. This could - // happen without ever executing the body, if the condition is false the first time - // it's tested. Or it could happen if a _later_ evaluation of the condition yields - // false. So we merge in the pre-loop state here into the post-body state: - self.flow_merge(pre_loop); - - // The `else` branch can only be reached if the loop condition *can* be false. To - // model this correctly, we need a second copy of the while condition constraint, - // since the first and later evaluations might produce different results. We would - // otherwise simplify `predicate AND ~predicate` to `False`. - let later_predicate_id = self.current_use_def_map_mut().add_predicate(predicate); - let later_reachability_constraint = self - .current_reachability_constraints_mut() - .add_atom(later_predicate_id); - self.record_negated_reachability_constraint(later_reachability_constraint); - - self.record_negated_narrowing_constraint(predicate, predicate_id); - - self.visit_block_body(orelse); - - // Breaking out of a while loop bypasses the `else` clause, so merge in the break - // states after visiting `else`. - for break_state in this_loop.break_states { - self.flow_merge(break_state); - } - } - ast::Stmt::With(ast::StmtWith { - items, - body, - is_async, - .. - }) => { - for item @ ast::WithItem { - range: _, - node_index: _, - context_expr, - optional_vars, - pattern, - } in items - { - self.visit_expr(context_expr); - - if let Some(optional_vars) = optional_vars.as_deref() { - let context_manager = self.add_standalone_expression(context_expr); - self.add_unpackable_assignment( - &Unpackable::WithItem { - item, - is_async: *is_async, - }, - optional_vars, - context_manager, - ); - // basedpython: the bound value went to the item's binder; - // the pattern destructures it from there - if let Some(pattern) = pattern.as_deref() - && let ast::Expr::Name(binder) = optional_vars - { - self.add_destructure_definitions(pattern, binder); - } - } - } - self.visit_block_body(body); - } - - ast::Stmt::For( - for_stmt @ ast::StmtFor { - range: _, - node_index: _, - is_async, - target, - pattern, - iter, - body, - orelse, - }, - ) => { - debug_assert_eq!(&self.current_assignments, &[]); - - let iter_expr = self.add_standalone_expression(iter); - self.visit_expr(iter); - - let literal_iterable_is_non_empty = (!*is_async) - .then(|| literal_iterable_truthiness(iter)) - .and_then(Truthiness::into_bool); - - let (after_empty_iter, non_empty_range_constraint) = - match literal_iterable_is_non_empty { - Some(false) => { - let after_iter = self.flow_snapshot(); - self.mark_unreachable(); - (Some(after_iter), None) - } - Some(true) => (None, None), - None if is_direct_range_call(iter) => { - let after_iter = self.flow_snapshot(); - let constraint = self.record_reachability_constraint( - PredicateOrLiteral::Predicate(Predicate { - node: PredicateNode::IsNonEmptyIterable(iter_expr), - is_positive: true, - }), - ); - - (None, Some((after_iter, constraint))) - } - None => { - self.record_ambiguous_reachability(); - (None, None) - } + self.visit_block_body(clause_body); + + let Some(next_falsy) = next_falsy else { + break; }; + falsy = next_falsy; + } - let pre_loop = self.flow_snapshot(); + for post_clause_state in post_clauses { + self.flow_merge(post_clause_state); + } + self.in_type_checking_block = is_outer_block_in_type_checking; + } + ast::Stmt::While( + while_stmt @ ast::StmtWhile { + test, + body, + orelse, + range: _, + node_index: _, + }, + ) => { // Pre-walk the loop to collect all the bound places, then create a loop header // definition for each bound place. See `struct LoopHeader` for more on this. Loop // header definitions store the ID of a reserved `LoopHeader` that we populate // after walking the body. - let bound_places = loop_bindings_visitor::collect_for_loop_bindings(for_stmt); + let bound_places = loop_bindings_visitor::collect_while_loop_bindings(while_stmt); let mut maybe_loop_header_info = None; // Avoid allocating a `LoopHeader` if there are no bound places in this loop. if !bound_places.is_empty() { maybe_loop_header_info = Some(self.synthesize_loop_header_definitions( - LoopStmtRef::For(for_stmt), + LoopStmtRef::While(while_stmt), bound_places, )); } - self.add_unpackable_assignment(&Unpackable::For(for_stmt), target, iter_expr); + // Visit the test expression after creating loop headers, so that loop-back values + // are visible. + self.visit_expr_with_context(test, ExpressionContext::Condition); + let condition_flow_snapshot = self.flow_snapshot_for_condition(test); - // basedpython: the element went to the loop's binder; the pattern - // destructures it from there - if let Some(pattern) = pattern.as_deref() - && let ast::Expr::Name(binder) = &**target - { - self.add_destructure_definitions(pattern, binder); + // Take the pre_loop snapshot from the post-test fallback flow before restoring the + // condition's truthy flow for the body. This preserves the zero-iteration path for + // the loop exit merge below. + let pre_loop = self.flow_snapshot(); + if let Some(truthy) = condition_flow_snapshot.into_truthy() { + self.flow_restore(truthy); } + let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); + self.record_reachability_constraint_id(predicate_id); let outer_loop = self.push_loop(); self.visit_block_body(body); @@ -5207,1187 +5885,1132 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { self.populate_loop_header(&bound_place_ids, header_id, loop_min_definition_id); } - if let Some(after_iter) = after_empty_iter { - self.flow_restore(after_iter); - } else if literal_iterable_is_non_empty.is_none() { - // We may execute the `else` clause without ever executing the body, so merge - // in a zero-iteration state before visiting `else`. - if let Some((after_iter, non_empty_range_constraint)) = - non_empty_range_constraint - { - let post_loop_body = self.flow_snapshot(); - self.flow_restore(after_iter); - self.record_negated_reachability_constraint(non_empty_range_constraint); - let no_iteration = self.flow_snapshot(); - self.flow_restore(post_loop_body); - self.flow_merge(no_iteration); - } else { - self.flow_merge(pre_loop); - } - } - self.visit_block_body(orelse); - - // Breaking out of a `for` loop bypasses the `else` clause, so merge in the break - // states after visiting `else`. - for break_state in this_loop.break_states { - self.flow_merge(break_state); - } - } - ast::Stmt::Match(ast::StmtMatch { - subject, - cases, - range: _, - node_index: _, - }) => { - debug_assert_eq!(self.current_match_case, None); - - let subject_expr = self.add_standalone_expression(subject); - self.visit_expr(subject); - if cases.is_empty() { - return; - } - - let (subject_targets, sequence_subject_targets) = - self.match_subject_targets(subject); - - let mut no_case_matched = self.flow_snapshot(); - - let has_catchall = cases - .last() - .is_some_and(|case| case.guard.is_none() && case.pattern.is_wildcard()); - - let mut post_case_snapshots = vec![]; - let mut previous_pattern: Option> = None; - - for (i, case) in cases.iter().enumerate() { - let (match_pattern_predicate, case_names) = self.create_pattern_predicate( - PatternSubject::Expression(subject_expr), - &case.pattern, - case.guard.as_deref(), - previous_pattern, - true, - ); - // basedpython: `case A:` looks like a wildcard but is not one - // when the name resolves to an enum member, and that is not - // known until type checking. The shortcut below is a - // precision optimization, so the conservative answer is to - // give it up for any case that offered a name at all - let offers_case_names = !case_names.is_empty(); - self.current_match_case = Some(CurrentMatchCase::new( - &case.pattern, - match_pattern_predicate, - case_names, - )); - self.visit_pattern(&case.pattern); - self.current_match_case = None; - // unlike in [Stmt::If], we don't reset [no_case_matched] - // here because the effects of visiting a pattern is binding - // symbols, and this doesn't occur unless the pattern - // actually matches - let is_catchall = has_catchall && i == cases.len() - 1 && !offers_case_names; - let (match_predicate, match_narrowing_id) = self - .add_pattern_narrowing_constraint( - match_pattern_predicate, - &subject_targets, - &sequence_subject_targets, - is_catchall, - ); - previous_pattern = Some(match_pattern_predicate); - let reachability_constraint = - self.record_reachability_constraint(match_predicate); - - let match_success_guard_failure = case.guard.as_ref().map(|guard| { - let guard_expr = self.add_standalone_expression(guard); - // We could also add the guard expression as a reachability constraint, but - // it seems unlikely that both the case predicate as well as the guard are - // statically known conditions, so we currently don't model that. - self.record_ambiguous_reachability(); - self.visit_expr(guard); - let condition_flow_snapshot = self.flow_snapshot_for_condition(guard); - let predicate = PredicateOrLiteral::Predicate(Predicate { - node: PredicateNode::Expression(guard_expr), - is_positive: true, - }); - // Use the same predicate ID for the successful and failed checks. - let guard_predicate_id = self.add_predicate(predicate); - let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); - let truthy = - if let Some(snapshots) = condition_flow_snapshot.into_branches() { - self.flow_restore(snapshots.falsy); - snapshots.truthy - } else { - self.flow_snapshot() - }; - self.current_use_def_map_mut() - .record_negated_narrowing_constraint_for_places( - guard_predicate_id, - &possibly_narrowed, - ); - let match_success_guard_failure = self.flow_snapshot(); - self.flow_restore(truthy); - self.current_use_def_map_mut() - .record_narrowing_constraint_for_places( - guard_predicate_id, - &possibly_narrowed, - ); - match_success_guard_failure - }); + self.record_exception_checkpoint_if(!Self::condition_evaluation_is_known_safe( + test, + )); - self.visit_block_body(&case.body); + // We execute the `else` branch once the condition evaluates to false. This could + // happen without ever executing the body, if the condition is false the first time + // it's tested. Or it could happen if a _later_ evaluation of the condition yields + // false. So we merge in the pre-loop state here into the post-body state: + self.flow_merge(pre_loop); - post_case_snapshots.push(self.flow_snapshot()); + // The `else` branch can only be reached if the loop condition *can* be false. To + // model this correctly, we need a second copy of the while condition constraint, + // since the first and later evaluations might produce different results. We would + // otherwise simplify `predicate AND ~predicate` to `False`. + let later_predicate_id = self.current_use_def_map_mut().add_predicate(predicate); + let later_reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(later_predicate_id); + self.record_negated_reachability_constraint(later_reachability_constraint); - if i != cases.len() - 1 || !has_catchall { - // We need to restore the state after each case, but not after the last - // one. The last one will just become the state that we merge the other - // snapshots into. - self.flow_restore(no_case_matched.clone()); - self.record_negated_narrowing_constraint( - match_predicate, - match_narrowing_id, - ); - self.record_negated_reachability_constraint(reachability_constraint); - if let Some(match_success_guard_failure) = match_success_guard_failure { - self.flow_merge(match_success_guard_failure); - } else { - assert!(case.guard.is_none()); - } - } else { - debug_assert!(match_success_guard_failure.is_none()); - debug_assert!(case.guard.is_none()); - } + self.record_negated_narrowing_constraint(predicate, predicate_id); - no_case_matched = self.flow_snapshot(); - } + self.visit_block_body(orelse); - for post_clause_state in post_case_snapshots { - self.flow_merge(post_clause_state); + // Breaking out of a while loop bypasses the `else` clause, so merge in the break + // states after visiting `else`. + for break_state in this_loop.break_states { + self.flow_merge(break_state); } } - ast::Stmt::Try(ast::StmtTry { + ast::Stmt::With(ast::StmtWith { + items, body, - handlers, - orelse, - finalbody, - is_star, - range: _, - node_index: _, + is_async, + .. }) => { - let was_in_try = std::mem::replace(&mut self.in_try, true); - self.record_ambiguous_reachability(); - - // Save the state prior to visiting any of the `try` block. - // - // Potentially none of the `try` block could have been executed prior to executing - // the `except` block(s) and/or the `finally` block. - // We will merge this state with all of the intermediate - // states during the `try` block before visiting those suites. - let pre_try_block_state = self.flow_snapshot(); - - self.try_node_context_stack_manager.push_context(); - - // Visit the `try` block! - let try_block_declarations = self.visit_block_body(body); - - let mut post_except_states = vec![]; - - // Take a record also of all the intermediate states we encountered - // while visiting the `try` block. Keep the context itself on the stack so that - // terminal statements in `except` and `else` suites can still be recorded as - // entries to the associated `finally` suite. - let try_block_snapshots = self - .try_node_context_stack_manager - .take_try_suite_snapshots(); - - if !handlers.is_empty() { - // Save the state immediately *after* visiting the `try` block - // but *before* we prepare for visiting the `except` block(s). - // - // We will revert to this state prior to visiting the `else` block, - // as there necessarily must have been 0 `except` blocks executed - // if we hit the `else` block. - let post_try_block_state = self.flow_snapshot(); - - // Prepare for visiting the `except` block(s) - self.flow_restore(pre_try_block_state); - for state in try_block_snapshots { - self.flow_merge(state); - } + for item @ ast::WithItem { + range: _, + node_index: _, + context_expr, + optional_vars, + pattern, + } in items + { + self.visit_expr(context_expr); + self.record_exception_checkpoint(); - // basedpython: an exception leaves the `try` block from inside - // it, at a point where the block had not unbound its own - // declarations yet. A handler is a sibling block, so those names - // are out of scope in it either way. - self.unbind_block_declarations(&try_block_declarations); + self.exception_context_stack_manager + .push_context_manager_context(); - let pre_except_state = self.flow_snapshot(); - let num_handlers = handlers.len(); + if let Some(optional_vars) = optional_vars.as_deref() { + let context_manager = self.add_standalone_expression(context_expr); + self.add_unpackable_assignment( + &Unpackable::WithItem { + item, + is_async: *is_async, + }, + optional_vars, + context_manager, + ); + // basedpython: the bound value went to the item's binder; + // the pattern destructures it from there + if let Some(pattern) = pattern.as_deref() + && let ast::Expr::Name(binder) = optional_vars + { + self.add_destructure_definitions(pattern, binder); + } + } + } - for (i, except_handler) in handlers.iter().enumerate() { - let ast::ExceptHandler::ExceptHandler(except_handler) = except_handler; - let ast::ExceptHandlerExceptHandler { - name: symbol_name, - type_: handled_exceptions, - body: handler_body, - range: _, - node_index: _, - } = except_handler; + self.visit_block_body(body); - if let Some(handled_exceptions) = handled_exceptions { - self.visit_expr(handled_exceptions); + for item in items.iter().rev() { + let mut exceptional_entries = self + .exception_context_stack_manager + .finish_context_manager_context() + .into_iter(); + + if let Some(exceptional_entry) = exceptional_entries.next() { + let normal_exit = self.flow_snapshot(); + if normal_exit.is_always_unreachable() { + self.exception_context_stack_manager + .record_deferred_terminal_context_manager_exit(); } + let context_expr = &item.context_expr; + let expression = self + .expressions_by_node + .get(&ExpressionNodeKey::from(context_expr)) + .copied() + .unwrap_or_else(|| self.add_standalone_expression(context_expr)); + let predicate = PredicateOrLiteral::Predicate(Predicate { + node: PredicateNode::ContextManagerSuppresses { + expression, + is_async: *is_async, + }, + is_positive: true, + }); + let predicate_id = self.add_predicate(predicate); - // If `handled_exceptions` above was `None`, it's something like `except as e:`, - // which is invalid syntax. However, it's still pretty obvious here that the user - // *wanted* `e` to be bound, so we should still create a definition here nonetheless. - let symbol = if let Some(symbol_name) = symbol_name { - let symbol = self.add_symbol(symbol_name.id.clone()); + self.flow_restore(exceptional_entry); + for exceptional_entry in exceptional_entries { + self.flow_merge(exceptional_entry); + } - self.add_definition( - symbol.into(), - DefinitionNodeRef::ExceptHandler(ExceptHandlerDefinitionNodeRef { - handler: except_handler, - is_star: *is_star, - }), + self.record_ambiguous_reachability(); + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); + let narrowing_constraint = self + .current_use_def_map_mut() + .narrowing_constraints + .add_atom(predicate_id); + self.current_use_def_map_mut() + .record_non_terminal_call_constraints( + reachability_constraint, + narrowing_constraint, ); - Some(symbol) - } else { - None - }; - - self.visit_block_body(handler_body); - // The caught exception is cleared at the end of the except clause - if let Some(symbol) = symbol { - self.delete_binding(symbol.into()); - } - // Each `except` block is mutually exclusive with all other `except` blocks. - post_except_states.push(self.flow_snapshot()); - // It's unnecessary to do the `self.flow_restore()` call for the final except handler, - // as we'll immediately call `self.flow_restore()` to a different state - // as soon as this loop over the handlers terminates. - if i < (num_handlers - 1) { - self.flow_restore(pre_except_state.clone()); - } + self.flow_merge(normal_exit); } - // If we get to the `else` block, we know that 0 of the `except` blocks can have been executed, - // and the entire `try` block must have been executed: - self.flow_restore(post_try_block_state); + // A manager cannot suppress an exception raised by its own exit method, but + // an earlier manager or enclosing `try` statement can still receive it. + self.record_exception_checkpoint(); } + } - self.visit_block_body(orelse); + ast::Stmt::For( + for_stmt @ ast::StmtFor { + range: _, + node_index: _, + is_async, + target, + pattern, + iter, + body, + orelse, + }, + ) => { + debug_assert_eq!(&self.current_assignments, &[]); - for post_except_state in post_except_states { - self.flow_merge(post_except_state); - } + let iter_expr = self.add_standalone_expression(iter); + self.visit_expr(iter); + let iteration_can_raise = *is_async || !Self::iteration_is_known_safe(iter); + self.record_exception_checkpoint_if(iteration_can_raise); - let normal_pre_finally_state = self.flow_snapshot(); - let terminal_finally_entry_snapshots = self - .try_node_context_stack_manager - .pop_context() - .into_terminal_finally_entry_snapshots(); + let literal_iterable_is_non_empty = (!*is_async) + .then(|| literal_iterable_truthiness(iter)) + .and_then(Truthiness::into_bool); - // TODO: there's lots of complexity here that isn't yet handled by our model. - // In order to accurately model the semantics of `finally` suites, we in fact need to visit - // the suite twice: once under the (current) assumption that either the `try + else` suite - // ran to completion or exactly one `except` branch ran to completion, and then again under - // the assumption that potentially none of the branches ran to completion and we in fact - // jumped from a `try`, `else` or `except` branch straight into the `finally` branch. - // This requires rethinking some fundamental assumptions semantic indexing makes. - // For more details, see: - // - https://astral-sh.notion.site/Exception-handler-control-flow-11348797e1ca80bb8ce1e9aedbbe439d - // - https://github.com/astral-sh/ruff/pull/13633#discussion_r1788626702 - if normal_pre_finally_state.is_always_unreachable() - && !terminal_finally_entry_snapshots.is_empty() - { - let mut snapshots = terminal_finally_entry_snapshots.into_iter(); - let first_snapshot = snapshots.next().expect("checked non-empty snapshots"); - self.flow_restore(first_snapshot); - for snapshot in snapshots { - self.flow_merge(snapshot); - } - self.visit_block_body(finalbody); - if !self.flow_snapshot().is_always_unreachable() { - self.record_terminal_finally_entry(); - } - self.mark_unreachable(); - } else { - // Mixed normal and terminal entry states are still handled by the normal path - // only. See the corresponding TODO tests in `terminal_statements.md`. - self.visit_block_body(finalbody); - } - self.in_try = was_in_try; - } + let (after_empty_iter, non_empty_range_constraint) = + match literal_iterable_is_non_empty { + Some(false) => { + let after_iter = self.flow_snapshot(); + self.mark_unreachable(); + (Some(after_iter), None) + } + Some(true) => (None, None), + None if is_direct_range_call(iter) => { + let after_iter = self.flow_snapshot(); + let constraint = self.record_reachability_constraint( + PredicateOrLiteral::Predicate(Predicate { + node: PredicateNode::IsNonEmptyIterable(iter_expr), + is_positive: true, + }), + ); - ast::Stmt::Raise(_) | ast::Stmt::Return(_) => { - let recovers_from_body = matches!(stmt, ast::Stmt::Return(_)) - && self.enclosing_function_wrote_down_no_return_type(); - if let ast::Stmt::Return(ast::StmtReturn { - value: Some(value), .. - }) = stmt - && recovers_from_body - { - // basedpython: a returned expression says more than its own type does — - // `return a is int` tells every caller what a truthy result means about the - // argument. Reading that is the narrowing machinery's job, and it evaluates - // a predicate over a standalone expression, so record one for it - self.add_standalone_expression(value); - } - walk_stmt(self, stmt); - // and what narrowing established about the members of a returned place is part of - // what is handed back. Nothing between the walk of the value and here changes any - // binding, so this is still the state the `return` sees - if let ast::Stmt::Return(ast::StmtReturn { - value: Some(value), .. - }) = stmt - && recovers_from_body - { - self.record_returned_place_members(value); - } - self.record_terminal_finally_entry(); - // Everything in the current block after a terminal statement is unreachable. - self.mark_unreachable(); - } + (None, Some((after_iter, constraint))) + } + None => { + self.record_ambiguous_reachability(); + (None, None) + } + }; - ast::Stmt::Continue(_) | ast::Stmt::Break(_) => { - // the value is evaluated before control leaves the loop, so it is - // visited before the break's flow effect is recorded - if let ast::Stmt::Break(ast::StmtBreak { - value: Some(value), .. - }) = stmt - { - self.check_break_value(stmt, value); - self.visit_expr(value); - } - self.unbind_blocks_left_by_jump(); - let snapshot = self.flow_snapshot(); - if let Some(current_loop) = self.current_loop_mut() { - if stmt.is_continue_stmt() { - current_loop.continue_states.push(snapshot); - } else { - current_loop.break_states.push(snapshot); - } - } - self.record_terminal_finally_entry(); - // Everything in the current block after a terminal statement is unreachable. - self.mark_unreachable(); - } - ast::Stmt::Global(ast::StmtGlobal { - range, - node_index: _, - names, - }) => { - for name in names { - self.scopes_by_expression - .record_expression(name, self.current_scope()); - let symbol_id = self.add_symbol(name.id.clone()); - let symbol = self.current_place_table().symbol(symbol_id); - // Check whether the variable has already been accessed in this scope. - if (symbol.is_bound() || symbol.is_declared() || symbol.is_used()) - && !symbol.is_parameter() - { - self.report_semantic_error(SemanticSyntaxError { - kind: SemanticSyntaxErrorKind::LoadBeforeGlobalDeclaration { - name: name.to_string(), - start: name.range.start(), - }, - range: name.range, - python_version: self.python_version(), - }); - } - // Check whether the variable has also been declared nonlocal. - if symbol.is_nonlocal() { - self.report_semantic_error(SemanticSyntaxError { - kind: SemanticSyntaxErrorKind::NonlocalAndGlobal(name.to_string()), - range: name.range, - python_version: self.python_version(), - }); - // Never mark a symbol both global and nonlocal, even in this error case. - continue; - } - // Check whether this is the module scope, where `global` has no effect. - let scope_id = self.current_scope(); - if scope_id.is_global() { - // It's important that we don't `mark_global` here, because we error on - // type annotations on places that are marked global, but it's actually - // legal to write `global x; x: int = 42` at the module level. - continue; - } - // Assuming none of the rules above are violated, repeated `global` - // declarations are allowed and ignored. - if symbol.is_global() { - continue; - } - self.current_place_table_mut() - .symbol_mut(symbol_id) - .mark_global(); - self.current_scope_info_mut() - .this_scope_global_or_nonlocal_declarations - .insert(name.id.clone(), *range); + let pre_loop = self.flow_snapshot(); + + // Pre-walk the loop to collect all the bound places, then create a loop header + // definition for each bound place. See `struct LoopHeader` for more on this. Loop + // header definitions store the ID of a reserved `LoopHeader` that we populate + // after walking the body. + let bound_places = loop_bindings_visitor::collect_for_loop_bindings(for_stmt); + let mut maybe_loop_header_info = None; + // Avoid allocating a `LoopHeader` if there are no bound places in this loop. + if !bound_places.is_empty() { + maybe_loop_header_info = Some(self.synthesize_loop_header_definitions( + LoopStmtRef::For(for_stmt), + bound_places, + )); } - walk_stmt(self, stmt); - } - ast::Stmt::Nonlocal(ast::StmtNonlocal { - range, - node_index: _, - names, - }) => { - for name in names { - self.scopes_by_expression - .record_expression(name, self.current_scope()); - let symbol_id = self.add_symbol(name.id.clone()); - let symbol = self.current_place_table().symbol(symbol_id); - // Check whether the variable has already been accessed in this scope. - if symbol.is_bound() || symbol.is_declared() || symbol.is_used() { - self.report_semantic_error(SemanticSyntaxError { - kind: SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration { - name: name.to_string(), - start: name.range.start(), - }, - range: name.range, - python_version: self.python_version(), - }); - } - // Check whether the variable has also been declared global. - if symbol.is_global() { - self.report_semantic_error(SemanticSyntaxError { - kind: SemanticSyntaxErrorKind::NonlocalAndGlobal(name.to_string()), - range: name.range, - python_version: self.python_version(), - }); - // Never mark a symbol both global and nonlocal, even in this error case. - continue; - } - // Check whether this is the module scope, where `nonlocal` isn't allowed. - let scope_id = self.current_scope(); - if scope_id.is_global() { - // The SemanticSyntaxChecker will report an error for this. - continue; - } - // Assuming none of the rules above are violated, repeated `nonlocal` - // declarations are allowed and ignored. - if symbol.is_nonlocal() { - continue; - } - self.current_place_table_mut() - .symbol_mut(symbol_id) - .mark_nonlocal(); - self.current_scope_info_mut() - .this_scope_global_or_nonlocal_declarations - .insert(name.id.clone(), *range); + + self.add_unpackable_assignment(&Unpackable::For(for_stmt), target, iter_expr); + + // basedpython: the element went to the loop's binder; the pattern + // destructures it from there + if let Some(pattern) = pattern.as_deref() + && let ast::Expr::Name(binder) = &**target + { + self.add_destructure_definitions(pattern, binder); } - walk_stmt(self, stmt); - } - ast::Stmt::Delete(ast::StmtDelete { - targets, - range: _, - node_index: _, - }) => { - // We will check the target expressions and then delete them. - walk_stmt(self, stmt); - for target in targets { - if let Some(mut target) = PlaceExpr::try_from_expr(target) { - if let PlaceExpr::Symbol(symbol) = &mut target { - // `del x` behaves like an assignment in that it forces all references - // to `x` in the current scope (including *prior* references) to refer - // to the current scope's binding (unless `x` is declared `global` or - // `nonlocal`). For example, this is an UnboundLocalError at runtime: - // - // ```py - // x = 1 - // def foo(): - // print(x) # can't refer to global `x` - // if False: - // del x - // foo() - // ``` - symbol.mark_bound(); - symbol.mark_used(); - } - let place_id = self.add_place(target); - self.invalidate_narrowing_aliases_for(place_id); - self.delete_binding(place_id); + let outer_loop = self.push_loop(); + self.visit_block_body(body); + let this_loop = self.pop_loop(outer_loop); + + // Loop-back bindings include everything that's visible if/when control reaches the + // end of the loop body, and they also include everything that's visible to a + // `continue` statement. Merge the `continue` states before collecting bindings. + for continue_state in this_loop.continue_states { + self.flow_merge(continue_state); + } + + // Collect all the loop-back bindings (including the `continue` states we just + // merged) and populate the `LoopHeader`. + if let Some((header_id, bound_place_ids, loop_min_definition_id)) = + maybe_loop_header_info + { + self.populate_loop_header(&bound_place_ids, header_id, loop_min_definition_id); + } + + self.record_exception_checkpoint_if(iteration_can_raise || !target.is_name_expr()); + + if let Some(after_iter) = after_empty_iter { + self.flow_restore(after_iter); + } else if literal_iterable_is_non_empty.is_none() { + // We may execute the `else` clause without ever executing the body, so merge + // in a zero-iteration state before visiting `else`. + if let Some((after_iter, non_empty_range_constraint)) = + non_empty_range_constraint + { + let post_loop_body = self.flow_snapshot(); + self.flow_restore(after_iter); + self.record_negated_reachability_constraint(non_empty_range_constraint); + let no_iteration = self.flow_snapshot(); + self.flow_restore(post_loop_body); + self.flow_merge(no_iteration); + } else { + self.flow_merge(pre_loop); } } + self.visit_block_body(orelse); + + // Breaking out of a `for` loop bypasses the `else` clause, so merge in the break + // states after visiting `else`. + for break_state in this_loop.break_states { + self.flow_merge(break_state); + } } - ast::Stmt::Expr(ast::StmtExpr { - value, + ast::Stmt::Match(ast::StmtMatch { + subject, + cases, range: _, node_index: _, }) => { - if self.in_module_scope() { - if let Some(expr) = dunder_all_extend_argument(value) { - self.add_standalone_expression(expr); - } - } - - self.visit_expr(value); + debug_assert_eq!(self.current_match_case, None); - // basedpython ` cast ` / ` cast! ` as a bare - // statement narrows the value place to the target type for the rest of - // the scope, like an unconditional `assert isinstance(value, type)`. - // `cast?` is left out: it yields `None` rather than asserting anything. - // The synthetic `cast` callee is unresolved and never `NoReturn`, so the - // terminal call analysis below is skipped for it. - if let ast::Expr::Call(call) = value.as_ref() - && matches!( - call.cast_kind, - Some(ast::CastKind::Static | ast::CastKind::Checked) - ) - { - let predicate = self.build_predicate(value); - self.record_narrowing_constraint(predicate); + let subject_expr = self.add_standalone_expression(subject); + self.visit_expr(subject); + if cases.is_empty() { return; } - // If the statement is a call (or an `await` wrapping a call), it could - // possibly be a call to a function marked with `NoReturn` (for example, - // `sys.exit()` or `await async_exit()`). In this case, we use a special - // kind of constraint to mark the following code as unreachable. - // - // Ideally, these constraints should be added for every call expression, even those in - // sub-expressions. But doing so makes the number of such constraints so high that - // it significantly degrades performance. We thus cut scope here and add these - // constraints only at statement-level function calls, like `sys.exit()`, and not - // within sub-expressions like `3 + sys.exit()` etc. - let call_info = match value.as_ref() { - ast::Expr::Call(ast::ExprCall { func, .. }) => { - Some((func.as_ref(), value.as_ref(), false)) - } - ast::Expr::Await(ast::ExprAwait { value: inner, .. }) => match inner.as_ref() { - ast::Expr::Call(ast::ExprCall { func, .. }) => { - Some((func.as_ref(), value.as_ref(), true)) - } - _ => None, - }, - _ => None, - }; + let (subject_targets, sequence_subject_targets) = + self.match_subject_targets(subject); - if let Some((func, expr, is_await)) = call_info { - // Avoid creating reachability nodes for calls on fluid specialization - // candidates. Without this short-circuit, performing reachability analysis - // can lead to quadratic blowup of cycle dependencies during full-scope - // fluid specialization inference, as Salsa flattens the dependencies of all - // cycle participants, and the reachability analysis of a given use of the - // candidate may create dependencies on all previous uses, leading to - // significant performance regressions. - // - // Note that built-in collection types do not have methods that explicitly - // return `Never`, so this rarely has a meaningful semantic impact. - // - // basedpython: the fluid short-circuit is about reachability only. An - // assertion guard called on such a receiver (`a = A(); a.f()`) still has - // to narrow, so its predicate is recorded either way. - let is_terminal_call_candidate = func - .as_attribute_expr() - .and_then(|attribute| self.fluid_candidate_binding(&attribute.value)) - .is_none(); - let is_guard_call_candidate = self.source_type.is_basedpython(); + let mut no_case_matched = self.flow_snapshot(); - if !self.source_type.is_stub() - && (is_terminal_call_candidate || is_guard_call_candidate) - { - let callable = self.add_standalone_expression(func); - let call_expr = self.add_standalone_expression(expr); + let has_catchall = cases + .last() + .is_some_and(|case| case.guard.is_none() && case.pattern.is_wildcard()); + + let mut post_case_snapshots = vec![]; + let mut previous_pattern: Option> = None; + + for (i, case) in cases.iter().enumerate() { + let (match_pattern_predicate, case_names) = self.create_pattern_predicate( + PatternSubject::Expression(subject_expr), + &case.pattern, + case.guard.as_deref(), + previous_pattern, + true, + ); + // basedpython: `case A:` looks like a wildcard but is not one + // when the name resolves to an enum member, and that is not + // known until type checking. The shortcut below is a + // precision optimization, so the conservative answer is to + // give it up for any case that offered a name at all + let offers_case_names = !case_names.is_empty(); + self.current_match_case = Some(CurrentMatchCase::new( + &case.pattern, + match_pattern_predicate, + case_names, + )); + self.record_exception_checkpoint_if(Self::pattern_can_raise(&case.pattern)); + self.visit_pattern(&case.pattern); + self.current_match_case = None; + // unlike in [Stmt::If], we don't reset [no_case_matched] + // here because the effects of visiting a pattern is binding + // symbols, and this doesn't occur unless the pattern + // actually matches + let is_catchall = has_catchall && i == cases.len() - 1 && !offers_case_names; + let (match_predicate, match_narrowing_id) = self + .add_pattern_narrowing_constraint( + match_pattern_predicate, + &subject_targets, + &sequence_subject_targets, + is_catchall, + ); + previous_pattern = Some(match_pattern_predicate); + let reachability_constraint = + self.record_reachability_constraint_id(match_narrowing_id); + + // For a pattern `P` and guard `G`, the case body is reached through `P && G`, + // while the next case is reached through `!P || (P && !G)`. Save `P && !G` + // separately so it can be merged with the pattern-failure state after the body. + let match_success_guard_failure = case.guard.as_ref().map(|guard| { + self.visit_expr_with_context(guard, ExpressionContext::Condition); + let condition_flow_snapshot = self.flow_snapshot_for_condition(guard); + let falsy = if let Some(snapshots) = condition_flow_snapshot.into_branches() + { + self.flow_restore(snapshots.truthy); + snapshots.falsy + } else { + self.flow_snapshot() + }; - if is_terminal_call_candidate { - let predicate = Predicate { - node: PredicateNode::IsNonTerminalCall(CallableAndCallExpr { - callable, - call_expr, - is_await, - }), - is_positive: true, - }; + let (guard_predicate, guard_predicate_id) = + self.record_expression_narrowing_constraint(guard); + let guard_reachability_constraint = + self.record_reachability_constraint_id(guard_predicate_id); + let guard_success = self.flow_snapshot(); - let predicate_id = - self.add_predicate(PredicateOrLiteral::Predicate(predicate)); - let narrowing_constraint = self - .current_use_def_map_mut() - .narrowing_constraints - .add_atom(predicate_id); + self.flow_restore(falsy); + self.record_negated_narrowing_constraint( + guard_predicate, + guard_predicate_id, + ); + self.record_negated_reachability_constraint(guard_reachability_constraint); + let match_success_guard_failure = self.flow_snapshot(); + self.flow_restore(guard_success); + match_success_guard_failure + }); - if self.in_function_scope() { - let reachability_constraint = self - .current_reachability_constraints_mut() - .add_atom(predicate_id); - self.current_use_def_map_mut() - .record_non_terminal_call_constraints( - reachability_constraint, - narrowing_constraint, - ); - } else { - // In non-function scopes, we only record a narrowing constraint - // (not a reachability constraint). Recording reachability for - // calls in module scope is simply too expensive, and it's not - // too important of a use case. - self.current_use_def_map_mut() - .record_narrowing_constraint_for_all_places( - narrowing_constraint, - ); - } - } + self.visit_block_body(&case.body); - // basedpython: the same call may be a call to an assertion guard - // (`def f(x) -> asserts x`), which narrows once it returns — that is, - // for the rest of this flow rather than inside a branch - if is_guard_call_candidate { - // record the call itself, which is what a checker sees; `expr` - // is the `await` for an awaited call - if let Some(call) = asserted_call(expr) { - self.basedpython_statement_calls - .insert(ExpressionNodeKey::from(call)); - } - self.record_narrowing_constraint(PredicateOrLiteral::Predicate( - Predicate { - node: PredicateNode::AssertsCall(CallableAndCallExpr { - callable, - call_expr, - is_await, - }), - is_positive: true, - }, - )); + post_case_snapshots.push(self.flow_snapshot()); + + if i != cases.len() - 1 || !has_catchall { + // We need to restore the state after each case, but not after the last + // one. The last one will just become the state that we merge the other + // snapshots into. + self.flow_restore(no_case_matched.clone()); + self.record_negated_narrowing_constraint( + match_predicate, + match_narrowing_id, + ); + self.record_negated_reachability_constraint(reachability_constraint); + if let Some(match_success_guard_failure) = match_success_guard_failure { + self.flow_merge(match_success_guard_failure); + } else { + assert!(case.guard.is_none()); } + } else { + debug_assert!(match_success_guard_failure.is_none()); + debug_assert!(case.guard.is_none()); } + + no_case_matched = self.flow_snapshot(); } - } - _ => { - walk_stmt(self, stmt); - } - } - } -} -impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { - fn visit_stmt(&mut self, stmt: &'ast ast::Stmt) { - let is_loop = matches!(stmt, ast::Stmt::For(_) | ast::Stmt::While(_)); - if is_loop { - self.loop_ranges.push(stmt.range()); - } - self.push_statement(CurrentStatement::default()); - self.visit_stmt_impl(stmt); - let current_statement = self.pop_statement(); - if is_loop { - self.loop_ranges.pop(); - } + for post_clause_state in post_case_snapshots { + self.flow_merge(post_clause_state); + } + } + ast::Stmt::Try(ast::StmtTry { + body, + handlers, + orelse, + finalbody, + is_star, + range: _, + node_index: _, + }) => { + let was_in_try_statement = std::mem::replace(&mut self.in_try_statement, true); + self.record_ambiguous_reachability(); - if current_statement.lambda_expressions.is_empty() - && current_statement.fluid_uses.is_empty() - { - return; - } + let exception_handlers = if handlers.is_empty() { + ExceptionHandlers::None + } else if handlers.iter().any(|handler| { + let ast::ExceptHandler::ExceptHandler(handler) = handler; + handler.type_.is_none() + }) { + ExceptionHandlers::catch_all() + } else { + ExceptionHandlers::propagating() + }; + self.exception_context_stack_manager + .push_try_context(exception_handlers, !finalbody.is_empty()); - // Classify how each fluid-candidate use in this statement interacts with the - // candidate's specialization. Constraints can only be read back from the - // inference of simple (non-compound) statements, so constraint-bearing roles - // inside compound statement headers are downgraded. - let classified: Vec<(Definition<'_>, FluidUse<'_>)> = current_statement - .fluid_uses - .into_iter() - .map(|(candidate_def, use_expression, range, loops)| { - let (mut role, discarded_call_result) = classify_fluid_use(stmt, use_expression); - if role.contributes_constraints() && !is_simple_statement(stmt) { - role = match role { - // A method call in a compound statement header (e.g. `if a.pop():`) - // cannot be read back for constraints; treat it as an opaque use. - FluidUseRole::MethodReceiver | FluidUseRole::SubscriptStore => { - FluidUseRole::Escape - } - FluidUseRole::TypeContextual => FluidUseRole::Escape, - role => role, - }; - } - ( - candidate_def, - FluidUse { - use_expression, - range, - role, - discarded_call_result, - statement_range: stmt.range(), - loops, - statement: None, - }, - ) - }) - .collect(); + // Visit the `try` block! + let try_block_declarations = self.visit_block_body(body); - let needs_standalone_statement = !current_statement.lambda_expressions.is_empty() - || classified - .iter() - .any(|(_, fluid_use)| fluid_use.role.contributes_constraints()); + let mut post_except_states = vec![]; - if !needs_standalone_statement { - for (candidate_def, fluid_use) in classified { - self.fluid_candidates_by_use - .insert(fluid_use.use_expression, candidate_def); - self.fluid_uses_by_candidate - .entry(candidate_def) - .or_default() - .push(fluid_use); - } - return; - } + // Take all checkpoints recorded immediately before operations in the `try` suite + // that may raise. Keep the context itself on the stack so that terminal statements + // in `except` and `else` suites can still be recorded as entries to the associated + // `finally` suite. + let try_block_snapshots = self.exception_context_stack_manager.end_try_suite(); - let standalone_statement = self.add_standalone_statement(stmt); + if !handlers.is_empty() { + // Save the state immediately *after* visiting the `try` block + // but *before* we prepare for visiting the `except` block(s). + // + // We will revert to this state prior to visiting the `else` block, + // as there necessarily must have been 0 `except` blocks executed + // if we hit the `else` block. + let post_try_block_state = self.flow_snapshot(); - // The body of a lambda expression needs access to the `Callable` type - // context the lambda is being inferred with, and so any statement - // containing a lambda must be inferable as a standalone statement - // to avoid large scope-level cycles. - self.enclosing_lambda_statements.extend( - current_statement - .lambda_expressions - .into_iter() - .map(|lambda| (lambda.into(), standalone_statement)), - ); + // Prepare for visiting the `except` block(s). If the `try` suite contained no + // exception checkpoints, its handlers are unreachable. + let mut try_block_snapshots = try_block_snapshots.into_iter(); + if let Some(first_snapshot) = try_block_snapshots.next() { + self.flow_restore(first_snapshot); + for snapshot in try_block_snapshots { + self.flow_merge(snapshot); + } + } else { + self.flow_restore(post_try_block_state.clone()); + self.mark_unreachable(); + } - // The inferred specialization of a fluid candidate depends on uses of - // the candidate in its containing scope, and so each constraining use must be - // part of a standalone inferable statement to avoid large scope-level cycles. - for (candidate_def, mut fluid_use) in classified { - if fluid_use.role.contributes_constraints() { - fluid_use.statement = Some(standalone_statement); - } + // basedpython: an exception leaves the `try` block from inside + // it, at a point where the block had not unbound its own + // declarations yet. A handler is a sibling block, so those names + // are out of scope in it either way. + self.unbind_block_declarations(&try_block_declarations); - self.fluid_candidates_by_use - .insert(fluid_use.use_expression, candidate_def); - self.fluid_uses_by_candidate - .entry(candidate_def) - .or_default() - .push(fluid_use); - } - } + let pre_except_state = self.flow_snapshot(); + let num_handlers = handlers.len(); - fn visit_keyword(&mut self, keyword: &'ast ast::Keyword) { - walk_keyword(self, keyword); + for (i, except_handler) in handlers.iter().enumerate() { + let ast::ExceptHandler::ExceptHandler(except_handler) = except_handler; + let ast::ExceptHandlerExceptHandler { + name: symbol_name, + type_: handled_exceptions, + body: handler_body, + range: _, + node_index: _, + } = except_handler; - if keyword.arg.is_some() { - return; - } + if let Some(handled_exceptions) = handled_exceptions { + self.visit_expr(handled_exceptions); + } - // Record a use of all members of `x` for a splatted keyword argument `**x`. - let current_scope = self.current_scope(); - let member_places = PlaceExpr::try_from_expr(&keyword.value) - .and_then(|value_place_expr| { - self.current_place_table() - .place_id((&value_place_expr).into()) - }) - .map(|value_place_id| { - let place_table = &self.place_tables[current_scope]; - place_table - .associated_place_ids(value_place_id) - .iter() - .filter(move |key_member_id| { - let key_member_expr = place_table.member(**key_member_id).expression(); + // If `handled_exceptions` above was `None`, it's something like `except as e:`, + // which is invalid syntax. However, it's still pretty obvious here that the user + // *wanted* `e` to be bound, so we should still create a definition here nonetheless. + let symbol = if let Some(symbol_name) = symbol_name { + let symbol = self.add_symbol(symbol_name.id.clone()); - // Only include top-level keys. - let Some(key_parent) = key_member_expr.as_ref().parent() else { - return true; - }; - match place_table.place(value_place_id) { - PlaceExprRef::Symbol(_) => false, - PlaceExprRef::Member(value_member) => { - key_parent == value_member.expression() - } + self.add_definition( + symbol.into(), + DefinitionNodeRef::ExceptHandler(ExceptHandlerDefinitionNodeRef { + handler: except_handler, + is_star: *is_star, + }), + ); + Some(symbol) + } else { + None + }; + + self.visit_block_body(handler_body); + // The caught exception is cleared at the end of the except clause + if let Some(symbol) = symbol { + self.delete_binding(symbol.into()); } - }) - .map(|key_member_id| ScopedPlaceId::from(*key_member_id)) - }); + // Each `except` block is mutually exclusive with all other `except` blocks. + post_except_states.push(self.flow_snapshot()); - let use_id = self.ast_ids[current_scope].record_use(keyword); - self.use_def_maps[current_scope] - .record_multi_use(member_places.into_iter().flatten(), use_id); - } + // It's unnecessary to do the `self.flow_restore()` call for the final except handler, + // as we'll immediately call `self.flow_restore()` to a different state + // as soon as this loop over the handlers terminates. + if i < (num_handlers - 1) { + self.flow_restore(pre_except_state.clone()); + } + } - fn visit_expr(&mut self, expr: &'ast ast::Expr) { - self.with_semantic_checker(|semantic, context| semantic.visit_expr(expr, context)); + // If we get to the `else` block, we know that 0 of the `except` blocks can have been executed, + // and the entire `try` block must have been executed: + self.flow_restore(post_try_block_state); + } - self.scopes_by_expression - .record_expression(expr, self.current_scope()); + self.visit_block_body(orelse); - match expr { - ast::Expr::Name(ast::ExprName { ctx, .. }) - | ast::Expr::Attribute(ast::ExprAttribute { ctx, .. }) - | ast::Expr::Subscript(ast::ExprSubscript { ctx, .. }) => { - // Record place effects after walking the expression. For names, this is - // equivalent because `walk_expr` is a no-op; for attribute/subscript places, - // child evaluation can introduce bindings (for example via walrus operators), - // and those bindings need to exist before we register parent/member associations. - let mut deferred_effects = None; - if let Some(mut place_expr) = PlaceExpr::try_from_expr(expr) { - if let Some(method_scope_id) = self.is_method_or_eagerly_executed_in_method() - && let PlaceExpr::Member(member) = &mut place_expr - && member.is_instance_attribute_candidate() - && let Some(attribute) = expr.as_attribute_expr() - { - // We specifically mark direct attribute assignments to the first - // parameter of a method, i.e. typically `self` or `cls`. - // However, we must check that the symbol hasn't been shadowed by an - // intermediate scope (e.g., a comprehension variable: `for self in [...]`) - // and that the AST base is still the original name rather than a - // rebinding expression such as `(self := other).x`. - let accessed_object_refers_to_first_parameter = - self.current_first_parameter_name.is_some_and(|first| { - attribute - .value - .as_name_expr() - .is_some_and(|name| name.id == first) - && !self.is_symbol_bound_in_intermediate_eager_scopes( - first, - method_scope_id, - ) - }); + for post_except_state in post_except_states { + self.flow_merge(post_except_state); + } - if accessed_object_refers_to_first_parameter { - member.mark_instance_attribute(); + let normal_pre_finally_state = self.flow_snapshot(); + let ( + terminal_finally_entry_snapshots, + has_escaping_exception, + has_deferred_terminal_context_manager_exit, + ) = self + .exception_context_stack_manager + .pop_try_context() + .into_finally_entry_state(); + // TODO: there's lots of complexity here that isn't yet handled by our model. + // In order to accurately model the semantics of `finally` suites, we in fact need to visit + // the suite twice: once under the (current) assumption that either the `try + else` suite + // ran to completion or exactly one `except` branch ran to completion, and then again under + // the assumption that potentially none of the branches ran to completion and we in fact + // jumped from a `try`, `else` or `except` branch straight into the `finally` branch. + // This requires rethinking some fundamental assumptions semantic indexing makes. + // For more details, see: + // - https://astral-sh.notion.site/Exception-handler-control-flow-11348797e1ca80bb8ce1e9aedbbe439d + // - https://github.com/astral-sh/ruff/pull/13633#discussion_r1788626702 + if normal_pre_finally_state.is_always_unreachable() + && !terminal_finally_entry_snapshots.is_empty() + { + let mut snapshots = terminal_finally_entry_snapshots.into_iter(); + let first_snapshot = snapshots.next().expect("checked non-empty snapshots"); + self.flow_restore(first_snapshot); + for snapshot in snapshots { + self.flow_merge(snapshot); + } + self.visit_block_body(finalbody); + if !self.flow_snapshot().is_always_unreachable() { + if !finalbody.is_empty() && has_escaping_exception { + self.record_exception_checkpoint(); } + self.record_terminal_finally_entry(); } + self.mark_unreachable(); + } else { + let mut post_finally_terminal_predicate = None; + let mut terminal_snapshots = terminal_finally_entry_snapshots.into_iter(); + if has_deferred_terminal_context_manager_exit + && let Some(snapshot) = terminal_snapshots.next() + { + let continuation = self.current_use_def_map().reachability; + self.current_reachability_constraints_mut() + .mark_used(continuation); + let predicate_id = + self.add_predicate(PredicateOrLiteral::Predicate(Predicate { + node: PredicateNode::FinallyNormalPathImpossible { + scope: self.current_scope_id(), + continuation, + }, + is_positive: true, + })); - let (is_use, is_definition) = match (ctx, self.current_assignment()) { - (ast::ExprContext::Store, Some(CurrentAssignment::AugAssign(_))) => { - // For augmented assignment, the target expression is also used. - (true, true) + self.flow_restore(snapshot); + for snapshot in terminal_snapshots { + self.flow_merge(snapshot); } - (ast::ExprContext::Load, _) => (true, false), - (ast::ExprContext::Store, _) => (false, true), - (ast::ExprContext::Del, _) => (true, true), - (ast::ExprContext::Invalid, _) => (false, false), - }; - deferred_effects = Some((place_expr, is_use, is_definition)); - } - - walk_expr(self, expr); - - if let Some((place_expr, is_use, is_definition)) = deferred_effects { - let place_id = self.add_place(place_expr); - if is_use { - self.record_place_use(place_id, expr); + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); + let narrowing_constraint = self + .current_use_def_map_mut() + .narrowing_constraints + .add_atom(predicate_id); + self.current_use_def_map_mut() + .record_non_terminal_call_constraints( + reachability_constraint, + narrowing_constraint, + ); - // Keep track of any uses of fluid specialization candidates. - if let Some(candidate_def) = self.fluid_candidate_binding(expr) { - let loops: Box<[TextRange]> = self.loop_ranges.as_slice().into(); - if let Some(current_statement) = self.current_statements.last_mut() { - current_statement.fluid_uses.push(( - candidate_def, - expr.into(), - expr.range(), - loops, - )); - } + if finalbody.is_empty() { + let terminal_snapshot = self.flow_snapshot(); + self.flow_restore(normal_pre_finally_state); + self.exception_context_stack_manager + .propagate_deferred_terminal_context_manager_exit( + terminal_snapshot, + ); + } else { + self.flow_merge(normal_pre_finally_state); + post_finally_terminal_predicate = Some(predicate_id); } } - - if is_definition { - self.record_place_definition(place_id, expr); + // Mixed normal and terminal entry states are still handled by the normal path + // only. See the corresponding TODO tests in `terminal_statements.md`. + self.visit_block_body(finalbody); + if !finalbody.is_empty() + && has_escaping_exception + && self.current_use_def_map().reachability + != ScopedReachabilityConstraintId::ALWAYS_FALSE + { + self.record_exception_checkpoint(); } - if let Some(unpack_position) = self - .current_assignment_mut() - .and_then(CurrentAssignment::unpack_position_mut) + if let Some(predicate_id) = post_finally_terminal_predicate + && self.current_use_def_map().reachability + != ScopedReachabilityConstraintId::ALWAYS_FALSE { - *unpack_position = UnpackPosition::Other; + let post_finally_state = self.flow_snapshot(); + let terminal_reachability = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); + let terminal_narrowing = self + .current_use_def_map_mut() + .narrowing_constraints + .add_atom(predicate_id); + self.current_use_def_map_mut() + .record_non_terminal_call_constraints( + terminal_reachability, + terminal_narrowing, + ); + let terminal_snapshot = self.flow_snapshot(); + self.flow_restore(post_finally_state); + self.exception_context_stack_manager + .propagate_deferred_terminal_context_manager_exit(terminal_snapshot); + + let normal_reachability = self + .current_reachability_constraints_mut() + .add_not_constraint(terminal_reachability); + let normal_narrowing = self + .current_use_def_map_mut() + .narrowing_constraints + .add_negated_atom(predicate_id); + self.current_use_def_map_mut() + .record_non_terminal_call_constraints( + normal_reachability, + normal_narrowing, + ); } } + self.in_try_statement = was_in_try_statement; } - ast::Expr::Named(node) => { - // basedpython: anonymous named tuples and Parameters specs use - // `Expr::Named` to represent `name: type` field labels. these - // aren't walrus assignments — the inner Name has - // `ExprContext::Invalid` to suppress place-effects — so we - // skip the assignment scope entirely. without this, ty's - // scope inference would call `expect_single_definition` on - // the named expr and panic - if matches!(node.target.as_ref(), ast::Expr::Name(n) if matches!(n.ctx, ast::ExprContext::Invalid)) + + ast::Stmt::Raise(_) => { + walk_stmt(self, stmt); + self.record_exception_checkpoint(); + self.record_terminal_finally_entry(); + // Everything in the current block after a terminal statement is unreachable. + self.mark_unreachable(); + } + + ast::Stmt::Return(_) => { + let recovers_from_body = self.enclosing_function_wrote_down_no_return_type(); + if let ast::Stmt::Return(ast::StmtReturn { + value: Some(value), .. + }) = stmt + && recovers_from_body { - self.visit_expr(&node.value); - return; + // basedpython: a returned expression says more than its own type does — + // `return a is int` tells every caller what a truthy result means about the + // argument. Reading that is the narrowing machinery's job, and it evaluates + // a predicate over a standalone expression, so record one for it + self.add_standalone_expression(value); } - self.visit_expr(&node.value); + walk_stmt(self, stmt); + // and what narrowing established about the members of a returned place is part of + // what is handed back. Nothing between the walk of the value and here changes any + // binding, so this is still the state the `return` sees + if let ast::Stmt::Return(ast::StmtReturn { + value: Some(value), .. + }) = stmt + && recovers_from_body + { + self.record_returned_place_members(value); + } + self.record_terminal_finally_entry(); + // Everything in the current block after a terminal statement is unreachable. + self.mark_unreachable(); + } - // See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements - if node.target.is_name_expr() { - self.push_assignment(CurrentAssignment::Named(node)); - self.visit_expr(&node.target); - self.pop_assignment(); - } else { - self.visit_expr(&node.target); + ast::Stmt::Continue(_) | ast::Stmt::Break(_) => { + // the value is evaluated before control leaves the loop, so it is + // visited before the break's flow effect is recorded + if let ast::Stmt::Break(ast::StmtBreak { + value: Some(value), .. + }) = stmt + { + self.check_break_value(stmt, value); + self.visit_expr(value); + } + if self + .exception_context_stack_manager + .has_context_manager_exception_checkpoint() + { + self.record_ambiguous_reachability(); + } + self.unbind_blocks_left_by_jump(); + let snapshot = self.flow_snapshot(); + if let Some(current_loop) = self.current_loop_mut() { + if stmt.is_continue_stmt() { + current_loop.continue_states.push(snapshot); + } else { + current_loop.break_states.push(snapshot); + } + } + self.record_terminal_finally_entry(); + // Everything in the current block after a terminal statement is unreachable. + self.mark_unreachable(); + } + ast::Stmt::Global(ast::StmtGlobal { + range, + node_index: _, + names, + }) => { + for name in names { + self.scopes_by_expression + .record_expression(name, self.current_scope()); + let symbol_id = self.add_symbol(name.id.clone()); + let symbol = self.current_place_table().symbol(symbol_id); + // Check whether the variable has already been accessed in this scope. + if (symbol.is_bound() || symbol.is_declared() || symbol.is_used()) + && !symbol.is_parameter() + { + self.report_semantic_error(SemanticSyntaxError { + kind: SemanticSyntaxErrorKind::LoadBeforeGlobalDeclaration { + name: name.to_string(), + start: name.range.start(), + }, + range: name.range, + python_version: self.python_version(), + }); + } + // Check whether the variable has also been declared nonlocal. + if symbol.is_nonlocal() { + self.report_semantic_error(SemanticSyntaxError { + kind: SemanticSyntaxErrorKind::NonlocalAndGlobal(name.to_string()), + range: name.range, + python_version: self.python_version(), + }); + // Never mark a symbol both global and nonlocal, even in this error case. + continue; + } + // Check whether this is the module scope, where `global` has no effect. + let scope_id = self.current_scope(); + if scope_id.is_global() { + // It's important that we don't `mark_global` here, because we error on + // type annotations on places that are marked global, but it's actually + // legal to write `global x; x: int = 42` at the module level. + continue; + } + // Assuming none of the rules above are violated, repeated `global` + // declarations are allowed and ignored. + if symbol.is_global() { + continue; + } + self.current_place_table_mut() + .symbol_mut(symbol_id) + .mark_global(); + self.current_scope_info_mut() + .this_scope_global_or_nonlocal_declarations + .insert(name.id.clone(), *range); } + walk_stmt(self, stmt); } - ast::Expr::Lambda(lambda) => { - self.current_statement_mut() - .expect("every lambda expression is part of a statement") - .lambda_expressions - .push(lambda); - - if let Some(parameters) = &lambda.parameters { - // The default value of the parameters needs to be evaluated in the - // enclosing scope. - for default in parameters - .iter_non_variadic_params() - .filter_map(|param| param.default.as_deref()) + ast::Stmt::Nonlocal(ast::StmtNonlocal { + range, + node_index: _, + names, + }) => { + for name in names { + self.scopes_by_expression + .record_expression(name, self.current_scope()); + let symbol_id = self.add_symbol(name.id.clone()); + let symbol = self.current_place_table().symbol(symbol_id); + // Check whether the variable has already been accessed in this scope. + if (symbol.is_bound() || symbol.is_declared() || symbol.is_used()) + && !symbol.is_parameter() { - self.visit_expr(default); + self.report_semantic_error(SemanticSyntaxError { + kind: SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration { + name: name.to_string(), + start: name.range.start(), + }, + range: name.range, + python_version: self.python_version(), + }); } - self.visit_parameters(parameters); - } - // return type annotation evaluated in enclosing scope, matching function defs - if let Some(returns) = &lambda.returns { - self.visit_annotation(returns); - } - self.push_scope(NodeWithScopeRef::Lambda(lambda)); - - // Add symbols and definitions for the parameters to the lambda scope. - if let Some(parameters) = lambda.parameters.as_ref() { - self.declare_lambda_parameters(parameters, lambda); + // Check whether the variable has also been declared global. + if symbol.is_global() { + self.report_semantic_error(SemanticSyntaxError { + kind: SemanticSyntaxErrorKind::NonlocalAndGlobal(name.to_string()), + range: name.range, + python_version: self.python_version(), + }); + // Never mark a symbol both global and nonlocal, even in this error case. + continue; + } + // Check whether this is the module scope, where `nonlocal` isn't allowed. + let scope_id = self.current_scope(); + if scope_id.is_global() { + // The SemanticSyntaxChecker will report an error for this. + continue; + } + // Assuming none of the rules above are violated, repeated `nonlocal` + // declarations are allowed and ignored. + if symbol.is_nonlocal() { + continue; + } + self.current_place_table_mut() + .symbol_mut(symbol_id) + .mark_nonlocal(); + self.current_scope_info_mut() + .this_scope_global_or_nonlocal_declarations + .insert(name.id.clone(), *range); } - - self.visit_expr(lambda.body.as_ref()); - self.pop_scope(); + walk_stmt(self, stmt); } - ast::Expr::If(ast::ExprIf { - body, test, orelse, .. + ast::Stmt::Delete(ast::StmtDelete { + targets, + range: _, + node_index: _, }) => { - self.visit_expr(test); - let condition_flow_snapshot = self.flow_snapshot_for_condition(test); - let falsy = if let Some(snapshots) = condition_flow_snapshot.into_branches() { - self.flow_restore(snapshots.truthy); - snapshots.falsy - } else { - self.flow_snapshot() - }; - let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); - let reachability_constraint = self.record_reachability_constraint(predicate); - let in_type_checking_block = self.in_type_checking_block; - self.current_use_def_map_mut() - .record_range_reachability(body.range(), in_type_checking_block); - self.visit_expr(body); - let post_body = self.flow_snapshot(); - self.flow_restore(falsy); - - self.record_negated_narrowing_constraint(predicate, predicate_id); - self.record_negated_reachability_constraint(reachability_constraint); - let in_type_checking_block = self.in_type_checking_block; - self.current_use_def_map_mut() - .record_range_reachability(orelse.range(), in_type_checking_block); - self.visit_expr(orelse); - self.flow_merge(post_body); - } - ast::Expr::ListComp( - list_comprehension @ ast::ExprListComp { - elt, generators, .. - }, - ) => { - let scope = self.with_generators_scope( - NodeWithScopeRef::ListComprehension(list_comprehension), - generators, - |builder| builder.visit_expr(elt), - ); - if self.async_comprehensions.contains(&scope) { - self.mark_current_comprehension_async(); - } - } - ast::Expr::SetComp( - set_comprehension @ ast::ExprSetComp { - elt, generators, .. - }, - ) => { - let scope = self.with_generators_scope( - NodeWithScopeRef::SetComprehension(set_comprehension), - generators, - |builder| builder.visit_expr(elt), - ); - if self.async_comprehensions.contains(&scope) { - self.mark_current_comprehension_async(); - } - } - ast::Expr::Generator( - generator @ ast::ExprGenerator { - elt, generators, .. - }, - ) => { - self.with_generators_scope( - NodeWithScopeRef::GeneratorExpression(generator), - generators, - |builder| builder.visit_expr(elt), - ); - } - ast::Expr::DictComp( - dict_comprehension @ ast::ExprDictComp { - key, - value, - generators, - .. - }, - ) => { - let scope = self.with_generators_scope( - NodeWithScopeRef::DictComprehension(dict_comprehension), - generators, - |builder| { - if let Some(key) = key { - builder.visit_expr(key); + // We will check the target expressions and then delete them. + walk_stmt(self, stmt); + for target in targets { + if let Some(mut target) = PlaceExpr::try_from_expr(target) { + if let PlaceExpr::Symbol(symbol) = &mut target { + // `del x` behaves like an assignment in that it forces all references + // to `x` in the current scope (including *prior* references) to refer + // to the current scope's binding (unless `x` is declared `global` or + // `nonlocal`). For example, this is an UnboundLocalError at runtime: + // + // ```py + // x = 1 + // def foo(): + // print(x) # can't refer to global `x` + // if False: + // del x + // foo() + // ``` + symbol.mark_bound(); + symbol.mark_used(); } - builder.visit_expr(value); - }, - ); - if self.async_comprehensions.contains(&scope) { - self.mark_current_comprehension_async(); + + let place_id = self.add_place(target); + self.invalidate_narrowing_aliases_for(place_id); + self.delete_binding(place_id); + } } } - ast::Expr::BoolOp(ast::ExprBoolOp { - values, + ast::Stmt::Expr(ast::StmtExpr { + value, range: _, node_index: _, - op, }) => { - let mut snapshots = vec![]; - let mut reachability_constraints = vec![]; - let mut last_condition_flow_snapshots = None; + if self.in_module_scope() { + if let Some(expr) = dunder_all_extend_argument(value) { + self.add_standalone_expression(expr); + } + } - for (index, value) in values.iter().enumerate() { - for id in &reachability_constraints { - self.current_use_def_map_mut() - .record_reachability_constraint(*id); // TODO: nicer API + self.visit_expr(value); + + // basedpython ` cast ` / ` cast! ` as a bare + // statement narrows the value place to the target type for the rest of + // the scope, like an unconditional `assert isinstance(value, type)`. + // `cast?` is left out: it yields `None` rather than asserting anything. + // The synthetic `cast` callee is unresolved and never `NoReturn`, so the + // terminal call analysis below is skipped for it. + if let ast::Expr::Call(call) = value.as_ref() + && matches!( + call.cast_kind, + Some(ast::CastKind::Static | ast::CastKind::Checked) + ) + { + let predicate = self.build_predicate(value, ExpressionContext::Value); + self.record_narrowing_constraint(predicate); + return; + } + + // If the statement is a call (or an `await` wrapping a call), it could + // possibly be a call to a function marked with `NoReturn` (for example, + // `sys.exit()` or `await async_exit()`). In this case, we use a special + // kind of constraint to mark the following code as unreachable. + // + // Ideally, these constraints should be added for every call expression, even those in + // sub-expressions. But doing so makes the number of such constraints so high that + // it significantly degrades performance. We thus cut scope here and add these + // constraints only at statement-level function calls, like `sys.exit()`, and not + // within sub-expressions like `3 + sys.exit()` etc. + let call_info = match value.as_ref() { + ast::Expr::Call(ast::ExprCall { func, .. }) => { + Some((func.as_ref(), value.as_ref(), false)) } + ast::Expr::Await(ast::ExprAwait { value: inner, .. }) => match inner.as_ref() { + ast::Expr::Call(ast::ExprCall { func, .. }) => { + Some((func.as_ref(), value.as_ref(), true)) + } + _ => None, + }, + _ => None, + }; - let in_type_checking_block = self.in_type_checking_block; - self.current_use_def_map_mut() - .record_range_reachability(value.range(), in_type_checking_block); - self.visit_expr(value); + if let Some((func, expr, is_await)) = call_info { + // Avoid creating reachability nodes for calls on fluid specialization + // candidates. Without this short-circuit, performing reachability analysis + // can lead to quadratic blowup of cycle dependencies during full-scope + // fluid specialization inference, as Salsa flattens the dependencies of all + // cycle participants, and the reachability analysis of a given use of the + // candidate may create dependencies on all previous uses, leading to + // significant performance regressions. + // + // Note that built-in collection types do not have methods that explicitly + // return `Never`, so this rarely has a meaningful semantic impact. + // + // basedpython: the fluid short-circuit is about reachability only. An + // assertion guard called on such a receiver (`a = A(); a.f()`) still has + // to narrow, so its predicate is recorded either way. + let is_terminal_call_candidate = func + .as_attribute_expr() + .and_then(|attribute| self.fluid_candidate_binding(&attribute.value)) + .is_none(); + let is_guard_call_candidate = self.source_type.is_basedpython(); - // Only non-final values can short-circuit this boolean operation. The final - // value can still have its own outcome-specific flow if it is nested. - if index < values.len() - 1 { - let condition_flow_snapshots = self.take_condition_flow_snapshots(value); - let predicate = self.build_predicate(value); - let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); - let predicate_id = match op { - ast::BoolOp::And => self.add_predicate(predicate), - ast::BoolOp::Or => self.add_negated_predicate(predicate), - }; - let reachability_constraint = self - .current_reachability_constraints_mut() - .add_atom(predicate_id); + if !self.source_type.is_stub() + && (is_terminal_call_candidate || is_guard_call_candidate) + { + let callable = + self.add_standalone_expression_impl(func, ExpressionKind::Callee, None); + let call_expr = self.add_standalone_expression(expr); - let continuation = - if let Some(condition_flow_snapshots) = condition_flow_snapshots { - let (short_circuit, continuation) = condition_flow_snapshots - .into_short_circuit_and_continuation(*op); - self.flow_restore(short_circuit); - continuation - } else { - self.flow_snapshot() + if is_terminal_call_candidate { + let predicate = Predicate { + node: PredicateNode::IsNonTerminalCall(CallableAndCallExpr { + callable, + call_expr, + is_await, + }), + is_positive: true, }; - // We first model the short-circuiting behavior. We take the short-circuit - // path here if all of the previous short-circuit paths were not taken, so - // we record all previously existing reachability constraints, and negate the - // one for the current expression. + let predicate_id = + self.add_predicate(PredicateOrLiteral::Predicate(predicate)); + let narrowing_constraint = self + .current_use_def_map_mut() + .narrowing_constraints + .add_atom(predicate_id); + + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); + self.current_use_def_map_mut() + .record_non_terminal_call_constraints( + reachability_constraint, + narrowing_constraint, + ); + } - self.record_negated_reachability_constraint(reachability_constraint); - snapshots.push(self.flow_snapshot()); - - // Then we model the non-short-circuiting behavior. Here, we need to delay - // the application of the reachability constraint until after the expression - // has been evaluated, so we only push it onto the stack here. - self.flow_restore(continuation); - self.record_narrowing_constraint_id_for_places( - predicate_id, - &possibly_narrowed, - ); - reachability_constraints.push(reachability_constraint); - } else { - last_condition_flow_snapshots = self.take_condition_flow_snapshots(value); + // basedpython: the same call may be a call to an assertion guard + // (`def f(x) -> asserts x`), which narrows once it returns — that is, + // for the rest of this flow rather than inside a branch + if is_guard_call_candidate { + // record the call itself, which is what a checker sees; `expr` + // is the `await` for an awaited call + if let Some(call) = asserted_call(expr) { + self.basedpython_statement_calls + .insert(ExpressionNodeKey::from(call)); + } + self.record_narrowing_constraint(PredicateOrLiteral::Predicate( + Predicate { + node: PredicateNode::AssertsCall(CallableAndCallExpr { + callable, + call_expr, + is_await, + }), + is_positive: true, + }, + )); + } } } + } + _ => { + walk_stmt(self, stmt); + } + } + } +} - let has_specialized_last = last_condition_flow_snapshots.is_some(); - let (last_short_circuit, no_short_circuit) = - if let Some(condition_flow_snapshots) = last_condition_flow_snapshots { - let (short_circuit, no_short_circuit) = - condition_flow_snapshots.into_short_circuit_and_continuation(*op); - (Some(short_circuit), Some(no_short_circuit)) - } else { - ( - None, - any_over_expr(expr, &ast::Expr::is_named_expr) - .then(|| self.flow_snapshot()), - ) - }; +impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { + fn visit_stmt(&mut self, stmt: &'ast ast::Stmt) { + let is_loop = matches!(stmt, ast::Stmt::For(_) | ast::Stmt::While(_)); + if is_loop { + self.loop_ranges.push(stmt.range()); + } + self.push_statement(CurrentStatement::default()); + self.visit_stmt_impl(stmt); + let current_statement = self.pop_statement(); + if is_loop { + self.loop_ranges.pop(); + } - if let Some(last_short_circuit) = last_short_circuit { - self.flow_restore(last_short_circuit); - } + if current_statement.lambda_expressions.is_empty() + && current_statement.fluid_uses.is_empty() + { + return; + } - for snapshot in snapshots { - self.flow_merge(snapshot); + // Classify how each fluid-candidate use in this statement interacts with the + // candidate's specialization. Constraints can only be read back from the + // inference of simple (non-compound) statements, so constraint-bearing roles + // inside compound statement headers are downgraded. + let classified: Vec<(Definition<'_>, FluidUse<'_>)> = current_statement + .fluid_uses + .into_iter() + .map(|(candidate_def, use_expression, range, loops)| { + let (mut role, discarded_call_result) = classify_fluid_use(stmt, use_expression); + if role.contributes_constraints() && !is_simple_statement(stmt) { + role = match role { + // A method call in a compound statement header (e.g. `if a.pop():`) + // cannot be read back for constraints; treat it as an opaque use. + FluidUseRole::MethodReceiver | FluidUseRole::SubscriptStore => { + FluidUseRole::Escape + } + FluidUseRole::TypeContextual => FluidUseRole::Escape, + role => role, + }; } + ( + candidate_def, + FluidUse { + use_expression, + range, + role, + discarded_call_result, + statement_range: stmt.range(), + loops, + statement: None, + }, + ) + }) + .collect(); + + let needs_standalone_statement = !current_statement.lambda_expressions.is_empty() + || classified + .iter() + .any(|(_, fluid_use)| fluid_use.role.contributes_constraints()); - if let Some(no_short_circuit) = no_short_circuit { - let bool_op_key = ExpressionNodeKey::from(expr); - let maybe_short_circuit = self.flow_snapshot(); + if !needs_standalone_statement { + for (candidate_def, fluid_use) in classified { + self.fluid_candidates_by_use + .insert(fluid_use.use_expression, candidate_def); + self.fluid_uses_by_candidate + .entry(candidate_def) + .or_default() + .push(fluid_use); + } + return; + } - if has_specialized_last { - // Restore the merged post-expression flow after constructing the two - // outcome-specific snapshots. - self.flow_merge(no_short_circuit.clone()); - } + let standalone_statement = self.add_standalone_statement(stmt); - let (truthy, falsy) = match op { - ast::BoolOp::And => (no_short_circuit, maybe_short_circuit), - ast::BoolOp::Or => (maybe_short_circuit, no_short_circuit), - }; + // The body of a lambda expression needs access to the `Callable` type + // context the lambda is being inferred with, and so any statement + // containing a lambda must be inferable as a standalone statement + // to avoid large scope-level cycles. + self.enclosing_lambda_statements.extend( + current_statement + .lambda_expressions + .into_iter() + .map(|lambda| (lambda.into(), standalone_statement)), + ); - self.condition_flow_snapshots_by_node - .insert(bool_op_key, ConditionFlowSnapshots { truthy, falsy }); - } - } - // basedpython: `a ?? b` evaluates `b` only when `a` is `None`, so `b` - // is a branch — a binding it makes is only possibly bound afterwards, - // and a `raise` or `return` in it does not end the enclosing flow - ast::Expr::BinOp(ast::ExprBinOp { - left, - op: ast::Operator::Coalesce, - right, - .. - }) => self.visit_coalesce_expression(left, right), - ast::Expr::StringLiteral(_) => { - walk_expr(self, expr); - } - ast::Expr::Yield(_) | ast::Expr::YieldFrom(_) => { - let scope = self.current_scope(); - if self.scopes[scope].kind() == ScopeKind::Function { - self.generator_functions.insert(scope); - } - walk_expr(self, expr); - } - ast::Expr::Await(_) => { - self.mark_current_comprehension_async(); - walk_expr(self, expr); - } - // basedpython: a statement expression's wrapped statement is visited - // as an ordinary statement, so everything it binds and narrows is - // recorded in the enclosing scope. Its *value* is modelled as a - // synthetic place written at each of the statement's value positions - // and read at the expression itself, which gives exhaustiveness and - // the union of branch types from the existing flow analysis. - ast::Expr::Statement(statement) => self.visit_statement_expression(expr, statement), - _ => { - walk_expr(self, expr); + // The inferred specialization of a fluid candidate depends on uses of + // the candidate in its containing scope, and so each constraining use must be + // part of a standalone inferable statement to avoid large scope-level cycles. + for (candidate_def, mut fluid_use) in classified { + if fluid_use.role.contributes_constraints() { + fluid_use.statement = Some(standalone_statement); } + + self.fluid_candidates_by_use + .insert(fluid_use.use_expression, candidate_def); + self.fluid_uses_by_candidate + .entry(candidate_def) + .or_default() + .push(fluid_use); } + } - // basedpython: this expression may produce the value of the statement - // expression currently being visited - if let Some(current) = self.current_statement_expressions.last() - && current.values.contains(&ExpressionNodeKey::from(expr)) - { - self.record_statement_expression_value(expr, current.place); + fn visit_keyword(&mut self, keyword: &'ast ast::Keyword) { + walk_keyword(self, keyword); + + if keyword.arg.is_some() { + return; } + + // Record a use of all members of `x` for a splatted keyword argument `**x`. + let current_scope = self.current_scope(); + let member_places = PlaceExpr::try_from_expr(&keyword.value) + .and_then(|value_place_expr| { + self.current_place_table() + .place_id((&value_place_expr).into()) + }) + .map(|value_place_id| { + let place_table = &self.place_tables[current_scope]; + place_table + .associated_place_ids(value_place_id) + .iter() + .filter(move |key_member_id| { + let key_member_expr = place_table.member(**key_member_id).expression(); + + // Only include top-level keys. + let Some(key_parent) = key_member_expr.as_ref().parent() else { + return true; + }; + match place_table.place(value_place_id) { + PlaceExprRef::Symbol(_) => false, + PlaceExprRef::Member(value_member) => { + key_parent == value_member.expression() + } + } + }) + .map(|key_member_id| ScopedPlaceId::from(*key_member_id)) + }); + + let use_id = self.ast_ids[current_scope].record_use(keyword); + self.use_def_maps[current_scope] + .record_multi_use(member_places.into_iter().flatten(), use_id); + } + + fn visit_expr(&mut self, expr: &'ast ast::Expr) { + // Generic AST walking evaluates child expressions as values. Short-circuit syntax + // propagates condition context explicitly through `visit_expr_with_context`. + self.visit_expr_with_context(expr, ExpressionContext::Value); } fn visit_parameters(&mut self, parameters: &'ast ast::Parameters) { @@ -6539,7 +7162,7 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { | ScopeKind::TypeParams => {} } - if self.in_try { + if self.in_try_statement { return Some(LazyImportContext::TryExceptBlocks); } @@ -6711,13 +7334,24 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { } } +/// A simple-name annotated assignment with an RHS whose declaration is already recorded. +/// Created only by `begin_annotated_assignment`; finishing it records the value binding. +#[derive(Copy, Clone, Debug, PartialEq)] +struct PendingAnnotatedAssignment<'db> { + definition: Definition<'db>, +} + #[derive(Copy, Clone, Debug, PartialEq)] enum CurrentAssignment<'ast, 'db> { Assign { node: &'ast ast::StmtAssign, unpack: Option>, + owner: BindingsOwner, + }, + AnnAssign { + node: &'ast ast::StmtAnnAssign, + pending: Option>, }, - AnnAssign(&'ast ast::StmtAnnAssign), AugAssign(&'ast ast::StmtAugAssign), For { node: &'ast ast::StmtFor, @@ -6742,7 +7376,9 @@ impl CurrentAssignment<'_, '_> { Self::For { unpack, .. } | Self::WithItem { unpack, .. } | Self::Comprehension { unpack, .. } => unpack.as_mut().map(|(position, _)| position), - Self::Assign { .. } | Self::AnnAssign(_) | Self::AugAssign(_) | Self::Named(_) => None, + Self::Assign { .. } | Self::AnnAssign { .. } | Self::AugAssign(_) | Self::Named(_) => { + None + } } } } @@ -7108,7 +7744,11 @@ impl<'ast> Unpackable<'ast> { ) -> CurrentAssignment<'ast, 'db> { let positioned = unpack.map(|unpack| (UnpackPosition::First, unpack)); match self { - Unpackable::Assign(stmt) => CurrentAssignment::Assign { node: stmt, unpack }, + Unpackable::Assign(stmt) => CurrentAssignment::Assign { + node: stmt, + unpack, + owner: BindingsOwner::Statement, + }, Unpackable::For(stmt) => CurrentAssignment::For { node: stmt, unpack: positioned, @@ -7474,7 +8114,7 @@ fn is_if_not_type_checking(expr: &ast::Expr) -> bool { /// This is a purely syntactic over-approximation: whether the assigned value actually /// is a generic instance with an inferred specialization is determined during type /// inference. -pub(crate) fn is_fluid_specialization_candidate(expr: &ast::Expr) -> bool { +fn is_fluid_specialization_candidate(expr: &ast::Expr) -> bool { match expr { ast::Expr::List(_) | ast::Expr::Set(_) | ast::Expr::Dict(_) => true, ast::Expr::Call(call) => matches!( diff --git a/crates/ty_python_core/src/builder/except_handlers.rs b/crates/ty_python_core/src/builder/except_handlers.rs index 18d15538e2..9f08c09383 100644 --- a/crates/ty_python_core/src/builder/except_handlers.rs +++ b/crates/ty_python_core/src/builder/except_handlers.rs @@ -1,24 +1,61 @@ -use crate::use_def::FlowSnapshot; +use std::debug_assert_matches; + +use crate::reachability_constraints::ScopedReachabilityConstraintId; +use crate::use_def::{ExceptionCheckpointKey, FlowSnapshot, UseDefMapBuilder}; use super::SemanticIndexBuilder; -/// An abstraction over the fact that each scope should have its own [`TryNodeContextStack`] +/// Active exception handlers and the flow states from which they can be entered. +#[derive(Debug, Default)] +pub(super) enum ExceptionHandlers { + /// No handlers are active, including after their snapshots have been taken. + #[default] + None, + /// Handlers may catch the exception, but it can also propagate to an enclosing handler. + Propagating(Vec), + /// A bare handler catches every exception and stops outward propagation. + CatchAll(Vec), +} + +impl ExceptionHandlers { + pub(super) fn propagating() -> Self { + Self::Propagating(Vec::new()) + } + + pub(super) fn catch_all() -> Self { + Self::CatchAll(Vec::new()) + } + + fn is_active(&self) -> bool { + !matches!(self, Self::None) + } + + fn is_catch_all(&self) -> bool { + matches!(self, Self::CatchAll(_)) + } +} + +/// Maintains a separate [`ExceptionContextStack`] for each scope. #[derive(Debug, Default)] -pub(super) struct TryNodeContextStackManager(Vec); +pub(super) struct ExceptionContextStackManager { + stacks: Vec, + /// Number of `try` and `with` contexts still collecting exception checkpoints. + active_handler_count: usize, +} -impl TryNodeContextStackManager { - /// Push a new [`TryNodeContextStack`] onto the stack of stacks. +impl ExceptionContextStackManager { + /// Push a new [`ExceptionContextStack`] onto the stack of stacks. /// - /// Each [`TryNodeContextStack`] is only valid for a single scope + /// Each [`ExceptionContextStack`] is only valid for a single scope. pub(super) fn enter_nested_scope(&mut self) { - self.0.push(TryNodeContextStack::default()); + self.stacks.push(ExceptionContextStack::default()); } - /// Pop a new [`TryNodeContextStack`] off the stack of stacks. + /// Pop an [`ExceptionContextStack`] off the stack of stacks. /// - /// Each [`TryNodeContextStack`] is only valid for a single scope + /// Each [`ExceptionContextStack`] is only valid for a single scope. pub(super) fn exit_scope(&mut self) { - let popped_context = self.0.pop(); + let popped_context = self.stacks.pop(); debug_assert!( popped_context.is_some(), "exit_scope() should never be called on an empty stack \ @@ -26,114 +63,320 @@ impl TryNodeContextStackManager { ); } - /// Push a [`TryNodeContext`] onto the [`TryNodeContextStack`] - /// at the top of our stack of stacks - pub(super) fn push_context(&mut self) { - self.current_try_context_stack().push_context(); + /// Registers a `try` statement on the current scope's exception-context stack. + /// + /// Only suites with handlers collect exception checkpoints; a bare handler prevents those + /// exceptions from propagating to enclosing suites. + pub(super) fn push_try_context( + &mut self, + exception_handlers: ExceptionHandlers, + has_finally: bool, + ) { + self.active_handler_count += usize::from(exception_handlers.is_active()); + self.current_exception_context_stack() + .push_try_context(exception_handlers, has_finally); + } + + /// Registers a context manager after it enters but before its target is assigned. + pub(super) fn push_context_manager_context(&mut self) { + self.active_handler_count += 1; + self.current_exception_context_stack() + .push_context_manager_context(); + } + + /// Removes the innermost context manager and returns the exceptions it could suppress. + /// + /// Removing the context before its exit method runs prevents it from suppressing exceptions + /// raised by its own exit method. + pub(super) fn finish_context_manager_context(&mut self) -> Vec { + let snapshots = self.take_exception_snapshots(); + let context = self.current_exception_context_stack().pop_context(); + debug_assert_matches!(context.kind, ExceptionContextKind::With); + snapshots } - /// Pop a [`TryNodeContext`] off the [`TryNodeContextStack`] at the top of our stack of stacks. - pub(super) fn pop_context(&mut self) -> TryNodeContext { - self.current_try_context_stack().pop_context() + /// Removes the current `try` context after its handlers have been deactivated. + pub(super) fn pop_try_context(&mut self) -> ExceptionContext { + let context = self.current_exception_context_stack().pop_context(); + debug_assert_matches!(context.kind, ExceptionContextKind::Try { .. }); + debug_assert!(!context.exception_handlers.is_active()); + context } - /// Retrieve the [`TryNodeContext`] that is currently at the top of the stack, and take all + /// Retrieve the [`ExceptionContext`] at the top of the stack, and take all /// snapshots recorded while visiting the `try` suite. - pub(super) fn take_try_suite_snapshots(&mut self) -> Vec { - self.current_try_context_stack().take_try_suite_snapshots() + /// + /// Taking the snapshots deactivates the suite's handlers before their bodies are visited. + pub(super) fn end_try_suite(&mut self) -> Vec { + self.take_exception_snapshots() + } + + /// Records a checkpoint for every active `try` or `with` context that could handle an + /// exception raised at the current point in control flow. + /// + /// Crosses eager scopes, but stops at lazy scopes, unreachable flow, and bare handlers. + pub(super) fn record_exception_checkpoint(&mut self, builder: &mut SemanticIndexBuilder) { + debug_assert_eq!(self.stacks.len(), builder.scope_stack.len()); + + let mut has_intervening_finally = false; + for (scope_stack_index, exception_context_stack) in self.stacks.iter_mut().enumerate().rev() + { + let scope_id = builder.scope_stack[scope_stack_index].file_scope_id; + let use_def_map = &builder.use_def_maps[scope_id]; + + // Each scope has an independent flow state, so an enclosing scope can still be + // reachable while we analyze an unreachable nested scope. + if use_def_map.reachability == ScopedReachabilityConstraintId::ALWAYS_FALSE { + break; + } + + if !exception_context_stack + .record_exception_checkpoint(use_def_map, &mut has_intervening_finally) + { + break; + } + + if !builder.exception_checkpoint_crosses_scope_boundary(scope_id) { + break; + } + } } - /// Retrieve the stack that is at the top of our stack of stacks. - /// For each `try` block on that stack, push the snapshot onto the `try` block - pub(super) fn record_definition(&mut self, builder: &SemanticIndexBuilder) { - self.current_try_context_stack().record_definition(builder); + /// Returns whether an active `try` or `with` context can receive an exception from this scope. + /// + /// A context can remain on the stack for its `finally` suite after its handlers become inactive. + pub(super) fn has_active_exception_handler(&self, builder: &SemanticIndexBuilder) -> bool { + if self.active_handler_count == 0 { + return false; + } + + debug_assert_eq!(self.stacks.len(), builder.scope_stack.len()); + + for (scope_stack_index, exception_context_stack) in self.stacks.iter().enumerate().rev() { + if exception_context_stack.has_active_exception_handler() { + return true; + } + + let scope_id = builder.scope_stack[scope_stack_index].file_scope_id; + if !builder.exception_checkpoint_crosses_scope_boundary(scope_id) { + return false; + } + } + + false } - /// Retrieve the stack that is at the top of our stack of stacks. - /// Push the snapshot onto the innermost `try` block's terminal-entry snapshots for its - /// `finally` suite. + /// Returns whether an enclosing context manager has already seen an exception checkpoint. + pub(super) fn has_context_manager_exception_checkpoint(&self) -> bool { + self.stacks.last().is_some_and(|stack| { + stack.0.iter().any(|context| { + matches!(context.kind, ExceptionContextKind::With) + && context.last_checkpoint_key.is_some() + }) + }) + } + + /// Records that a context manager makes an apparently terminal control-flow path possibly + /// non-terminal because it may silence an earlier exception. Whether it actually suppresses + /// exceptions is determined during type inference. + pub(super) fn record_deferred_terminal_context_manager_exit(&mut self) { + if let Some(context) = self.current_exception_context_stack().innermost_try() { + context.has_deferred_terminal_context_manager_exit = true; + } + } + + /// Forwards a deferred terminal state to the nearest enclosing `try`. + pub(super) fn propagate_deferred_terminal_context_manager_exit( + &mut self, + terminal_snapshot: FlowSnapshot, + ) { + if let Some(context) = self.current_exception_context_stack().innermost_try() { + context.has_deferred_terminal_context_manager_exit = true; + context + .terminal_finally_entry_snapshots + .push(terminal_snapshot); + } + } + + /// Records a terminal entry for the nearest enclosing `try`, skipping `with` contexts. pub(super) fn record_terminal_finally_entry(&mut self, builder: &SemanticIndexBuilder) { - self.current_try_context_stack() + self.current_exception_context_stack() .record_terminal_finally_entry(builder); } - /// Retrieve the [`TryNodeContextStack`] that is relevant for the current scope. - fn current_try_context_stack(&mut self) -> &mut TryNodeContextStack { - self.0 + /// Takes the current context's snapshots and updates the number of active handlers. + fn take_exception_snapshots(&mut self) -> Vec { + if let Some(snapshots) = self + .current_exception_context_stack() + .take_exception_snapshots() + { + self.active_handler_count -= 1; + snapshots + } else { + Vec::new() + } + } + + /// Retrieve the [`ExceptionContextStack`] that is relevant for the current scope. + fn current_exception_context_stack(&mut self) -> &mut ExceptionContextStack { + self.stacks .last_mut() - .expect("There should always be at least one `TryBlockContexts` on the stack") + .expect("There should always be at least one `ExceptionContextStack` on the stack") } } -/// The contexts of nested `try`/`except` blocks for a single scope +/// The contexts of nested `try` and `with` statements for a single scope. #[derive(Debug, Default)] -struct TryNodeContextStack(Vec); +struct ExceptionContextStack(Vec); -impl TryNodeContextStack { - /// Push a new [`TryNodeContext`] for recording intermediate states - /// while visiting a [`ruff_python_ast::StmtTry`] node that has a `finally` branch. - fn push_context(&mut self) { - self.0.push(TryNodeContext::default()); +impl ExceptionContextStack { + /// Returns whether a `try` or `with` context is still collecting exception checkpoints. + fn has_active_exception_handler(&self) -> bool { + self.0 + .iter() + .any(|context| context.exception_handlers.is_active()) } - /// Pop a [`TryNodeContext`] off the stack. - fn pop_context(&mut self) -> TryNodeContext { + /// Registers a `try` statement and whether exceptions must first pass through cleanup. + fn push_try_context(&mut self, exception_handlers: ExceptionHandlers, has_finally: bool) { + self.0.push(ExceptionContext::new( + ExceptionContextKind::Try { has_finally }, + exception_handlers, + )); + } + + /// Registers a context manager that may receive exceptions from its body. + fn push_context_manager_context(&mut self) { + self.0.push(ExceptionContext::new( + ExceptionContextKind::With, + ExceptionHandlers::propagating(), + )); + } + + /// Pop an [`ExceptionContext`] off the stack. + fn pop_context(&mut self) -> ExceptionContext { self.0 .pop() - .expect("Cannot pop a `try` block off an empty `TryBlockContexts` stack") + .expect("Cannot pop an exception context off an empty `ExceptionContextStack`") } - /// Take all snapshots recorded while visiting the `try` suite. - fn take_try_suite_snapshots(&mut self) -> Vec { - std::mem::take( - &mut self - .0 - .last_mut() - .expect("Cannot take snapshots from an empty `TryBlockContexts` stack") - .try_suite_snapshots, - ) + /// Takes the innermost context's snapshots if it has active handlers, deactivating them. + fn take_exception_snapshots(&mut self) -> Option> { + let context = self + .0 + .last_mut() + .expect("Cannot take snapshots from an empty `ExceptionContextStack`"); + match std::mem::take(&mut context.exception_handlers) { + ExceptionHandlers::None => None, + ExceptionHandlers::Propagating(snapshots) | ExceptionHandlers::CatchAll(snapshots) => { + Some(snapshots) + } + } } - /// For each `try` block on the stack, push the snapshot onto the `try` block - fn record_definition(&mut self, builder: &SemanticIndexBuilder) { - for context in &mut self.0 { - context.record_definition(builder.flow_snapshot()); + /// Records a checkpoint for every active `try` or `with` context in this scope. + /// Returns whether the checkpoint should continue propagating to an enclosing scope. + /// + /// A bare handler consumes the exception, preventing any outer handler from seeing it. A + /// `finally` suite prevents enclosing context managers from receiving a checkpoint until its + /// cleanup has run, while preserving existing outer-`try` handler behavior. The snapshot is + /// constructed only if a handler has not already observed the current flow state. + fn record_exception_checkpoint( + &mut self, + use_def_map: &UseDefMapBuilder<'_>, + has_intervening_finally: &mut bool, + ) -> bool { + let checkpoint_key = use_def_map.exception_checkpoint_key(); + + for context in self.0.iter_mut().rev() { + if *has_intervening_finally && matches!(context.kind, ExceptionContextKind::With) { + continue; + } + + match &mut context.exception_handlers { + ExceptionHandlers::None => context.has_escaping_exception = true, + ExceptionHandlers::Propagating(snapshots) + | ExceptionHandlers::CatchAll(snapshots) => { + if context.last_checkpoint_key != Some(checkpoint_key) { + snapshots.push(use_def_map.snapshot()); + context.last_checkpoint_key = Some(checkpoint_key); + } + if context.exception_handlers.is_catch_all() { + return false; + } + context.has_escaping_exception = true; + } + } + + *has_intervening_finally |= matches!( + context.kind, + ExceptionContextKind::Try { has_finally: true } + ); } + + true } - /// Push the snapshot onto the innermost `try` block's terminal-entry snapshots for its - /// `finally` suite. + /// Records a terminal entry for the nearest `try` context, skipping intervening `with` contexts. fn record_terminal_finally_entry(&mut self, builder: &SemanticIndexBuilder) { - if let Some(context) = self.0.last_mut() { - context.record_terminal_finally_entry(builder.flow_snapshot()); + if let Some(context) = self.innermost_try() { + context + .terminal_finally_entry_snapshots + .push(builder.flow_snapshot()); } } + + /// Finds the nearest enclosing `try`, skipping context managers. + fn innermost_try(&mut self) -> Option<&mut ExceptionContext> { + self.0 + .iter_mut() + .rev() + .find(|context| matches!(context.kind, ExceptionContextKind::Try { .. })) + } +} + +/// Distinguishes `try` exception contexts from `with` exception contexts. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExceptionContextKind { + Try { has_finally: bool }, + With, } -/// Context for tracking definitions over the course of a single -/// [`ruff_python_ast::StmtTry`] node +/// Exception-entry states for one `try` or `with` statement. /// -/// It will likely be necessary to add more fields to this struct in the future -/// when we add more advanced handling of `finally` branches. -#[derive(Debug, Default)] -pub(super) struct TryNodeContext { - try_suite_snapshots: Vec, +/// Only `try` contexts also collect terminal entries for a `finally` suite. +#[derive(Debug)] +pub(super) struct ExceptionContext { + exception_handlers: ExceptionHandlers, + kind: ExceptionContextKind, + last_checkpoint_key: Option, + /// Whether an exception escaped this suite and must also propagate after its cleanup. + has_escaping_exception: bool, + /// Whether apparently terminal control flow in a nested context-manager body, such as a + /// `return` or `raise`, may become non-terminal if type inference determines that the context + /// manager suppresses exceptions. This flag belongs to the enclosing `try` context because it + /// affects control flow into its `finally` suite. + has_deferred_terminal_context_manager_exit: bool, terminal_finally_entry_snapshots: Vec, } -impl TryNodeContext { - pub(super) fn into_terminal_finally_entry_snapshots(self) -> Vec { - self.terminal_finally_entry_snapshots - } - - /// Take a record of what the internal state looked like after a definition - fn record_definition(&mut self, snapshot: FlowSnapshot) { - self.try_suite_snapshots.push(snapshot); +impl ExceptionContext { + fn new(kind: ExceptionContextKind, exception_handlers: ExceptionHandlers) -> Self { + Self { + exception_handlers, + kind, + last_checkpoint_key: None, + has_escaping_exception: false, + has_deferred_terminal_context_manager_exit: false, + terminal_finally_entry_snapshots: Vec::new(), + } } - /// Take a record of what the internal state looked like before a terminal control-flow - /// transfer that will pass through the `finally` suite. - fn record_terminal_finally_entry(&mut self, snapshot: FlowSnapshot) { - self.terminal_finally_entry_snapshots.push(snapshot); + pub(super) fn into_finally_entry_state(self) -> (Vec, bool, bool) { + ( + self.terminal_finally_entry_snapshots, + self.has_escaping_exception, + self.has_deferred_terminal_context_manager_exit, + ) } } diff --git a/crates/ty_python_core/src/db.rs b/crates/ty_python_core/src/db.rs index e7732dc6bc..ca7a49f369 100644 --- a/crates/ty_python_core/src/db.rs +++ b/crates/ty_python_core/src/db.rs @@ -39,7 +39,7 @@ pub trait TestProgramDb: Db { { #[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn program_inner(db: &dyn TestProgramDb) -> Program<'_> { - Program::from_settings(db, db.program_settings().clone()) + Program::from_settings(db, db.program_settings()) } program_inner(self) diff --git a/crates/ty_python_core/src/definition.rs b/crates/ty_python_core/src/definition.rs index 4f27a79b6b..60904b433e 100644 --- a/crates/ty_python_core/src/definition.rs +++ b/crates/ty_python_core/src/definition.rs @@ -554,6 +554,7 @@ pub(crate) struct AssignmentDefinitionNodeRef<'ast, 'db> { pub(crate) value: &'ast ast::Expr, pub(crate) target: &'ast ast::Expr, pub(crate) sole_target: bool, + pub(crate) owner: BindingsOwner, } #[derive(Copy, Clone, Debug)] @@ -728,17 +729,20 @@ impl<'db> DefinitionNodeRef<'_, 'db> { value, target, sole_target, + owner, }) => DefinitionKind::Assignment(AssignmentDefinitionKind { unpack, node: AstNodeRef::new(parsed, node), value: AstNodeRef::new(parsed, value), target: AstNodeRef::new(parsed, target), sole_target, + owner, }), DefinitionNodeRef::AnnotatedAssignment(AnnotatedAssignmentDefinitionNodeRef { node, }) => DefinitionKind::AnnotatedAssignment(AnnotatedAssignmentDefinitionKind { node: AstNodeRef::new(parsed, node), + has_value: node.value.is_some(), }), DefinitionNodeRef::AugmentedAssignment(augmented_assignment) => { DefinitionKind::AugmentedAssignment(AstNodeRef::new(parsed, augmented_assignment)) @@ -889,6 +893,7 @@ impl<'db> DefinitionNodeRef<'_, 'db> { unpack: _, target, sole_target: _, + owner: _, }) => DefinitionNodeKey(NodeKey::from_node(target)), Self::AnnotatedAssignment(ann_assign) => ann_assign.node.into(), Self::AugmentedAssignment(node) => node.into(), @@ -1074,7 +1079,7 @@ impl<'db> DefinitionKind<'db> { /// (several of them share one statement expression, and the node they are /// keyed on may already carry a definition of its own — a walrus in tail /// position, say). - pub const fn is_statement_expression_value(&self) -> bool { + pub(crate) const fn is_statement_expression_value(&self) -> bool { matches!(self, DefinitionKind::StatementExpressionValue(_)) } @@ -1226,11 +1231,11 @@ impl<'db> DefinitionKind<'db> { // assignments only in shape — the annotation is a marker for the // keyword prefix and states no type, so they bind without // declaring, exactly like the `a = 1` they lower to - if ann_assign.value(module).is_some() + if ann_assign.has_value() && is_untyped_declaration_marker(ann_assign.annotation(module)) { DefinitionCategory::Binding - } else if in_stub || ann_assign.value(module).is_some() { + } else if in_stub || ann_assign.has_value() { DefinitionCategory::DeclarationAndBinding } else { DefinitionCategory::Declaration @@ -1349,7 +1354,7 @@ impl<'db> MatchPatternDefinitionKind<'db> { /// basedpython: whether this is a bare `case A:` whose name type checking may /// yet resolve to an enum member of the subject, in which case the pattern is /// a value pattern and binds nothing. - pub fn is_case_name(&self) -> bool { + pub(crate) fn is_case_name(&self) -> bool { self.is_case_name } } @@ -1535,6 +1540,15 @@ impl ImportFromSubmoduleDefinitionKind { } } +/// The inference region that owns bindings created while evaluating an assignment's value. +#[derive(Clone, Copy, Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub enum BindingsOwner { + /// A simple-name assignment is represented by its definition. + Definition, + /// An assignment with multiple, unpacking, or non-name targets is represented by its statement. + Statement, +} + #[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct AssignmentDefinitionKind<'db> { unpack: Option>, @@ -1542,6 +1556,7 @@ pub struct AssignmentDefinitionKind<'db> { value: AstNodeRef, target: AstNodeRef, sole_target: bool, + owner: BindingsOwner, } impl<'db> AssignmentDefinitionKind<'db> { @@ -1571,11 +1586,16 @@ impl<'db> AssignmentDefinitionKind<'db> { pub fn is_sole_target(&self) -> bool { self.sole_target } + + pub fn owner(&self) -> BindingsOwner { + self.owner + } } #[derive(Clone, Debug, get_size2::GetSize)] pub struct AnnotatedAssignmentDefinitionKind { node: AstNodeRef, + has_value: bool, } impl AnnotatedAssignmentDefinitionKind { @@ -1587,6 +1607,11 @@ impl AnnotatedAssignmentDefinitionKind { self.node(module).value.as_deref() } + /// Returns whether this annotated assignment has a right-hand-side value. + pub const fn has_value(&self) -> bool { + self.has_value + } + pub fn annotation<'ast>(&self, module: &'ast ParsedModuleRef) -> &'ast ast::Expr { &self.node(module).annotation } diff --git a/crates/ty_python_core/src/expression.rs b/crates/ty_python_core/src/expression.rs index 93edd77421..c476402d17 100644 --- a/crates/ty_python_core/src/expression.rs +++ b/crates/ty_python_core/src/expression.rs @@ -7,13 +7,25 @@ use ruff_db::files::File; use ruff_python_ast as ast; use salsa; -/// Whether or not this expression should be inferred as a normal expression or -/// a type expression. For example, in `self.x: = `, the -/// `` is inferred as a type expression, while `` is inferred -/// as a normal expression. +/// The context used to infer an independently tracked expression. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum ExpressionKind { + /// An ordinary value expression, such as `1` in `self.x: int = 1`. Normal, + /// The callable part of a call, such as `list[T]` in `list[T]()`. + /// + /// Type variables used to specialize the callable must already be bound. A constructor call + /// cannot introduce type variable bindings as a generic alias definition can: + /// + /// ```python + /// from typing import TypeVar + /// + /// T = TypeVar("T") + /// Items = list[T] # Valid: defines a generic alias. + /// list[T]() # Error: no generic context binds T. + /// ``` + Callee, + /// An expression interpreted as a type, such as `int` in `self.x: int = 1`. TypeExpression, } @@ -59,7 +71,7 @@ pub struct Expression<'db> { #[returns(clone)] pub assigned_to: Option>, - /// Should this expression be inferred as a normal expression or a type expression? + /// The inference context for this expression. #[returns(copy)] pub kind: ExpressionKind, } diff --git a/crates/ty_python_core/src/lib.rs b/crates/ty_python_core/src/lib.rs index b70950a3e7..53597371f4 100644 --- a/crates/ty_python_core/src/lib.rs +++ b/crates/ty_python_core/src/lib.rs @@ -39,7 +39,7 @@ use symbol::ScopedSymbolId; pub use use_def::{ ApplicableConstraints, BindingWithConstraints, BindingWithConstraintsIterator, DeclarationWithConstraint, DeclarationsIterator, LiveBinding, LoopHeaderId, NarrowingEvaluator, - ScopedDefinitionId, UseDefMap, + PredicateNarrowingTargets, ScopedDefinitionId, UseDefMap, }; use use_def::{EnclosingSnapshotKey, ScopedEnclosingSnapshotId}; @@ -1075,6 +1075,27 @@ impl Truthiness { if condition { self.negate() } else { self } } + #[must_use] + pub fn and(self, other: Self) -> Self { + match self { + Truthiness::AlwaysTrue => other, + Truthiness::AlwaysFalse => self, + Truthiness::Ambiguous => match other { + Truthiness::AlwaysFalse => Truthiness::AlwaysFalse, + Truthiness::AlwaysTrue | Truthiness::Ambiguous => Truthiness::Ambiguous, + }, + } + } + + /// Like [`Truthiness::and`], but evaluates `other` only when `self` may be true. + #[must_use] + pub fn and_then(self, other: impl FnOnce() -> Self) -> Self { + match self { + Truthiness::AlwaysFalse => self, + Truthiness::AlwaysTrue | Truthiness::Ambiguous => self.and(other()), + } + } + #[must_use] pub fn or(self, other: Self) -> Self { match self { @@ -1172,6 +1193,8 @@ impl HasTrackedScope for ast::Identifier {} #[cfg(test)] mod tests { + use std::assert_matches; + use ruff_db::{ files::{File, system_path_to_file}, parsed::ParsedModuleRef, @@ -1179,6 +1202,7 @@ mod tests { use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange}; + use super::Truthiness::{AlwaysFalse, AlwaysTrue, Ambiguous}; use super::*; use crate::{ @@ -1238,6 +1262,35 @@ mod tests { .collect() } + #[test] + fn truthiness_and() { + for (left, right, expected) in [ + (AlwaysTrue, AlwaysTrue, AlwaysTrue), + (AlwaysTrue, AlwaysFalse, AlwaysFalse), + (AlwaysTrue, Ambiguous, Ambiguous), + (AlwaysFalse, AlwaysTrue, AlwaysFalse), + (AlwaysFalse, AlwaysFalse, AlwaysFalse), + (AlwaysFalse, Ambiguous, AlwaysFalse), + (Ambiguous, AlwaysTrue, Ambiguous), + (Ambiguous, AlwaysFalse, AlwaysFalse), + (Ambiguous, Ambiguous, Ambiguous), + ] { + assert_eq!(left.and(right), expected, "{left:?}.and({right:?})"); + + let mut calls = 0; + let lazy_result = left.and_then(|| { + calls += 1; + right + }); + assert_eq!(lazy_result, expected, "{left:?}.and_then(|| {right:?})"); + assert_eq!( + calls, + usize::from(left != AlwaysFalse), + "{left:?}.and_then call count" + ); + } + } + #[test] fn empty() { let TestCase { db, file } = test_case(""); @@ -1268,10 +1321,10 @@ mod tests { let declaration = use_def .first_public_declaration(global_table.symbol_id("x").expect("symbol to exist")) .unwrap(); - assert!(matches!( + assert_matches!( declaration.kind(&db), DefinitionKind::AnnotatedAssignment(_) - )); + ); } #[test] @@ -1285,7 +1338,7 @@ mod tests { let use_def = use_def_map(&db, scope); let binding = use_def.first_public_binding(foo).unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Import(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Import(_)); } #[test] @@ -1322,7 +1375,7 @@ mod tests { let binding = use_def .first_public_binding(global_table.symbol_id("foo").expect("symbol to exist")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::ImportFrom(_))); + assert_matches!(binding.kind(&db), DefinitionKind::ImportFrom(_)); } #[test] @@ -1342,7 +1395,7 @@ mod tests { let binding = use_def .first_public_binding(global_table.symbol_id("x").expect("symbol exists")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Assignment(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Assignment(_)); } #[test] @@ -1358,10 +1411,7 @@ mod tests { .first_public_binding(global_table.symbol_id("x").unwrap()) .unwrap(); - assert!(matches!( - binding.kind(&db), - DefinitionKind::AugmentedAssignment(_) - )); + assert_matches!(binding.kind(&db), DefinitionKind::AugmentedAssignment(_)); } #[test] @@ -1401,7 +1451,7 @@ y = 2 let binding = use_def .first_public_binding(class_table.symbol_id("x").expect("symbol exists")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Assignment(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Assignment(_)); } #[test] @@ -1440,7 +1490,7 @@ y = 2 let binding = use_def .first_public_binding(function_table.symbol_id("x").expect("symbol exists")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Assignment(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Assignment(_)); } #[test] @@ -1475,22 +1525,22 @@ def f(a: str, /, b: str, c: int = 1, *args, d: int = 2, **kwargs): let binding = use_def .first_public_binding(function_table.symbol_id(name).expect("symbol exists")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Parameter(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Parameter(_)); } let args_binding = use_def .first_public_binding(function_table.symbol_id("args").expect("symbol exists")) .unwrap(); - assert!(matches!( + assert_matches!( args_binding.kind(&db), DefinitionKind::Parameter(ParameterDefinitionNodeKind::VariadicPositionalParameter(_)) - )); + ); let kwargs_binding = use_def .first_public_binding(function_table.symbol_id("kwargs").expect("symbol exists")) .unwrap(); - assert!(matches!( + assert_matches!( kwargs_binding.kind(&db), DefinitionKind::Parameter(ParameterDefinitionNodeKind::VariadicKeywordParameter(_)) - )); + ); } #[test] @@ -1520,37 +1570,37 @@ def f(a: str, /, b: str, c: int = 1, *args, d: int = 2, **kwargs): let binding = use_def .first_public_binding(lambda_table.symbol_id(name).expect("symbol exists")) .unwrap(); - assert!(matches!( + assert_matches!( binding.kind(&db), DefinitionKind::LambdaParameter(LambdaParameterDefinitionNodeKind { index: _, lambda: _, parameter: ParameterDefinitionNodeKind::Parameter(_) }) - )); + ); } let args_binding = use_def .first_public_binding(lambda_table.symbol_id("args").expect("symbol exists")) .unwrap(); - assert!(matches!( + assert_matches!( args_binding.kind(&db), DefinitionKind::LambdaParameter(LambdaParameterDefinitionNodeKind { index: 3, lambda: _, parameter: ParameterDefinitionNodeKind::VariadicPositionalParameter(_) }) - )); + ); let kwargs_binding = use_def .first_public_binding(lambda_table.symbol_id("kwargs").expect("symbol exists")) .unwrap(); - assert!(matches!( + assert_matches!( kwargs_binding.kind(&db), DefinitionKind::LambdaParameter(LambdaParameterDefinitionNodeKind { index: 5, lambda: _, parameter: ParameterDefinitionNodeKind::VariadicKeywordParameter(_) }) - )); + ); } /// Test case to validate that the comprehension scope is correctly identified and that the target @@ -1597,10 +1647,7 @@ def f(a: str, /, b: str, c: int = 1, *args, d: int = 2, **kwargs): .expect("symbol exists"), ) .unwrap(); - assert!(matches!( - binding.kind(&db), - DefinitionKind::Comprehension(_) - )); + assert_matches!(binding.kind(&db), DefinitionKind::Comprehension(_)); } } @@ -1723,7 +1770,7 @@ with item1 as x, item2 as y: let binding = use_def .first_public_binding(global_table.symbol_id(name).expect("symbol exists")) .expect("Expected with item definition for {name}"); - assert!(matches!(binding.kind(&db), DefinitionKind::WithItem(_))); + assert_matches!(binding.kind(&db), DefinitionKind::WithItem(_)); } } @@ -1746,7 +1793,7 @@ with context() as (x, y): let binding = use_def .first_public_binding(global_table.symbol_id(name).expect("symbol exists")) .expect("Expected with item definition for {name}"); - assert!(matches!(binding.kind(&db), DefinitionKind::WithItem(_))); + assert_matches!(binding.kind(&db), DefinitionKind::WithItem(_)); } } @@ -1800,7 +1847,7 @@ def func(): let binding = use_def .first_public_binding(global_table.symbol_id("func").expect("symbol exists")) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::Function(_))); + assert_matches!(binding.kind(&db), DefinitionKind::Function(_)); } #[test] @@ -2055,7 +2102,7 @@ match subject: let binding = use_def .first_public_binding(global_table.symbol_id(name).expect("symbol exists")) .expect("Expected with item definition for {name}"); - assert!(matches!(binding.kind(&db), DefinitionKind::MatchPattern(_))); + assert_matches!(binding.kind(&db), DefinitionKind::MatchPattern(_)); } } @@ -2081,7 +2128,7 @@ match 1: let binding = use_def .first_public_binding(global_table.symbol_id(name).expect("symbol exists")) .expect("Expected with item definition for {name}"); - assert!(matches!(binding.kind(&db), DefinitionKind::MatchPattern(_))); + assert_matches!(binding.kind(&db), DefinitionKind::MatchPattern(_)); } } @@ -2098,7 +2145,7 @@ match 1: .first_public_binding(global_table.symbol_id("x").unwrap()) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::For(_))); + assert_matches!(binding.kind(&db), DefinitionKind::For(_)); } #[test] @@ -2117,8 +2164,8 @@ match 1: .first_public_binding(global_table.symbol_id("y").unwrap()) .unwrap(); - assert!(matches!(x_binding.kind(&db), DefinitionKind::For(_))); - assert!(matches!(y_binding.kind(&db), DefinitionKind::For(_))); + assert_matches!(x_binding.kind(&db), DefinitionKind::For(_)); + assert_matches!(y_binding.kind(&db), DefinitionKind::For(_)); } #[test] @@ -2134,6 +2181,6 @@ match 1: .first_public_binding(global_table.symbol_id("a").unwrap()) .unwrap(); - assert!(matches!(binding.kind(&db), DefinitionKind::For(_))); + assert_matches!(binding.kind(&db), DefinitionKind::For(_)); } } diff --git a/crates/ty_python_core/src/member.rs b/crates/ty_python_core/src/member.rs index 70496dfb61..bd9cf4c3c5 100644 --- a/crates/ty_python_core/src/member.rs +++ b/crates/ty_python_core/src/member.rs @@ -1064,6 +1064,8 @@ fn hash_single(value: &T) -> u64 { #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; #[test] @@ -1200,7 +1202,7 @@ mod tests { MemberExpr::try_from_expr(ast::ExprRef::from(small_expr.expr())).unwrap(); // Should use Small allocation - assert!(matches!(small_member.segments, Segments::Small(_))); + assert_matches!(small_member.segments, Segments::Small(_)); assert_eq!(small_member.num_segments(), 7); // Test Heap allocation: 8 segments (exceeds inline capacity) @@ -1209,7 +1211,7 @@ mod tests { let heap_member = MemberExpr::try_from_expr(ast::ExprRef::from(heap_expr.expr())).unwrap(); // Should use Heap allocation - assert!(matches!(heap_member.segments, Segments::Heap(_))); + assert_matches!(heap_member.segments, Segments::Heap(_)); assert_eq!(heap_member.num_segments(), 8); // Test Small allocation with relative offset limit @@ -1219,7 +1221,7 @@ mod tests { MemberExpr::try_from_expr(ast::ExprRef::from(small_offset_expr.expr())).unwrap(); // Should use Small allocation (3 segments, small offsets) - assert!(matches!(small_offset_member.segments, Segments::Small(_))); + assert_matches!(small_offset_member.segments, Segments::Small(_)); assert_eq!(small_offset_member.num_segments(), 3); // Test Small allocation with maximum 63-byte relative offset limit @@ -1230,7 +1232,7 @@ mod tests { let max_offset_member = MemberExpr::try_from_expr(ast::ExprRef::from(max_offset_expr.expr())).unwrap(); // Should still use Small allocation (exactly at the limit) - assert!(matches!(max_offset_member.segments, Segments::Small(_))); + assert_matches!(max_offset_member.segments, Segments::Small(_)); assert_eq!(max_offset_member.num_segments(), 2); // Test that heap allocation works for segment content that would exceed relative offset limits @@ -1241,7 +1243,7 @@ mod tests { let long_expr = parse_expression(&long_expr_code).unwrap(); let long_member = MemberExpr::try_from_expr(ast::ExprRef::from(long_expr.expr())).unwrap(); // Should use Heap allocation due to large relative offset - assert!(matches!(long_member.segments, Segments::Heap(_))); + assert_matches!(long_member.segments, Segments::Heap(_)); assert_eq!(long_member.num_segments(), 2); } } diff --git a/crates/ty_python_core/src/narrowing_constraints.rs b/crates/ty_python_core/src/narrowing_constraints.rs index 9042a93e03..302f27e318 100644 --- a/crates/ty_python_core/src/narrowing_constraints.rs +++ b/crates/ty_python_core/src/narrowing_constraints.rs @@ -266,6 +266,29 @@ impl NarrowingConstraintsBuilder { } } + /// Adds a constraint that selects between two formulas based on `predicate`. + pub(crate) fn add_conditional( + &mut self, + predicate: ScopedPredicateId, + if_true: ScopedNarrowingConstraint, + if_false: ScopedNarrowingConstraint, + ) -> ScopedNarrowingConstraint { + let node = InteriorNode { + atom: predicate, + if_true, + if_uncertain: ALWAYS_FALSE, + if_false, + }; + if let Some(cached) = self.interior_cache.get(&node) { + return *cached; + } + if self.interiors.len() >= MAX_INTERIOR_NODES { + return ALWAYS_TRUE; + } + + self.add_interior(node) + } + pub(crate) fn add_or_constraint( &mut self, a: ScopedNarrowingConstraint, diff --git a/crates/ty_python_core/src/node_key.rs b/crates/ty_python_core/src/node_key.rs index ec89e3674d..4ba137adad 100644 --- a/crates/ty_python_core/src/node_key.rs +++ b/crates/ty_python_core/src/node_key.rs @@ -9,6 +9,11 @@ use crate::ast_node_ref::AstNodeRef; pub struct NodeKey(NodeIndex); impl NodeKey { + /// Returns the index of the AST node. + pub fn index(self) -> NodeIndex { + self.0 + } + pub fn from_node(node: N) -> Self where N: HasNodeIndex, diff --git a/crates/ty_python_core/src/predicate.rs b/crates/ty_python_core/src/predicate.rs index b17fdb626f..abeef72721 100644 --- a/crates/ty_python_core/src/predicate.rs +++ b/crates/ty_python_core/src/predicate.rs @@ -19,6 +19,7 @@ use crate::db::Db; use crate::definition::Definition; use crate::expression::Expression; use crate::global_scope; +use crate::reachability_constraints::ScopedReachabilityConstraintId; use crate::scope::{FileScopeId, ScopeId}; use crate::symbol::ScopedSymbolId; @@ -114,7 +115,33 @@ pub struct CallableAndCallExpr<'db> { #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum PredicateNode<'db> { + /// The truthiness of an expression's resulting value. Expression(Expression<'db>), + /// A boolean operation, `not`, or conditional expression evaluated directly as a condition. + /// + /// In `if x and False`, the truthy branch is unreachable. But after `y = x and False`, + /// `if y` may be truthy: it can call `x.__bool__` a second time and get a different result. + Condition(Expression<'db>), + /// A chained comparison evaluated directly as a condition. Its inferred truthiness is + /// available without walking the expression again. + ChainedComparisonCondition(Expression<'db>), + /// Whether a context manager's exit return type allows an exception to be suppressed. + /// + /// Resolved during type inference because the context manager's type is unavailable during + /// semantic indexing. + ContextManagerSuppresses { + expression: Expression<'db>, + is_async: bool, + }, + /// Whether semantic evaluation rules out every normal entry into a `finally` suite. + /// + /// The continuation is captured before constructing this predicate, so its constraint cannot + /// depend on the predicate itself. Deferring evaluation preserves terminal cleanup paths when + /// a context manager's suppression behavior is unavailable during semantic indexing. + FinallyNormalPathImpossible { + scope: ScopeId<'db>, + continuation: ScopedReachabilityConstraintId, + }, /// These predicates are recorded for statements with call expressions. As part of /// reachability constraints, they are used to determine whether control flow can /// continue past this statement or not. diff --git a/crates/ty_python_core/src/program.rs b/crates/ty_python_core/src/program.rs index 7a645a4d53..8d6f4d5212 100644 --- a/crates/ty_python_core/src/program.rs +++ b/crates/ty_python_core/src/program.rs @@ -35,7 +35,7 @@ impl get_size2::GetSize for Program<'_> {} impl<'db> Program<'db> { /// Creates a program from settings whose search roots have already been registered. - pub fn from_settings(db: &'db dyn Db, settings: ProgramSettings) -> Self { + pub fn from_settings(db: &'db dyn Db, settings: &ProgramSettings) -> Self { let ProgramSettings { python_version, python_platform, @@ -43,7 +43,7 @@ impl<'db> Program<'db> { } = settings; let resolver_environment = - ResolverEnvironment::new(db, python_version.version, &search_paths); + ResolverEnvironment::new(db, python_version.version, search_paths); Program::new(db, python_platform, resolver_environment, None) } diff --git a/crates/ty_python_core/src/re_exports.rs b/crates/ty_python_core/src/re_exports.rs index 580244c64b..04ae261750 100644 --- a/crates/ty_python_core/src/re_exports.rs +++ b/crates/ty_python_core/src/re_exports.rs @@ -28,7 +28,7 @@ use ruff_python_ast::{ visitor::{Visitor, walk_expr, walk_pattern, walk_stmt}, }; use rustc_hash::FxHashMap; -use ty_module_resolver::{ImportingFile, ModuleName, resolve_module}; +use ty_module_resolver::{ImportingFile, resolve_module_for_import_from}; use crate::{Db, ProgramFile}; @@ -265,19 +265,11 @@ impl<'db> Visitor<'db> for ExportFinder<'db> { let program_file = self.program_file; let file = program_file.file(db); let resolver_environment = program_file.resolver_environment(db); - for export in ModuleName::from_import_statement( + for export in resolve_module_for_import_from( db, ImportingFile::File(file, resolver_environment), node, ) - .ok() - .and_then(|module_name| { - resolve_module( - db, - ImportingFile::File(file, resolver_environment), - &module_name, - ) - }) .iter() .flat_map(|module| { module diff --git a/crates/ty_python_core/src/reachability_constraints.rs b/crates/ty_python_core/src/reachability_constraints.rs index ad188fec73..421986d5dd 100644 --- a/crates/ty_python_core/src/reachability_constraints.rs +++ b/crates/ty_python_core/src/reachability_constraints.rs @@ -7,6 +7,7 @@ use std::cmp::Ordering; use ruff_index::{Idx, IndexVec}; use rustc_hash::FxHashMap; +use crate::narrowing_constraints::{NarrowingConstraintsBuilder, ScopedNarrowingConstraint}; use crate::predicate::ScopedPredicateId; use crate::rank::{RankBitBox, RankBitBoxVec}; @@ -194,6 +195,11 @@ pub struct ReachabilityConstraintsBuilder { } impl ReachabilityConstraintsBuilder { + /// Returns whether new constraint combinations may lose precision at the arena limit. + pub(crate) fn is_saturated(&self) -> bool { + self.interiors.len() >= MAX_INTERIOR_NODES + } + pub(crate) fn build(self) -> ReachabilityConstraints { if self.interior_used.first_zero().is_none() { ReachabilityConstraints { @@ -226,6 +232,75 @@ impl ReachabilityConstraintsBuilder { } } + /// Converts a reachability formula into a narrowing gate. + /// + /// An ambiguous reachability leaf cannot exclude a control-flow path, so its + /// narrowing gate is `ALWAYS_TRUE`, preserving any existing narrowing. + /// Interior ambiguous branches are omitted because narrowing follows the + /// runtime-true or runtime-false path of each predicate. + pub(crate) fn narrowing_gate( + &self, + root: ScopedReachabilityConstraintId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, + ) -> ScopedNarrowingConstraint { + enum Action { + Visit(ScopedReachabilityConstraintId), + Finish(ScopedReachabilityConstraintId), + } + + let terminal = |id| match id { + ScopedReachabilityConstraintId::ALWAYS_TRUE + | ScopedReachabilityConstraintId::AMBIGUOUS => { + Some(ScopedNarrowingConstraint::ALWAYS_TRUE) + } + ScopedReachabilityConstraintId::ALWAYS_FALSE => { + Some(ScopedNarrowingConstraint::ALWAYS_FALSE) + } + _ => None, + }; + + if let Some(root) = terminal(root) { + return root; + } + + let root_node = self.interiors[root]; + if let (Some(if_true), Some(if_false)) = + (terminal(root_node.if_true), terminal(root_node.if_false)) + { + return narrowing_constraints.add_conditional(root_node.atom, if_true, if_false); + } + + let mut converted = FxHashMap::default(); + let mut actions = vec![Action::Visit(root)]; + + while let Some(action) = actions.pop() { + match action { + Action::Visit(id) => { + if terminal(id).is_some() || converted.contains_key(&id) { + continue; + } + + let node = self.interiors[id]; + actions.push(Action::Finish(id)); + actions.push(Action::Visit(node.if_false)); + actions.push(Action::Visit(node.if_true)); + } + Action::Finish(id) => { + let node = self.interiors[id]; + let if_true = + terminal(node.if_true).unwrap_or_else(|| converted[&node.if_true]); + let if_false = + terminal(node.if_false).unwrap_or_else(|| converted[&node.if_false]); + let result = + narrowing_constraints.add_conditional(node.atom, if_true, if_false); + converted.insert(id, result); + } + } + } + + converted[&root] + } + /// Implements the ordering that determines which level a TDD node appears at. /// /// Each interior node checks the value of a single variable (for us, a `Predicate`). @@ -355,7 +430,7 @@ impl ReachabilityConstraintsBuilder { match (a, b) { (ALWAYS_TRUE, _) | (_, ALWAYS_TRUE) => return ALWAYS_TRUE, (ALWAYS_FALSE, other) | (other, ALWAYS_FALSE) => return other, - (AMBIGUOUS, AMBIGUOUS) => return AMBIGUOUS, + _ if a == b => return a, _ => {} } @@ -425,7 +500,7 @@ impl ReachabilityConstraintsBuilder { match (a, b) { (ALWAYS_FALSE, _) | (_, ALWAYS_FALSE) => return ALWAYS_FALSE, (ALWAYS_TRUE, other) | (other, ALWAYS_TRUE) => return other, - (AMBIGUOUS, AMBIGUOUS) => return AMBIGUOUS, + _ if a == b => return a, _ => {} } diff --git a/crates/ty_python_core/src/scope.rs b/crates/ty_python_core/src/scope.rs index 728ee428e1..b423f53d47 100644 --- a/crates/ty_python_core/src/scope.rs +++ b/crates/ty_python_core/src/scope.rs @@ -60,7 +60,7 @@ impl<'db> ScopeId<'db> { } /// Returns the class definition for the enclosing class if this scope is a method body. - pub fn class_definition_of_method(self, db: &'db dyn Db) -> Option> { + fn class_definition_of_method(self, db: &'db dyn Db) -> Option> { semantic_index(db, self.program_file(db)).class_definition_of_method(self.file_scope_id(db)) } diff --git a/crates/ty_python_core/src/use_def.rs b/crates/ty_python_core/src/use_def.rs index 284c428743..a1352c6c6c 100644 --- a/crates/ty_python_core/src/use_def.rs +++ b/crates/ty_python_core/src/use_def.rs @@ -242,16 +242,16 @@ use std::collections::hash_map::Entry; use std::hash::{Hash as _, Hasher as _}; use std::ops::Index; use std::rc::Rc; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; use ruff_index::{FrozenIndexVec, Idx, IndexVec, newtype_index}; use ruff_text_size::TextRange; -use rustc_hash::{FxBuildHasher, FxHashMap, FxHasher}; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet, FxHasher}; use smallvec::SmallVec; use thin_vec::ThinVec; use crate::ast_ids::ScopedUseId; -use crate::definition::{Definition, DefinitionState}; +use crate::definition::{Definition, DefinitionCategory, DefinitionState}; use crate::frozen::FrozenMap; use crate::member::ScopedMemberId; use crate::narrowing_constraints::{ @@ -272,8 +272,11 @@ use crate::{ BoundnessAnalysis, EnclosingSnapshotResult, LoopHeader, PossiblyNarrowedPlaces, SemanticIndex, }; +mod exception_checkpoint; mod place_state; +pub(super) use exception_checkpoint::ExceptionCheckpointKey; +use exception_checkpoint::{ExceptionCheckpointSnapshot, ExceptionCheckpointState}; pub use place_state::LiveBinding; pub use place_state::ScopedDefinitionId; pub(super) use place_state::{FutureDefinitions, PreviousDefinitions}; @@ -454,7 +457,7 @@ impl PlaceStateInterner { /// The builder needs a `SmallVec` and an optional unbound constraint while constructing each /// binding state. Neither is needed after the semantic index is built, so the retained map stores /// cumulative end offsets into one contiguous array instead. -#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, Hash, get_size2::GetSize)] struct RetainedBindings { ends: FrozenIndexVec, live_bindings: Box<[LiveBinding]>, @@ -523,7 +526,7 @@ impl Index for RetainedBindings { } /// Compact, retained representation of the interned declaration vectors for a scope. -#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, Hash, get_size2::GetSize)] struct RetainedDeclarations { /// The exclusive end of each state in `live_declarations`; its start is the previous end. ends: FrozenIndexVec, @@ -600,10 +603,44 @@ enum InternedEnclosingSnapshotId { #[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct ConstraintTables<'db> { predicates: Predicates<'db>, + predicate_narrowing_targets: PredicateNarrowingTargets, reachability_constraints: ReachabilityConstraints, narrowing_constraints: NarrowingConstraints, } +/// Predicate-place pairs for which type narrowing may produce a constraint. +/// +/// Reachability gates can contain predicates that are unrelated to the place being narrowed. +/// Keeping the conservative targets computed while building the semantic index lets type +/// inference skip constructing those predicates' full narrowing maps. +#[derive(Debug, Default, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +pub struct PredicateNarrowingTargets(Box<[(ScopedPredicateId, ScopedPlaceId)]>); + +impl PredicateNarrowingTargets { + fn from_entries(mut entries: Vec<(ScopedPredicateId, ScopedPlaceId)>) -> Self { + entries.sort_unstable_by_key(|&(predicate, place)| (place, predicate)); + entries.dedup(); + + Self(entries.into_boxed_slice()) + } + + /// Returns whether `predicate` may narrow `place`. + pub fn contains(&self, predicate: ScopedPredicateId, place: ScopedPlaceId) -> bool { + self.0 + .binary_search_by_key(&(place, predicate), |&(predicate, place)| { + (place, predicate) + }) + .is_ok() + } + + /// Returns whether any predicate may narrow `place`. + pub fn contains_place(&self, place: ScopedPlaceId) -> bool { + self.0 + .binary_search_by_key(&place, |&(_, target)| target) + .is_ok() + } +} + /// Fields that are empty in most use-def maps. /// /// These fields share an allocation to avoid storing five collection headers in every @@ -634,6 +671,7 @@ struct UseDefMapExtra { static EMPTY_CONSTRAINT_TABLES: LazyLock> = LazyLock::new(|| ConstraintTables { predicates: IndexVec::new().into(), + predicate_narrowing_targets: PredicateNarrowingTargets::default(), reachability_constraints: ReachabilityConstraintsBuilder::default().build(), narrowing_constraints: NarrowingConstraintsBuilder::default().build(), }); @@ -644,79 +682,56 @@ static ALWAYS_UNBOUND_BINDINGS: LazyLock = static ALWAYS_UNDECLARED_DECLARATIONS: LazyLock = LazyLock::new(|| Declarations::undeclared(ScopedReachabilityConstraintId::ALWAYS_TRUE)); +/// One event in a scope's use-def history. #[derive(Clone, Copy, Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] -enum RetainedDefinitionState<'db> { +enum DefinitionEntry<'db> { + /// The early declaration of a combined definition whose binding is recorded separately. + /// It participates in declaration lookup, but not in binding-usage analysis. + DeclarationPart(Definition<'db>), + /// A binding or standalone declaration with no recorded use. Unused(Definition<'db>), Used(Definition<'db>), Undefined, Deleted, } -impl<'db> RetainedDefinitionState<'db> { - fn new(state: DefinitionState<'db>, used: bool) -> Self { - match state { - DefinitionState::Defined(definition) if used => Self::Used(definition), - DefinitionState::Defined(definition) => Self::Unused(definition), - DefinitionState::Undefined => { - debug_assert!(!used); - Self::Undefined - } - DefinitionState::Deleted => { - debug_assert!(!used); - Self::Deleted - } - } - } - +impl<'db> DefinitionEntry<'db> { fn state(self) -> DefinitionState<'db> { match self { - Self::Unused(definition) | Self::Used(definition) => { - DefinitionState::Defined(definition) - } + Self::DeclarationPart(definition) + | Self::Unused(definition) + | Self::Used(definition) => DefinitionState::Defined(definition), Self::Undefined => DefinitionState::Undefined, Self::Deleted => DefinitionState::Deleted, } } - - fn is_used(self) -> bool { - matches!(self, Self::Used(_)) - } } -static_assertions::assert_eq_size!(RetainedDefinitionState<'static>, DefinitionState<'static>); +static_assertions::assert_eq_size!(DefinitionEntry<'static>, DefinitionState<'static>); /// Retained definition states, excluding the implicit unbound definition at index zero. #[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct RetainedDefinitions<'db> { - states: Box<[RetainedDefinitionState<'db>]>, + states: Box<[DefinitionEntry<'db>]>, } impl<'db> RetainedDefinitions<'db> { - fn new( - states: IndexVec>, - used: IndexVec, - ) -> Self { + fn new(states: IndexVec>) -> Self { let mut states = states.into_iter(); - let mut used = used.into_iter(); let unbound_state = states.next(); - let unbound_used = used.next(); - debug_assert_eq!(unbound_state, Some(DefinitionState::Undefined)); - debug_assert_eq!(unbound_used, Some(false)); + debug_assert_eq!(unbound_state, Some(DefinitionEntry::Undefined)); Self { - states: states - .zip(used) - .map(|(state, used)| RetainedDefinitionState::new(state, used)) - .collect(), + states: states.collect(), } } #[inline] - fn get(&self, id: ScopedDefinitionId) -> RetainedDefinitionState<'db> { + fn get(&self, id: ScopedDefinitionId) -> DefinitionEntry<'db> { let index = id.index(); if index == 0 { - RetainedDefinitionState::Undefined + DefinitionEntry::Undefined } else { self.states[index - 1] } @@ -724,18 +739,12 @@ impl<'db> RetainedDefinitions<'db> { fn iter_enumerated( &self, - ) -> impl Iterator)> + '_ { - std::iter::once(( - ScopedDefinitionId::UNBOUND, - RetainedDefinitionState::Undefined, - )) - .chain( - self.states - .iter() - .copied() - .enumerate() - .map(|(index, state)| (ScopedDefinitionId::new(index + 1), state)), - ) + ) -> impl Iterator)> + '_ { + self.states + .iter() + .copied() + .enumerate() + .map(|(index, entry)| (ScopedDefinitionId::new(index + 1), entry)) } } @@ -751,9 +760,9 @@ pub struct UseDefMap<'db> { constraint_tables: Option>>, /// Interned [`Bindings`] values. - interned_bindings: RetainedBindings, + interned_bindings: Arc, /// Interned [`Declarations`] values. - interned_declarations: RetainedDeclarations, + interned_declarations: Arc, /// Tracks the reachability constraint for statements and certain sub-expressions /// (e.g. ternary branches, boolean operator operands), keyed by their text range. @@ -808,6 +817,37 @@ pub struct UseDefMap<'db> { end_of_scope_reachability: ScopedReachabilityConstraintId, } +/// Shares equivalent scope-local binding and declaration tables within a file. +/// +/// Their IDs are interpreted through each scope's own definitions and constraints, so +/// identical tables can share storage without sharing the scope-specific data they reference. +#[derive(Default)] +pub(super) struct UseDefMapInterner { + bindings: FxHashSet>, + declarations: FxHashSet>, +} + +impl UseDefMapInterner { + pub(super) fn intern<'db>(&mut self, mut map: UseDefMap<'db>) -> Arc> { + map.interned_bindings = Self::intern_table(&mut self.bindings, map.interned_bindings); + map.interned_declarations = + Self::intern_table(&mut self.declarations, map.interned_declarations); + Arc::new(map) + } + + fn intern_table( + values: &mut FxHashSet>, + value: Arc, + ) -> Arc { + if let Some(existing) = values.get(value.as_ref()) { + Arc::clone(existing) + } else { + values.insert(Arc::clone(&value)); + value + } + } +} + /// Information about a given range of source code. #[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] struct RangeInfo { @@ -890,12 +930,22 @@ impl<'db> UseDefMap<'db> { self.end_of_scope_reachability } - pub fn all_definitions_with_usage( + /// Definitions relevant to usage analysis, including standalone declarations. + /// + /// The early declaration part of a combined definition is omitted: its later binding entry + /// carries the usage information for that definition. + pub fn definitions_with_usage( &self, - ) -> impl Iterator, bool)> + '_ { + ) -> impl Iterator, bool)> + '_ { self.all_definitions .iter_enumerated() - .map(|(id, state)| (id, state.state(), state.is_used())) + .filter_map(|(id, entry)| match entry { + DefinitionEntry::Unused(definition) => Some((id, definition, false)), + DefinitionEntry::Used(definition) => Some((id, definition, true)), + DefinitionEntry::DeclarationPart(_) + | DefinitionEntry::Undefined + | DefinitionEntry::Deleted => None, + }) } pub fn bindings_at_use(&self, use_id: ScopedUseId) -> BindingWithConstraintsIterator<'_, 'db> { @@ -1314,6 +1364,10 @@ impl<'map, 'db> NarrowingEvaluator<'map, 'db> { &self.constraint_tables.predicates } + pub fn predicate_narrowing_targets(&self) -> &'map PredicateNarrowingTargets { + &self.constraint_tables.predicate_narrowing_targets + } + pub fn narrowing_constraints(&self) -> &'map NarrowingConstraints { &self.constraint_tables.narrowing_constraints } @@ -1382,6 +1436,8 @@ pub(super) struct FlowSnapshot { symbol_states: IndexVec, member_states: IndexVec, reachability: ScopedReachabilityConstraintId, + checkpoint_flow: ScopedReachabilityConstraintId, + checkpoint_state: ExceptionCheckpointSnapshot, pending_reachability: PendingReachabilityId, } @@ -1402,7 +1458,7 @@ struct PendingReachabilityConstraint { narrowing_constraint: ScopedNarrowingConstraint, } -/// An append-only tree of scope-wide reachability constraints and call narrowing gates. +/// An append-only tree of scope-wide reachability constraints and narrowing gates. /// /// Each [`PendingPlaceState`] remembers the last node applied for each constraint kind, so /// snapshots can share place states and defer applying subsequent constraints until needed. @@ -1543,8 +1599,8 @@ impl PendingReachability { /// Returns the place state needed to resolve a use. /// - /// A call's narrowing gate is only needed if the place is later changed or merged, so it is - /// not materialized here. + /// Pending narrowing gates are only needed to preserve path correlations across a later place + /// change or merge, so they are not materialized here. fn materialize_ref_at_use<'a>( &self, pending: &'a mut PendingPlaceState, @@ -1579,7 +1635,7 @@ impl PendingReachability { constraint } - /// Combines the call narrowing gates after `ancestor` through `target` into one constraint. + /// Combines the narrowing gates after `ancestor` through `target` into one constraint. /// /// `ancestor` must be an ancestor of `target`. fn narrowing_constraint_between( @@ -1705,7 +1761,7 @@ impl PendingReachability { continue; } - // Preserve call gates that precede the branch, then merge gates introduced on the + // Preserve gates that precede the branch, then merge gates introduced on the // individual branch paths. If either path has no gate, the merged gate simplifies // to `ALWAYS_TRUE` and can be discarded. self.materialize_narrowing(current, branch_ancestor, narrowing_constraints); @@ -1768,17 +1824,15 @@ pub(super) struct SingleSymbolSnapshot { #[derive(Debug)] pub(super) struct UseDefMapBuilder<'db> { - /// Append-only array of [`DefinitionState`]. - all_definitions: IndexVec>, - - /// Tracks whether each binding definition has at least one use. - /// - /// Uses the same index as `all_definitions`. - used_bindings: IndexVec, + /// Append-only history of declarations and bindings, including their usage state. + all_definitions: IndexVec>, /// Builder of predicates. predicates: PredicatesBuilder<'db>, + /// Predicate-place pairs for which a narrowing constraint was recorded. + predicate_narrowing_targets: Vec<(ScopedPredicateId, ScopedPlaceId)>, + /// Builder of reachability constraints. pub(super) reachability_constraints: ReachabilityConstraintsBuilder, @@ -1804,6 +1858,15 @@ pub(super) struct UseDefMapBuilder<'db> { /// keyed by their text range. range_reachability: Vec<(TextRange, RangeInfo)>, + /// Identifies the current control-flow path for exception checkpoints. + /// + /// Unlike `reachability`, this excludes per-call gates so repeated calls with unchanged + /// bindings share a checkpoint. + checkpoint_flow: ScopedReachabilityConstraintId, + + /// Restorable identity of the bindings visible to exception handlers. + checkpoint_state: ExceptionCheckpointState, + /// Live bindings for each so-far-recorded definition and, for binding-only definitions, the /// live declarations. definitions_by_definition: @@ -1832,20 +1895,25 @@ pub(super) struct UseDefMapBuilder<'db> { /// Is this a class scope? is_class_scope: bool, + + /// Whether reachability predicates should also preserve narrowing across branches. + reachability_narrowing_enabled: bool, } impl<'db> UseDefMapBuilder<'db> { - pub(super) fn new(is_class_scope: bool) -> Self { + pub(super) fn new(scope_kind: ScopeKind) -> Self { Self { - all_definitions: IndexVec::from_iter([DefinitionState::Undefined]), - used_bindings: IndexVec::from_iter([false]), + all_definitions: IndexVec::from_iter([DefinitionEntry::Undefined]), predicates: PredicatesBuilder::default(), + predicate_narrowing_targets: Vec::new(), reachability_constraints: ReachabilityConstraintsBuilder::default(), narrowing_constraints: NarrowingConstraintsBuilder::default(), bindings_by_use: IndexVec::new(), multi_bindings_by_use: FxHashMap::default(), reachability: ScopedReachabilityConstraintId::ALWAYS_TRUE, range_reachability: Vec::new(), + checkpoint_flow: ScopedReachabilityConstraintId::ALWAYS_TRUE, + checkpoint_state: ExceptionCheckpointState::default(), definitions_by_definition: FxHashMap::default(), symbol_states: IndexVec::new(), member_states: IndexVec::new(), @@ -1854,7 +1922,11 @@ impl<'db> UseDefMapBuilder<'db> { reachable_symbol_definitions: IndexVec::new(), enclosing_snapshots: EnclosingSnapshots::default(), loop_headers: IndexVec::new(), - is_class_scope, + is_class_scope: scope_kind.is_class(), + reachability_narrowing_enabled: matches!( + scope_kind, + ScopeKind::Module | ScopeKind::Class | ScopeKind::Function | ScopeKind::Lambda + ), } } @@ -1866,15 +1938,14 @@ impl<'db> UseDefMapBuilder<'db> { self.loop_headers[id] = header; } - fn push_definition(&mut self, state: DefinitionState<'db>) -> ScopedDefinitionId { - let def_id = self.all_definitions.push(state); - let used_id = self.used_bindings.push(false); - debug_assert_eq!(def_id, used_id); - def_id + fn push_definition(&mut self, entry: DefinitionEntry<'db>) -> ScopedDefinitionId { + // Declaration-only entries also change the type visible to an exception handler. + self.checkpoint_state.record_binding_change(); + self.all_definitions.push(entry) } pub(super) fn definition(&self, def_id: ScopedDefinitionId) -> DefinitionState<'db> { - self.all_definitions[def_id] + self.all_definitions[def_id].state() } pub(super) fn mark_unreachable(&mut self) { @@ -1882,6 +1953,7 @@ impl<'db> UseDefMapBuilder<'db> { } pub(super) fn add_place(&mut self, place: ScopedPlaceId) { + self.checkpoint_state.record_binding_change(); match place { ScopedPlaceId::Symbol(symbol) => { let new_place = self.symbol_states.push(PendingPlaceState::new( @@ -1918,6 +1990,12 @@ impl<'db> UseDefMapBuilder<'db> { self.all_definitions.next_index() } + /// Identifies the visible bindings and control-flow path observed by an exception handler. + pub(super) fn exception_checkpoint_key(&self) -> ExceptionCheckpointKey { + self.checkpoint_state + .key((!self.reachability_constraints.is_saturated()).then_some(self.checkpoint_flow)) + } + pub(super) fn record_binding( &mut self, place: ScopedPlaceId, @@ -1926,7 +2004,7 @@ impl<'db> UseDefMapBuilder<'db> { can_be_shadowed: FutureDefinitions, ) { let pending = self.pending_reachability.current; - let def_id = self.push_definition(DefinitionState::Defined(binding)); + let def_id = self.push_definition(DefinitionEntry::Unused(binding)); let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); let place_state = self.pending_reachability.materialize( @@ -2001,6 +2079,9 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.predicate_narrowing_targets + .extend(places.iter().map(|place| (predicate, *place))); + let atom = self.narrowing_constraints.add_atom(predicate); self.record_narrowing_constraint_node_for_places(atom, places); } @@ -2019,6 +2100,8 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.predicate_narrowing_targets.push((predicate, place)); + let constraint = self.narrowing_constraints.add_atom(predicate); let pending = self.pending_reachability.current; let state = @@ -2049,6 +2132,8 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.predicate_narrowing_targets.push((predicate, place)); + let constraint = self.narrowing_constraints.add_atom(predicate); let pending = self.pending_reachability.current; let state = @@ -2069,7 +2154,9 @@ impl<'db> UseDefMapBuilder<'db> { /// Records a negated narrowing constraint for only the specified places. /// /// The positive and negative constraints use the same predicate ID. This lets `P or not P` - /// simplify to `ALWAYS_TRUE`, so narrowing cancels out after a complete `if`/`else`. + /// simplify to `ALWAYS_TRUE`, so narrowing cancels out after a complete `if`/`else`. The + /// predicate's possible targets are independent of its polarity and were already recorded + /// with the positive constraint. pub(super) fn record_negated_narrowing_constraint_for_places( &mut self, predicate: ScopedPredicateId, @@ -2202,6 +2289,7 @@ impl<'db> UseDefMapBuilder<'db> { symbol: ScopedSymbolId, pre_definition: SingleSymbolSnapshot, ) { + self.checkpoint_state.record_binding_change(); let negated_reachability_id = self .reachability_constraints .add_not_constraint(reachability_id); @@ -2263,42 +2351,23 @@ impl<'db> UseDefMapBuilder<'db> { } } - /// Records a narrowing constraint for all places in the current scope. - /// - /// This is used to gate narrowing by `IsNonTerminalCall` constraints: when a branch contains - /// a call to a `NoReturn` function, all narrowing in that branch should be conditional - /// on the call actually returning `Never`. - pub(super) fn record_narrowing_constraint_for_all_places( - &mut self, - constraint: ScopedNarrowingConstraint, - ) { - let pending = self.pending_reachability.current; - for state in self - .symbol_states - .iter_mut() - .chain(self.member_states.iter_mut()) - { - let state = self.pending_reachability.materialize( - state, - pending, - &mut self.narrowing_constraints, - &mut self.reachability_constraints, - ); - state.record_narrowing_constraint(&mut self.narrowing_constraints, constraint); - } - } - pub(super) fn record_reachability_constraint( &mut self, - constraint: ScopedReachabilityConstraintId, + reachability_constraint: ScopedReachabilityConstraintId, ) { - self.record_reachability_constraint_impl( - constraint, - ScopedNarrowingConstraint::ALWAYS_TRUE, - ); + self.checkpoint_flow = self + .reachability_constraints + .add_and_constraint(self.checkpoint_flow, reachability_constraint); + let narrowing_constraint = if self.reachability_narrowing_enabled { + self.reachability_constraints + .narrowing_gate(reachability_constraint, &mut self.narrowing_constraints) + } else { + ScopedNarrowingConstraint::ALWAYS_TRUE + }; + self.record_reachability_constraint_impl(reachability_constraint, narrowing_constraint); } - /// Records a call's reachability predicate and its corresponding narrowing gate together. + /// Records a reachability predicate and its corresponding narrowing gate together. /// /// Reachability is materialized when a place is used, while the narrowing gate remains pending /// until that place is changed or merged. @@ -2307,6 +2376,7 @@ impl<'db> UseDefMapBuilder<'db> { reachability_constraint: ScopedReachabilityConstraintId, narrowing_constraint: ScopedNarrowingConstraint, ) { + self.checkpoint_state.record_call_gate(); self.record_reachability_constraint_impl(reachability_constraint, narrowing_constraint); } @@ -2327,7 +2397,7 @@ impl<'db> UseDefMapBuilder<'db> { place: ScopedPlaceId, declaration: Definition<'db>, ) { - let def_id = self.push_definition(DefinitionState::Defined(declaration)); + let def_id = self.push_definition(DefinitionEntry::Unused(declaration)); let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); @@ -2359,14 +2429,24 @@ impl<'db> UseDefMapBuilder<'db> { ); } - pub(super) fn record_declaration_and_binding( + /// Record some or all of a definition that both declares a type and binds a value. + /// + /// Annotated assignments can declare before their RHS and bind afterward. Each phase gets a + /// fresh scoped ID, so definitions created by the RHS remain in execution order. + pub(super) fn record_combined_definition( &mut self, place: ScopedPlaceId, definition: Definition<'db>, + part: DefinitionCategory, ) { // We don't need to store prior state for a definition that is both a declaration and a // binding. - let def_id = self.push_definition(DefinitionState::Defined(definition)); + let entry = if part.is_binding() { + DefinitionEntry::Unused(definition) + } else { + DefinitionEntry::DeclarationPart(definition) + }; + let def_id = self.push_definition(entry); let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); @@ -2376,38 +2456,41 @@ impl<'db> UseDefMapBuilder<'db> { &mut self.narrowing_constraints, &mut self.reachability_constraints, ); - place_state.record_declaration(def_id, self.reachability); - place_state.record_binding( - def_id, - self.reachability, - self.is_class_scope, - place.is_symbol(), - PreviousDefinitions::AreShadowed, - FutureDefinitions::ShadowThisOne, - ); - let reachable_definitions = match place { ScopedPlaceId::Symbol(symbol) => &mut self.reachable_symbol_definitions[symbol], ScopedPlaceId::Member(member) => &mut self.reachable_member_definitions[member], }; - reachable_definitions.declarations.record_declaration( - def_id, - self.reachability, - PreviousDefinitions::AreKept, - ); - reachable_definitions.bindings.record_binding( - def_id, - self.reachability, - self.is_class_scope, - place.is_symbol(), - PreviousDefinitions::AreKept, - FutureDefinitions::ShadowThisOne, - ); + if part.is_declaration() { + place_state.record_declaration(def_id, self.reachability); + reachable_definitions.declarations.record_declaration( + def_id, + self.reachability, + PreviousDefinitions::AreKept, + ); + } + if part.is_binding() { + place_state.record_binding( + def_id, + self.reachability, + self.is_class_scope, + place.is_symbol(), + PreviousDefinitions::AreShadowed, + FutureDefinitions::ShadowThisOne, + ); + reachable_definitions.bindings.record_binding( + def_id, + self.reachability, + self.is_class_scope, + place.is_symbol(), + PreviousDefinitions::AreKept, + FutureDefinitions::ShadowThisOne, + ); + } } pub(super) fn delete_binding(&mut self, place: ScopedPlaceId) { - let def_id = self.push_definition(DefinitionState::Deleted); + let def_id = self.push_definition(DefinitionEntry::Deleted); let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); @@ -2659,15 +2742,9 @@ impl<'db> UseDefMapBuilder<'db> { } fn mark_definition_used(&mut self, definition_id: ScopedDefinitionId) { - if definition_id.is_unbound() { - return; - } - - if matches!( - self.all_definitions[definition_id], - DefinitionState::Defined(_) - ) { - self.used_bindings[definition_id] = true; + let entry = &mut self.all_definitions[definition_id]; + if let DefinitionEntry::Unused(definition) = *entry { + *entry = DefinitionEntry::Used(definition); } } @@ -2677,6 +2754,8 @@ impl<'db> UseDefMapBuilder<'db> { symbol_states: self.symbol_states.clone(), member_states: self.member_states.clone(), reachability: self.reachability, + checkpoint_flow: self.checkpoint_flow, + checkpoint_state: self.checkpoint_state.snapshot(), pending_reachability: self.pending_reachability.current, } } @@ -2704,6 +2783,7 @@ impl<'db> UseDefMapBuilder<'db> { /// Restore the current builder places state to the given snapshot. pub(super) fn restore(&mut self, snapshot: FlowSnapshot) { + self.checkpoint_state.restore(snapshot.checkpoint_state); // We never remove places from `place_states` (it's an IndexVec, and the place // IDs must line up), so the current number of known places must always be equal to or // greater than the number of known places in a previously-taken snapshot. @@ -2715,6 +2795,7 @@ impl<'db> UseDefMapBuilder<'db> { self.symbol_states = snapshot.symbol_states; self.member_states = snapshot.member_states; self.reachability = snapshot.reachability; + self.checkpoint_flow = snapshot.checkpoint_flow; self.pending_reachability.current = snapshot.pending_reachability; // If the snapshot we are restoring is missing some places we've recorded since, we need @@ -2747,6 +2828,8 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.checkpoint_state.merge(snapshot.checkpoint_state); + // We never remove places from `place_states` (it's an IndexVec, and the place // IDs must line up), so the current number of known places must always be equal to or // greater than the number of known places in a previously-taken snapshot. @@ -2774,6 +2857,9 @@ impl<'db> UseDefMapBuilder<'db> { self.reachability = self .reachability_constraints .add_or_constraint(self.reachability, snapshot.reachability); + self.checkpoint_flow = self + .reachability_constraints + .add_or_constraint(self.checkpoint_flow, snapshot.checkpoint_flow); } pub(super) fn finish(mut self: Box) -> UseDefMap<'db> { @@ -2783,8 +2869,8 @@ impl<'db> UseDefMapBuilder<'db> { .iter_mut() .chain(self.member_states.iter_mut()) { - // No later state change can require the correlation represented by pending call - // narrowing gates, so only reachability needs to be finalized here. + // No later place change or merge can require the path correlation represented by + // pending narrowing gates, so only reachability needs to be finalized here. self.pending_reachability.materialize_reachability( state, pending, @@ -2905,6 +2991,8 @@ impl<'db> UseDefMapBuilder<'db> { }) }); let predicates = self.predicates.build(); + let predicate_narrowing_targets = + PredicateNarrowingTargets::from_entries(self.predicate_narrowing_targets); let reachability_constraints = self.reachability_constraints.build(); let narrowing_constraints = self.narrowing_constraints.build(); let constraint_tables = (!reachability_constraints.used_interiors().is_empty() @@ -2912,17 +3000,18 @@ impl<'db> UseDefMapBuilder<'db> { .then(|| { Box::new(ConstraintTables { predicates, + predicate_narrowing_targets, reachability_constraints, narrowing_constraints, }) }); - let all_definitions = RetainedDefinitions::new(self.all_definitions, self.used_bindings); + let all_definitions = RetainedDefinitions::new(self.all_definitions); UseDefMap { all_definitions, constraint_tables, - interned_bindings, - interned_declarations, + interned_bindings: Arc::new(interned_bindings), + interned_declarations: Arc::new(interned_declarations), range_reachability: self.range_reachability.into_boxed_slice(), symbol_states, definitions_by_definition, diff --git a/crates/ty_python_core/src/use_def/exception_checkpoint.rs b/crates/ty_python_core/src/use_def/exception_checkpoint.rs new file mode 100644 index 0000000000..61a6956e3e --- /dev/null +++ b/crates/ty_python_core/src/use_def/exception_checkpoint.rs @@ -0,0 +1,128 @@ +use crate::reachability_constraints::ScopedReachabilityConstraintId; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct Revision(u64); + +/// The provenance of the visible bindings and the call gates applied to them. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct ExceptionCheckpointSnapshot { + bindings: Revision, + calls: Revision, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CheckpointFlow { + Normalized(ScopedReachabilityConstraintId), + Conservative(Revision), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ExceptionCheckpointKey { + bindings: Revision, + flow: CheckpointFlow, +} + +/// Tracks state changes that normalized scope-wide reachability cannot distinguish. +/// +/// Binding identities are restored with flow snapshots. Joining different identities creates a +/// fresh one, even if both paths have ambiguous reachability. Calls do not immediately change the +/// checkpoint key, since a later straight-line call cannot expose additional bindings. Their +/// identities still matter when restoring or joining paths: catching an exception from a +/// `NoReturn` call can make previously unreachable bindings visible again. +#[derive(Debug, Default)] +pub(super) struct ExceptionCheckpointState { + current: ExceptionCheckpointSnapshot, + next_revision: Revision, + control_flow_revision: Revision, +} + +impl ExceptionCheckpointState { + fn fresh_revision(&mut self) -> Revision { + self.next_revision.0 += 1; + self.next_revision + } + + pub(super) fn record_binding_change(&mut self) { + self.current.bindings = self.fresh_revision(); + } + + pub(super) fn record_call_gate(&mut self) { + self.current.calls = self.fresh_revision(); + } + + pub(super) fn snapshot(&self) -> ExceptionCheckpointSnapshot { + self.current + } + + pub(super) fn restore(&mut self, snapshot: ExceptionCheckpointSnapshot) { + let calls_changed = self.current.calls != snapshot.calls; + self.control_flow_revision = self.fresh_revision(); + self.current = snapshot; + if calls_changed { + self.current.bindings = self.control_flow_revision; + } + } + + pub(super) fn merge(&mut self, snapshot: ExceptionCheckpointSnapshot) { + self.control_flow_revision = self.fresh_revision(); + if self.current != snapshot { + if self.current.calls != snapshot.calls { + self.current.calls = self.control_flow_revision; + } + self.current.bindings = self.control_flow_revision; + } + } + + /// Uses the conservative control-flow revision when the reachability arena is saturated. + pub(super) fn key( + &self, + normalized_flow: Option, + ) -> ExceptionCheckpointKey { + ExceptionCheckpointKey { + bindings: self.current.bindings, + flow: normalized_flow.map_or( + CheckpointFlow::Conservative(self.control_flow_revision), + CheckpointFlow::Normalized, + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FLOW: Option = + Some(ScopedReachabilityConstraintId::AMBIGUOUS); + + #[test] + fn unchanged_branches_preserve_binding_identity() { + let mut state = ExceptionCheckpointState::default(); + state.record_binding_change(); + state.record_call_gate(); + let snapshot = state.snapshot(); + let key = state.key(FLOW); + + state.restore(snapshot); + state.merge(snapshot); + assert_eq!(state.key(FLOW), key); + } + + #[test] + fn conservative_keys_still_coalesce_straight_line_calls() { + let mut state = ExceptionCheckpointState::default(); + state.record_binding_change(); + let key = state.key(None); + assert_ne!(key, state.key(FLOW)); + state.record_call_gate(); + state.record_call_gate(); + assert_eq!(state.key(None), key); + + let snapshot = state.snapshot(); + state.restore(snapshot); + let restored_key = state.key(None); + assert_ne!(restored_key, key); + state.merge(snapshot); + assert_ne!(state.key(None), restored_key); + } +} diff --git a/crates/ty_python_core/src/use_def/place_state.rs b/crates/ty_python_core/src/use_def/place_state.rs index 01d0494bdc..f924a1c9b9 100644 --- a/crates/ty_python_core/src/use_def/place_state.rs +++ b/crates/ty_python_core/src/use_def/place_state.rs @@ -50,7 +50,8 @@ use crate::ReachabilityConstraintsBuilder; use crate::narrowing_constraints::{NarrowingConstraintsBuilder, ScopedNarrowingConstraint}; use crate::reachability_constraints::ScopedReachabilityConstraintId; -/// A newtype-index for a definition in a particular scope. +/// An index into a scope's use-def history. A combined definition can have separate declaration +/// and binding entries when they take effect at different points in control flow. #[newtype_index] #[derive(Ord, PartialOrd, get_size2::GetSize)] pub struct ScopedDefinitionId; @@ -61,7 +62,7 @@ impl ScopedDefinitionId { /// unbound or undeclared at a given usage site. /// When creating a use-def-map builder, we always add an empty `DefinitionState::Undefined` definition /// at index 0, so this ID is always present. - pub(crate) const UNBOUND: ScopedDefinitionId = ScopedDefinitionId::from_u32(0); + const UNBOUND: ScopedDefinitionId = ScopedDefinitionId::from_u32(0); pub(crate) fn is_unbound(self) -> bool { self == Self::UNBOUND diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index 01986c5971..756b33fea8 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_semantic" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } @@ -18,6 +18,7 @@ ruff_index = { workspace = true, features = ["salsa"] } ruff_macros = { workspace = true } ruff_memory_usage = { workspace = true } ruff_python_ast = { workspace = true, features = ["salsa"] } +ruff_python_edits = { workspace = true } ruff_python_literal = { workspace = true } ruff_python_parser = { workspace = true } ruff_python_stdlib = { workspace = true } diff --git a/crates/ty_python_semantic/README.md b/crates/ty_python_semantic/README.md index 96f36cc39e..545b400765 100644 --- a/crates/ty_python_semantic/README.md +++ b/crates/ty_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_python_semantic). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_semantic/mdtest.py b/crates/ty_python_semantic/mdtest.py index 2f0ed30b4b..4732aed5db 100644 --- a/crates/ty_python_semantic/mdtest.py +++ b/crates/ty_python_semantic/mdtest.py @@ -7,7 +7,7 @@ # ] # # [tool.uv] -# exclude-newer = "7 days" +# exclude-newer = "P7D" # /// from __future__ import annotations diff --git a/crates/ty_python_semantic/mdtest.py.lock b/crates/ty_python_semantic/mdtest.py.lock index de16515750..c8426b8f5e 100644 --- a/crates/ty_python_semantic/mdtest.py.lock +++ b/crates/ty_python_semantic/mdtest.py.lock @@ -6,6 +6,14 @@ requires-python = ">=3.11" exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + [manifest] requirements = [ { name = "rich" }, @@ -27,11 +35,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] diff --git a/crates/ty_python_semantic/resources/lint_docs/.mdformat.toml b/crates/ty_python_semantic/resources/lint_docs/.mdformat.toml new file mode 100644 index 0000000000..0163082076 --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/.mdformat.toml @@ -0,0 +1 @@ +wrap = 100 diff --git a/crates/ty_python_semantic/resources/lint_docs/abstract-and-final-method.md b/crates/ty_python_semantic/resources/lint_docs/abstract-and-final-method.md index 390c976af7..18aab9e61a 100644 --- a/crates/ty_python_semantic/resources/lint_docs/abstract-and-final-method.md +++ b/crates/ty_python_semantic/resources/lint_docs/abstract-and-final-method.md @@ -4,9 +4,9 @@ Checks for methods decorated with both `@abstractmethod` and `@final`. ## Why is this bad? -An abstract method must be overridden for a subclass to become concrete, but a final -method cannot be overridden. Combining the decorators therefore makes it impossible -for a subclass to provide a concrete implementation. +An abstract method must be overridden for a subclass to become concrete, but a final method cannot +be overridden. Combining the decorators therefore makes it impossible for a subclass to provide a +concrete implementation. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/abstract-method-in-final-class.md b/crates/ty_python_semantic/resources/lint_docs/abstract-method-in-final-class.md index 3279d6358f..1031fff70a 100644 --- a/crates/ty_python_semantic/resources/lint_docs/abstract-method-in-final-class.md +++ b/crates/ty_python_semantic/resources/lint_docs/abstract-method-in-final-class.md @@ -4,14 +4,14 @@ Checks for `@final` classes that have unimplemented abstract methods. ## Why is this bad? -A class decorated with `@final` cannot be subclassed. If such a class has abstract -methods that are not implemented, the class can never be properly instantiated, as -the abstract methods can never be implemented (since subclassing is prohibited). - -At runtime, instantiation of classes with unimplemented abstract methods is only -prevented for classes that have `ABCMeta` (or a subclass of it) as their metaclass. -However, type checkers also enforce this for classes that do not use `ABCMeta`, since -the intent for the class to be abstract is clear from the use of `@abstractmethod`. +A class decorated with `@final` cannot be subclassed. If such a class has abstract methods that are +not implemented, the class can never be properly instantiated, as the abstract methods can never be +implemented (since subclassing is prohibited). + +At runtime, instantiation of classes with unimplemented abstract methods is only prevented for +classes that have `ABCMeta` (or a subclass of it) as their metaclass. However, type checkers also +enforce this for classes that do not use `ABCMeta`, since the intent for the class to be abstract is +clear from the use of `@abstractmethod`. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/ambiguous-protocol-member.md b/crates/ty_python_semantic/resources/lint_docs/ambiguous-protocol-member.md index 2204bf0c0d..16dca4a10e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/ambiguous-protocol-member.md +++ b/crates/ty_python_semantic/resources/lint_docs/ambiguous-protocol-member.md @@ -4,10 +4,10 @@ Checks for protocol classes with members that will lead to ambiguous interfaces. ## Why is this bad? -Assigning to an undeclared variable in a protocol class, or to an undeclared attribute -through a protocol method's `self` or `cls` receiver, leads to an ambiguous interface -which may lead to the type checker inferring unexpected things. It's recommended to -ensure that all members of a protocol class are explicitly declared. +Assigning to an undeclared variable in a protocol class, or to an undeclared attribute through a +protocol method's `self` or `cls` receiver, leads to an ambiguous interface which may lead to the +type checker inferring unexpected things. It's recommended to ensure that all members of a protocol +class are explicitly declared. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/assert-type-unspellable-subtype.md b/crates/ty_python_semantic/resources/lint_docs/assert-type-unspellable-subtype.md index 2344427557..9aed9fcded 100644 --- a/crates/ty_python_semantic/resources/lint_docs/assert-type-unspellable-subtype.md +++ b/crates/ty_python_semantic/resources/lint_docs/assert-type-unspellable-subtype.md @@ -1,16 +1,15 @@ ## What it does -Checks for `assert_type()` calls where the actual type -is an unspellable subtype of the asserted type. +Checks for `assert_type()` calls where the actual type is an unspellable subtype of the asserted +type. ## Why is this bad? -`assert_type()` is intended to ensure that the inferred type of a value -is exactly the same as the asserted type. But in some situations, ty -has nonstandard extensions to the type system that allow it to infer -more precise types than can be expressed in user annotations. ty emits a -different error code to `type-assertion-failure` in these situations so -that users can easily differentiate between the two cases. +`assert_type()` is intended to ensure that the inferred type of a value is exactly the same as the +asserted type. But in some situations, ty has nonstandard extensions to the type system that allow +it to infer more precise types than can be expressed in user annotations. ty emits a different error +code to `type-assertion-failure` in these situations so that users can easily differentiate between +the two cases. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/blanket-ignore-comment.md b/crates/ty_python_semantic/resources/lint_docs/blanket-ignore-comment.md index d0b846d2f5..2c5090d8ac 100644 --- a/crates/ty_python_semantic/resources/lint_docs/blanket-ignore-comment.md +++ b/crates/ty_python_semantic/resources/lint_docs/blanket-ignore-comment.md @@ -4,9 +4,9 @@ Checks for `ty: ignore` comments that don't specify which rules to ignore. ## Why is this bad? -A blanket `ty: ignore` comment suppresses every type-checking diagnostic on the -applicable line or file. Specifying rule codes documents which diagnostics are -expected and prevents the comment from silencing unrelated errors. +A blanket `ty: ignore` comment suppresses every type-checking diagnostic on the applicable line or +file. Specifying rule codes documents which diagnostics are expected and prevents the comment from +silencing unrelated errors. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/call-abstract-method.md b/crates/ty_python_semantic/resources/lint_docs/call-abstract-method.md index b08995f830..9af8a750e1 100644 --- a/crates/ty_python_semantic/resources/lint_docs/call-abstract-method.md +++ b/crates/ty_python_semantic/resources/lint_docs/call-abstract-method.md @@ -1,28 +1,25 @@ ## What it does -Checks for calls to abstract `@classmethod`s or `@staticmethod`s -with "trivial bodies" when accessed on the class object itself. +Checks for calls to abstract `@classmethod`s or `@staticmethod`s with "trivial bodies" when accessed +on the class object itself. -"Trivial bodies" are bodies that solely consist of `...`, `pass`, -a docstring, and/or `raise NotImplementedError`. +"Trivial bodies" are bodies that solely consist of `...`, `pass`, a docstring, and/or +`raise NotImplementedError`. ## Why is this bad? -An abstract method with a trivial body has no concrete implementation -to execute, so calling such a method directly on the class will probably -not have the desired effect. - -It is also unsound to call these methods directly on the class. Unlike -other methods, ty permits abstract methods with trivial bodies to have -non-`None` return types even though they always return `None` at runtime. -This is because it is expected that these methods will always be -overridden rather than being called directly. As a result of this -exception to the normal rule, ty may infer an incorrect type if one of -these methods is called directly, which may then mean that type errors +An abstract method with a trivial body has no concrete implementation to execute, so calling such a +method directly on the class will probably not have the desired effect. + +It is also unsound to call these methods directly on the class. Unlike other methods, ty permits +abstract methods with trivial bodies to have non-`None` return types even though they always return +`None` at runtime. This is because it is expected that these methods will always be overridden +rather than being called directly. As a result of this exception to the normal rule, ty may infer an +incorrect type if one of these methods is called directly, which may then mean that type errors elsewhere in your code go undetected by ty. -Calling abstract classmethods or staticmethods via `type[X]` is allowed, -since the actual runtime type could be a concrete subclass with an implementation. +Calling abstract classmethods or staticmethods via `type[X]` is allowed, since the actual runtime +type could be a concrete subclass with an implementation. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/call-top-callable.md b/crates/ty_python_semantic/resources/lint_docs/call-top-callable.md index 8aa3e10212..ec535b8a51 100644 --- a/crates/ty_python_semantic/resources/lint_docs/call-top-callable.md +++ b/crates/ty_python_semantic/resources/lint_docs/call-top-callable.md @@ -1,15 +1,15 @@ ## What it does -Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all -callable types with return type `T`). +Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all callable +types with return type `T`). ## Why is this bad? When an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or -`isinstance(x, Callable)`), we know the object is callable, but we don't know its -precise signature. This type represents the set of all possible callable types -(including, e.g., functions that take no arguments and functions that require arguments), -so no specific set of arguments can be guaranteed to be valid. +`isinstance(x, Callable)`), we know the object is callable, but we don't know its precise signature. +This type represents the set of all possible callable types (including, e.g., functions that take no +arguments and functions that require arguments), so no specific set of arguments can be guaranteed +to be valid. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/conflicting-declarations.md b/crates/ty_python_semantic/resources/lint_docs/conflicting-declarations.md index bc1eea57a5..782b2bee84 100644 --- a/crates/ty_python_semantic/resources/lint_docs/conflicting-declarations.md +++ b/crates/ty_python_semantic/resources/lint_docs/conflicting-declarations.md @@ -4,9 +4,8 @@ Checks whether a variable has been declared as two conflicting types. ## Why is this bad -A variable with two conflicting declarations likely indicates a mistake. -Moreover, it could lead to incorrect or ill-defined type inference for -other code that relies on these variables. +A variable with two conflicting declarations likely indicates a mistake. Moreover, it could lead to +incorrect or ill-defined type inference for other code that relies on these variables. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/conflicting-metaclass.md b/crates/ty_python_semantic/resources/lint_docs/conflicting-metaclass.md index 6d38fafd98..0322258742 100644 --- a/crates/ty_python_semantic/resources/lint_docs/conflicting-metaclass.md +++ b/crates/ty_python_semantic/resources/lint_docs/conflicting-metaclass.md @@ -1,8 +1,7 @@ ## What it does -Checks for class definitions where the metaclass of the class -being created would not be a subclass of the metaclasses of -all the class's bases. +Checks for class definitions where the metaclass of the class being created would not be a subclass +of the metaclasses of all the class's bases. ## Why is it bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/cyclic-class-definition.md b/crates/ty_python_semantic/resources/lint_docs/cyclic-class-definition.md index b4b81c39a9..fabb294089 100644 --- a/crates/ty_python_semantic/resources/lint_docs/cyclic-class-definition.md +++ b/crates/ty_python_semantic/resources/lint_docs/cyclic-class-definition.md @@ -1,13 +1,11 @@ ## What it does -Checks for class definitions in stub files that inherit -(directly or indirectly) from themselves. +Checks for class definitions in stub files that inherit (directly or indirectly) from themselves. ## Why is it bad? -Although forward references are natively supported in stub files, -inheritance cycles are still disallowed, as it is impossible to -resolve a consistent [method resolution order] for a class that +Although forward references are natively supported in stub files, inheritance cycles are still +disallowed, as it is impossible to resolve a consistent [method resolution order] for a class that inherits from itself. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/cyclic-type-alias-definition.md b/crates/ty_python_semantic/resources/lint_docs/cyclic-type-alias-definition.md index 0ca6b7b3d3..0f369e6ba4 100644 --- a/crates/ty_python_semantic/resources/lint_docs/cyclic-type-alias-definition.md +++ b/crates/ty_python_semantic/resources/lint_docs/cyclic-type-alias-definition.md @@ -1,11 +1,12 @@ ## What it does -Checks for type alias definitions that (directly or mutually) refer to themselves. +Checks for circular type alias definitions. ## Why is it bad? -Although it is permitted to define a recursive type alias, it is not meaningful -to have a type alias whose expansion can only result in itself, and is therefore not allowed. +Recursive aliases are valid when recursive references occur inside another type, such as +`list[Tree]`. An alias cannot expand directly to itself or include itself as a union member. This +applies to both `type` statements and aliases created with `TypeAliasType`. ## Examples @@ -15,8 +16,16 @@ python-version = "3.12" ``` ```python +from typing import TypeAliasType + type Itself = Itself # error type A = B # error type B = A # error + +type IntOr = int | IntOr # error + +Cycle = TypeAliasType("Cycle", "Cycle") # error + +type Tree = int | list[Tree] # valid recursive alias ``` diff --git a/crates/ty_python_semantic/resources/lint_docs/dataclass-field-order.md b/crates/ty_python_semantic/resources/lint_docs/dataclass-field-order.md index d0285d4679..ccf325312d 100644 --- a/crates/ty_python_semantic/resources/lint_docs/dataclass-field-order.md +++ b/crates/ty_python_semantic/resources/lint_docs/dataclass-field-order.md @@ -1,13 +1,12 @@ ## What it does -Checks for dataclass definitions where required fields are defined after -fields with default values. +Checks for dataclass definitions where required fields are defined after fields with default values. ## Why is this bad? -In dataclasses, all required fields (fields without default values) must be -defined before fields with default values. This is a Python requirement that -will raise a `TypeError` at runtime if violated. +In dataclasses, all required fields (fields without default values) must be defined before fields +with default values. This is a Python requirement that will raise a `TypeError` at runtime if +violated. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/disjoint-cast.md b/crates/ty_python_semantic/resources/lint_docs/disjoint-cast.md new file mode 100644 index 0000000000..5436f259eb --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/disjoint-cast.md @@ -0,0 +1,155 @@ +## What it does + +Detects `cast` calls where the inferred type of the value is disjoint from the destination type. + +Two types are disjoint if they are entirely non-overlapping. For example, `str` and `int` are +disjoint types because it is impossible to create a Python object that is both a `str` and an `int` +at the same time: Python forbids multiple inheritance between these two classes: + +```pycon +>>> class StrAndInt(int, str): ... +Traceback (most recent call last): + File "", line 1, in + class StrAndInt(int, str): ... +TypeError: multiple bases have instance lay-out conflict +``` + +This means that any object of type `int` can never also be of type `str`, and any object of type +`str` can never also inhabit the type `int`. The only common subtype of these two types is +[`Never`][never], the uninhabited type, which has no members. + +## Why is this bad? + +`cast()` is deliberately designed as an "escape hatch" in the type system that is neither validated +at runtime nor, by default, by type checkers. While upcasting to a supertype is always sound, and +casting to a subtype can be sound in some situations if accompanied by careful validation checks, +`cast()` is also deliberately designed to allow unsound narrowing, and most useful applications of +`cast()` in real-world code cannot be fully validated by a type checker. + +Nonetheless, even while acknowledging the fact that `cast()` is intentionally designed to allow +unsoundness, casting a value to an entirely *disjoint* type is especially likely to indicate a +mistake in your code. A cast from an `int` to a `str`, for example, likely indicates a bug or +misunderstanding. + +This rule therefore provides a means for codebases to partially validate their uses of `cast()` +without banning the API -- or even banning all unsound uses of the API -- entirely. + +## Example + +```py +from typing import cast + + +def parse(value: int) -> str: + return cast(str, value) # error: [disjoint-cast] +``` + +Casts between overlapping (non-disjoint) types are allowed: + +```py +from collections.abc import Sequence +from typing import cast + + +def validate(numbers: Sequence[int | None]) -> Sequence[int]: + if None in numbers: + raise TypeError("must provide a sequence of numbers!") + return cast(Sequence[int], numbers) +``` + +Note that disjointness between types can sometimes be surprising. For example, `list[int]` is +disjoint from `list[bool]` even though `bool` is a subtype of `int`. Due to the fact that `list` is +[mutable and invariant], it would be deeply unsound for ty to ever narrow an object of type +`list[int]` to the type `list[bool]`. As such, ty will complain about a cast from `list[int]` to +`list[bool]` when this rule is enabled. + +Similarly, two `NewType`s can be disjoint even when they share the same underlying nominal base +type, unless one `NewType` is explicitly declared as a sub-newtype of the other. + +```py +from typing import NewType, cast + + +UserId = NewType("UserId", int) +ProUserId = NewType("ProUserId", int) + + +def f(x: list[int], user_id: UserId): + y = cast(list[bool], x) # error: [disjoint-cast] + pro_user_id = cast(ProUserId, user_id) # error: [disjoint-cast] +``` + +## Alternatives + +In many cases, the diagnostic can be avoided by switching to use covariant generic types rather than +invariant ones: + +```py +# `Sequence`, unlike `list`, is immutable and covariant +from collections.abc import Sequence +from typing import cast + + +def f(x: Sequence[int]): + y = cast(Sequence[bool], x) # no diagnostic +``` + +Though if you're able to use covariant types, a type-safe narrowing mechanism that provides runtime +validation, such as using `TypeIs`, is generally preferable to using `cast`: + +```py +# `Sequence`, unlike `list`, is immutable and covariant +from collections.abc import Sequence +from typing_extensions import TypeIs, reveal_type + + +def is_sequence_of_bools(x: Sequence[int]) -> TypeIs[Sequence[bool]]: + return all(isinstance(item, bool) for item in x) + + +def f(x: Sequence[int]): + assert is_sequence_of_bools(x) + reveal_type(x) # revealed: Sequence[bool] +``` + +If you're unable to switch to an immutable, covariant generic type, other solutions to this +particular diagnostic might include assigning a new list altogether: + +```py +def f(x: list[int]): + y: list[bool] = [] + for item in x: + assert isinstance(item, bool) + y.append(item) +``` + +Or using a `TypeGuard`. While the "narrowing" below is still unsound, there is at least some runtime +validation of the element types taking place, making it superior to the `cast`: + +```py +from typing_extensions import TypeGuard, reveal_type + + +def is_list_of_bools(x: list[int]) -> TypeGuard[list[bool]]: + return all(isinstance(item, bool) for item in x) + + +def f(x: list[int]): + assert is_list_of_bools(x) + reveal_type(x) # revealed: list[bool] +``` + +## Default level + +This rule is disabled by default. It is designed as a strict rule for users who want additional +soundness checks from their type checker, and it may have false positives in some situations. + +## See also + +- The Ruff rule [`banned-api`][banned-api] can be used to ban the use of `cast()` entirely in your + codebase. +- `redundant-cast` detects casts where the value already has the destination type. + +[banned-api]: https://docs.astral.sh/ruff/rules/banned-api/ +[mutable and invariant]: https://docs.astral.sh/ty/reference/typing-faq/#invariant-generics +[never]: https://docs.python.org/3/library/typing.html#typing.Never diff --git a/crates/ty_python_semantic/resources/lint_docs/division-by-zero.md b/crates/ty_python_semantic/resources/lint_docs/division-by-zero.md index a252834b7b..5a5a4e69e9 100644 --- a/crates/ty_python_semantic/resources/lint_docs/division-by-zero.md +++ b/crates/ty_python_semantic/resources/lint_docs/division-by-zero.md @@ -8,8 +8,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. ## Rule status -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/duplicate-kw-only.md b/crates/ty_python_semantic/resources/lint_docs/duplicate-kw-only.md index 88aa531a14..e67e9a0440 100644 --- a/crates/ty_python_semantic/resources/lint_docs/duplicate-kw-only.md +++ b/crates/ty_python_semantic/resources/lint_docs/duplicate-kw-only.md @@ -1,16 +1,13 @@ ## What it does -Checks for dataclass definitions with more than one field -annotated with `KW_ONLY`. +Checks for dataclass definitions with more than one field annotated with `KW_ONLY`. ## Why is this bad? -`dataclasses.KW_ONLY` is a special marker used to -emulate the `*` syntax in normal signatures. -It can only be used once per dataclass. +`dataclasses.KW_ONLY` is a special marker used to emulate the `*` syntax in normal signatures. It +can only be used once per dataclass. -Attempting to annotate two different fields with -it will lead to a runtime error. +Attempting to annotate two different fields with it will lead to a runtime error. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/dynamic-function-decorator-return.md b/crates/ty_python_semantic/resources/lint_docs/dynamic-function-decorator-return.md new file mode 100644 index 0000000000..7d0aa8d898 --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/dynamic-function-decorator-return.md @@ -0,0 +1,96 @@ +## What it does + +Detects decorator applications that replace a function with `Any` or another [dynamic type]. + +## Why is this bad? + +A decorator can replace the function it receives with any object. Type checkers therefore use the +decorator's return type as the type of the decorated function. If the decorator returns `Any` or +`Unknown`, the original type is lost, along with the type checker's ability to catch invalid calls +and attribute accesses. basedpython infers an unannotated return, so a decorator reaches this state +by saying `Any` outright, or by coming from code the checker cannot read: + +```py +from collections.abc import Callable +from typing import Any + + +def untyped_decorator(function: Callable[..., object]) -> Any: + return function + + +# error: "Decorator returns `Any`" +@untyped_decorator +def stringify(value: int) -> str: + return str(value) + + +# No type error is reported, even though `stringify` expects an integer. +stringify("not an integer") +``` + +This rule identifies the point where a decorator erases useful type information, before that +imprecision spreads to every use of the decorated function. It can be especially useful in cases +where the decorator is defined in a third-party library. Whereas linter rules such as +[`ANN201`][ann201] and [`ANN202`][ann202] can complain about missing annotations in your first-party +code, they cannot identify instances where unsound types leak into your code due to missing type +annotations in third-party code installed into `site-packages`. + +## Examples + +`third_party_library.py`: + +```py +from collections.abc import Callable +from typing import Any + + +def untyped_decorator(function: Callable[..., object]) -> Any: + return function +``` + +`first_party.py`: + +```py +from third_party_library import untyped_decorator + + +# error: "Decorator returns `Any`" +@untyped_decorator +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +If making a PR to the third-party library to improve their annotations is not possible, fixes for +this diagnostic could include writing your own decorator or introducing a type-safe wrapper: + +```py +from collections.abc import Callable +from typing import TypeVar + +from third_party_library import untyped_decorator + + +FunctionT = TypeVar("FunctionT", bound=Callable[..., object]) + + +def typed_wrapper(f: FunctionT) -> FunctionT: + decorated = untyped_decorator(f) + assert decorated is f + return decorated + + +@typed_wrapper +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +## Default level + +This rule is disabled by default. It is intended for advanced users wanting additional soundness +checks from their type checker, not for users who have just started to use type checkers on their +Python code. + +[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ +[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ +[dynamic type]: https://typing.python.org/en/latest/spec/glossary.html#term-dynamic-type diff --git a/crates/ty_python_semantic/resources/lint_docs/empty-body.md b/crates/ty_python_semantic/resources/lint_docs/empty-body.md index 085b28f1d5..36555eb6c5 100644 --- a/crates/ty_python_semantic/resources/lint_docs/empty-body.md +++ b/crates/ty_python_semantic/resources/lint_docs/empty-body.md @@ -2,21 +2,20 @@ Detects functions with empty bodies that have a non-`None` return type annotation. -The errors reported by this rule have the same motivation as the `invalid-return-type` -rule. The diagnostic exists as a separate error code to allow users to disable this -rule while prototyping code. While we strongly recommend enabling this rule if -possible, users migrating from other type checkers may also find it useful to -temporarily disable this rule on some or all of their codebase if they find it -results in a large number of diagnostics. +The errors reported by this rule have the same motivation as the `invalid-return-type` rule. The +diagnostic exists as a separate error code to allow users to disable this rule while prototyping +code. While we strongly recommend enabling this rule if possible, users migrating from other type +checkers may also find it useful to temporarily disable this rule on some or all of their codebase +if they find it results in a large number of diagnostics. ## Why is this bad? -A function with an empty body (containing only `...`, `pass`, or a docstring) will -implicitly return `None` at runtime. Returning `None` when the return type is non-`None` -is unsound, and will lead to ty inferring incorrect types elsewhere. +A function with an empty body (containing only `...`, `pass`, or a docstring) will implicitly return +`None` at runtime. Returning `None` when the return type is non-`None` is unsound, and will lead to +ty inferring incorrect types elsewhere. -Functions with empty bodies are permitted in certain contexts where they serve as -declarations rather than implementations: +Functions with empty bodies are permitted in certain contexts where they serve as declarations +rather than implementations: - Functions in stub files (`.pyi`) - Methods in Protocol classes diff --git a/crates/ty_python_semantic/resources/lint_docs/final-on-non-method.md b/crates/ty_python_semantic/resources/lint_docs/final-on-non-method.md index f726cf33bf..78414f97ca 100644 --- a/crates/ty_python_semantic/resources/lint_docs/final-on-non-method.md +++ b/crates/ty_python_semantic/resources/lint_docs/final-on-non-method.md @@ -4,9 +4,8 @@ Checks for `@final` decorators applied to non-method functions. ## Why is this bad? -The `@final` decorator is only meaningful on methods and classes. -Applying it to a module-level function or a nested function has no -effect and is likely a mistake. +The `@final` decorator is only meaningful on methods and classes. Applying it to a module-level +function or a nested function has no effect and is likely a mistake. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/final-without-value.md b/crates/ty_python_semantic/resources/lint_docs/final-without-value.md index d788e85229..9365c0053c 100644 --- a/crates/ty_python_semantic/resources/lint_docs/final-without-value.md +++ b/crates/ty_python_semantic/resources/lint_docs/final-without-value.md @@ -1,14 +1,14 @@ ## What it does -Checks for `Final` symbols that are declared without a value and are never -assigned a value in their scope. +Checks for `Final` symbols that are declared without a value and are never assigned a value in their +scope. ## Why is this bad? -A `Final` symbol must be initialized with a value at the time of declaration -or in a subsequent assignment. At module or function scope, the assignment must -occur in the same scope. In a class body, the assignment may occur in `__init__`. -Protocol members are declarations of an interface and do not require a value. +A `Final` symbol must be initialized with a value at the time of declaration or in a subsequent +assignment. At module or function scope, the assignment must occur in the same scope. In a class +body, the assignment may occur in `__init__`. Protocol members are declarations of an interface and +do not require a value. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/ignore-comment-unknown-rule.md b/crates/ty_python_semantic/resources/lint_docs/ignore-comment-unknown-rule.md index 43b2451b51..3a29c7a129 100644 --- a/crates/ty_python_semantic/resources/lint_docs/ignore-comment-unknown-rule.md +++ b/crates/ty_python_semantic/resources/lint_docs/ignore-comment-unknown-rule.md @@ -1,11 +1,12 @@ ## What it does -Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule. +Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint +rule. ## Why is this bad? -A `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match -any known rule will not suppress any type errors, and is probably a mistake. +A `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match any +known rule will not suppress any type errors, and is probably a mistake. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/implicit-declaration.md b/crates/ty_python_semantic/resources/lint_docs/implicit-declaration.md index b223ee0f8b..77f376fa03 100644 --- a/crates/ty_python_semantic/resources/lint_docs/implicit-declaration.md +++ b/crates/ty_python_semantic/resources/lint_docs/implicit-declaration.md @@ -4,22 +4,20 @@ Checks for a variable that a basedpython file assigns without ever declaring it. ## Why is this bad? -Python introduces a variable by assigning to it, so a typo makes a new variable -rather than an error, and reading a statement tells you nothing about whether -the name is new or one you have seen before. +Python introduces a variable by assigning to it, so a typo makes a new variable rather than an +error, and reading a statement tells you nothing about whether the name is new or one you have seen +before. -basedpython has a keyword for each: `let` for a binding that never changes, and -`var` for one that does. With this rule on, every variable a scope binds has to -be declared once with one of them, and every later assignment is visibly a -re-assignment. +basedpython has a keyword for each: `let` for a binding that never changes, and `var` for one that +does. With this rule on, every variable a scope binds has to be declared once with one of them, and +every later assignment is visibly a re-assignment. -This rule is off by default, because a file written without the keywords is -valid basedpython. +This rule is off by default, because a file written without the keywords is valid basedpython. ## Examples -Every assignment to a name the scope never declares is reported, so a variable -introduced this way is reported wherever it is written: +Every assignment to a name the scope never declares is reported, so a variable introduced this way +is reported wherever it is written: `undeclared.by`: @@ -37,5 +35,5 @@ var count = 0 count = count + 1 ``` -An assignment to something other than a plain name — an attribute, a subscript, -an item of an unpacking — is not a declaration, and is never reported. +An assignment to something other than a plain name — an attribute, a subscript, an item of an +unpacking — is not a declaration, and is never reported. diff --git a/crates/ty_python_semantic/resources/lint_docs/index-out-of-bounds.md b/crates/ty_python_semantic/resources/lint_docs/index-out-of-bounds.md index 6e9bf07632..cee4a068d4 100644 --- a/crates/ty_python_semantic/resources/lint_docs/index-out-of-bounds.md +++ b/crates/ty_python_semantic/resources/lint_docs/index-out-of-bounds.md @@ -1,7 +1,6 @@ ## What it does -Checks for attempts to use an out of bounds index to get an item from -a container. +Checks for attempts to use an out of bounds index to get an item from a container. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/ineffective-final.md b/crates/ty_python_semantic/resources/lint_docs/ineffective-final.md index 8c3a1904c3..c990385144 100644 --- a/crates/ty_python_semantic/resources/lint_docs/ineffective-final.md +++ b/crates/ty_python_semantic/resources/lint_docs/ineffective-final.md @@ -4,9 +4,9 @@ Checks for calls to `final()` that type checkers cannot interpret. ## Why is this bad? -The `final()` function is designed to be used as a decorator. When called directly -as a function (e.g., `final(type(...))`), type checkers will not understand the -application of `final` and will not prevent subclassing. +The `final()` function is designed to be used as a decorator. When called directly as a function +(e.g., `final(type(...))`), type checkers will not understand the application of `final` and will +not prevent subclassing. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/instance-layout-conflict.md b/crates/ty_python_semantic/resources/lint_docs/instance-layout-conflict.md index e9f8292d5d..7b819f935d 100644 --- a/crates/ty_python_semantic/resources/lint_docs/instance-layout-conflict.md +++ b/crates/ty_python_semantic/resources/lint_docs/instance-layout-conflict.md @@ -1,27 +1,23 @@ ## What it does -Checks for classes definitions which will fail at runtime due to -"instance memory layout conflicts". +Checks for classes definitions which will fail at runtime due to "instance memory layout conflicts". -This error is usually caused by attempting to combine multiple classes -that define non-empty `__slots__` in a class's [Method Resolution Order][method-resolution-order] -(MRO), or by attempting to combine multiple builtin classes in a class's -MRO. +This error is usually caused by attempting to combine multiple classes that define non-empty +`__slots__` in a class's [Method Resolution Order][method-resolution-order] (MRO), or by attempting +to combine multiple builtin classes in a class's MRO. ## Why is this bad? -Inheriting from bases with conflicting instance memory layouts -will lead to a `TypeError` at runtime. +Inheriting from bases with conflicting instance memory layouts will lead to a `TypeError` at +runtime. -An instance memory layout conflict occurs when CPython cannot determine -the memory layout instances of a class should have, because the instance -memory layout of one of its bases conflicts with the instance memory layout -of one or more of its other bases. +An instance memory layout conflict occurs when CPython cannot determine the memory layout instances +of a class should have, because the instance memory layout of one of its bases conflicts with the +instance memory layout of one or more of its other bases. -For example, if a Python class defines non-empty `__slots__`, this will -impact the memory layout of instances of that class. Multiple inheritance -from more than one different class defining non-empty `__slots__` is not -allowed: +For example, if a Python class defines non-empty `__slots__`, this will impact the memory layout of +instances of that class. Multiple inheritance from more than one different class defining non-empty +`__slots__` is not allowed: ```python class A: @@ -36,17 +32,16 @@ class B: class C(A, B): ... # error ``` -An instance layout conflict can also be caused by attempting to use -multiple inheritance with two builtin classes, due to the way that these -classes are implemented in a CPython C extension: +An instance layout conflict can also be caused by attempting to use multiple inheritance with two +builtin classes, due to the way that these classes are implemented in a CPython C extension: ```python # TypeError: multiple bases have instance lay-out conflict class A(int, float): ... # error ``` -Note that pure-Python classes with no `__slots__`, or pure-Python classes -with empty `__slots__`, are always compatible: +Note that pure-Python classes with no `__slots__`, or pure-Python classes with empty `__slots__`, +are always compatible: ```python class A: ... @@ -66,17 +61,16 @@ class D(A, B, C): ... ## Known problems -Classes that have "dynamic" definitions of `__slots__` (definitions do not consist -of string literals, or tuples of string literals) are not currently considered disjoint -bases by ty. - -Additionally, this check is not exhaustive: many C extensions (including several in -the standard library) define classes that use extended memory layouts and thus cannot -coexist in a single MRO. Since it is currently not possible to represent this fact in -stub files, having a full knowledge of these classes is also impossible. When it comes -to classes that do not define `__slots__` at the Python level, therefore, ty, currently -only hard-codes a number of cases where it knows that a class will produce instances with -an atypical memory layout. +Classes whose `__slots__` values cannot be determined statically are not always considered disjoint +bases by ty. Static definitions can include string literals, fixed-length tuples, and literal lists, +sets, or dictionaries of string literals. + +Additionally, this check is not exhaustive: many C extensions (including several in the standard +library) define classes that use extended memory layouts and thus cannot coexist in a single MRO. +Since it is currently not possible to represent this fact in stub files, having a full knowledge of +these classes is also impossible. When it comes to classes that do not define `__slots__` at the +Python level, therefore, ty, currently only hard-codes a number of cases where it knows that a class +will produce instances with an atypical memory layout. ## Further reading diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-argument-type.md b/crates/ty_python_semantic/resources/lint_docs/invalid-argument-type.md index c49208ca30..478196c43c 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-argument-type.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-argument-type.md @@ -4,9 +4,9 @@ Detects call arguments whose type is not assignable to the corresponding typed p ## Why is this bad? -Passing an argument of a type the function (or callable object) does not accept violates -the expectations of the function author and may cause unexpected runtime errors within the -body of the function. +Passing an argument of a type the function (or callable object) does not accept violates the +expectations of the function author and may cause unexpected runtime errors within the body of the +function. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-assignment.md b/crates/ty_python_semantic/resources/lint_docs/invalid-assignment.md index 2fce4c9d7b..130b64b3c9 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-assignment.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-assignment.md @@ -1,12 +1,11 @@ ## What it does -Checks for assignments where the type of the value -is not [assignable to] the type of the assignee. +Checks for assignments where the type of the value is not [assignable to] the type of the assignee. ## Why is this bad? -Such assignments break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Such assignments break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-access.md b/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-access.md index cbb8b62f23..227e6abdb2 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-access.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-access.md @@ -1,15 +1,21 @@ ## What it does -Checks for assignments to class variables from instances -and assignments to instance-only attributes from their class. +Checks for assignments to class variables from instances and assignments to instance-only attributes +from their class. Also checks for reads and writes of generic instance attributes through a generic +class or a specialized generic alias. -An "instance-only" variable is one which is only ever assigned to or declared -when accessed via `self` in an instance method. +An "instance-only" variable is one which is only ever assigned to or declared when accessed via +`self` in an instance method. + +A generic instance attribute has a type that depends on the class's type parameters. Specializing a +generic class does not create separate class attribute storage, so these attributes cannot be +accessed through the generic class or a specialized alias. Access through a `type[...]` receiver is +allowed because it can refer to a concrete subclass with its own class attributes. ## Why is this bad? -Incorrect assignments break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Incorrect assignments break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. ## Examples @@ -42,3 +48,20 @@ C().class_var = 3 # error # Cannot assign to instance-only variable from class C.instance_only_var = 56 # error ``` + +```python +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Box(Generic[T]): + value: T + + +Box[int].value = 1 # error +Box.value # error + +box = Box[int]() +box.value = 1 # okay +``` diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-override.md b/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-override.md index 39fdb06157..14dd1a01ee 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-override.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-attribute-override.md @@ -1,17 +1,16 @@ ## What it does -Detects attribute overrides that change whether an inherited attribute -is a class variable or an instance variable. +Detects attribute overrides that change whether an inherited attribute is a class variable or an +instance variable. -This rule currently only covers class-variable and instance-variable -category changes. +This rule currently only covers class-variable and instance-variable category changes. ## Why is this bad? -Pure class variables and instance variables have different access and -assignment behavior. Overriding one with the other violates the -[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"), because code that is valid for -the superclass may no longer be valid for the subclass. +Pure class variables and instance variables have different access and assignment behavior. +Overriding one with the other violates the +[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"), because code that is valid +for the superclass may no longer be valid for the subclass. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-context-manager.md b/crates/ty_python_semantic/resources/lint_docs/invalid-context-manager.md index 6ea70624b4..9f32a2ea5b 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-context-manager.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-context-manager.md @@ -1,7 +1,6 @@ ## What it does -Checks for expressions used in `with` statements -that do not implement the context manager protocol. +Checks for expressions used in `with` statements that do not implement the context manager protocol. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-dataclass.md b/crates/ty_python_semantic/resources/lint_docs/invalid-dataclass.md index ebd24edcd2..8d0b237c2a 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-dataclass.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-dataclass.md @@ -4,17 +4,17 @@ Checks for invalid applications of the `@dataclass` decorator. ## Why is this bad? -Applying `@dataclass` with incompatible arguments raises an exception while creating the -class: +Applying `@dataclass` with incompatible arguments raises an exception while creating the class: - `order=True` with `eq=False` - `weakref_slot=True` with `slots=False` +- `slots=True` when the class already defines `__slots__` -Applying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`, -`Enum`, or `Protocol` is also invalid: +Applying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`, `Enum`, or `Protocol` +is also invalid: -- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when - instantiating the class. +- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when instantiating the + class. - `Enum` classes with `@dataclass` are [explicitly not supported]. - `Protocol` classes define interfaces and cannot be instantiated. diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-declaration.md b/crates/ty_python_semantic/resources/lint_docs/invalid-declaration.md index ee290fed6f..66e925f260 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-declaration.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-declaration.md @@ -1,12 +1,12 @@ ## What it does -Checks for declarations where the inferred type of an existing symbol -is not [assignable to] its post-hoc declared type. +Checks for declarations where the inferred type of an existing symbol is not [assignable to] its +post-hoc declared type. ## Why is this bad? -Such declarations break the rules of the type system and -weaken a type checker's ability to accurately reason about your code. +Such declarations break the rules of the type system and weaken a type checker's ability to +accurately reason about your code. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-enum-member-annotation.md b/crates/ty_python_semantic/resources/lint_docs/invalid-enum-member-annotation.md index 727f4d7e96..3dc833ab96 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-enum-member-annotation.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-enum-member-annotation.md @@ -4,13 +4,12 @@ Checks for enum members that have explicit type annotations. ## Why is this bad? -The [typing spec] states that type checkers should infer a literal type -for all enum members. An explicit type annotation on an enum member is -misleading because the annotated type will be incorrect — the actual -runtime type is the enum class itself, not the annotated type. +The [typing spec] states that type checkers should infer a literal type for all enum members. An +explicit type annotation on an enum member is misleading because the annotated type will be +incorrect — the actual runtime type is the enum class itself, not the annotated type. -In CPython's `enum` module, annotated assignments with values are still -treated as members at runtime, but the annotation will confuse readers of the code. +In CPython's `enum` module, annotated assignments with values are still treated as members at +runtime, but the annotation will confuse readers of the code. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-exception-caught.md b/crates/ty_python_semantic/resources/lint_docs/invalid-exception-caught.md index 43a671356a..20171fb4b7 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-exception-caught.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-exception-caught.md @@ -45,4 +45,5 @@ except ZeroDivisionError: ## Ruff rule -This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) +This rule corresponds to Ruff's +[`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-explicit-override.md b/crates/ty_python_semantic/resources/lint_docs/invalid-explicit-override.md index fb38d467dc..18a6ba7062 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-explicit-override.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-explicit-override.md @@ -1,11 +1,12 @@ ## What it does -Checks for methods that are decorated with `@override` but do not override any method in a superclass. +Checks for methods that are decorated with `@override` but do not override any method in a +superclass. ## Why is this bad? -Decorating a method with `@override` declares to the type checker that the intention is that it should -override a method from a superclass. +Decorating a method with `@override` declares to the type checker that the intention is that it +should override a method from a superclass. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-frozen-dataclass-subclass.md b/crates/ty_python_semantic/resources/lint_docs/invalid-frozen-dataclass-subclass.md index 70333ff4a4..cb8f02e622 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-frozen-dataclass-subclass.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-frozen-dataclass-subclass.md @@ -7,8 +7,7 @@ Checks for dataclasses with invalid frozen inheritance: ## Why is this bad? -Python raises a `TypeError` at runtime when either of these inheritance -patterns occurs. +Python raises a `TypeError` at runtime when either of these inheritance patterns occurs. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-generic-class.md b/crates/ty_python_semantic/resources/lint_docs/invalid-generic-class.md index 8a9267afd4..212e17d875 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-generic-class.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-generic-class.md @@ -4,8 +4,8 @@ Checks for the creation of invalid generic classes ## Why is this bad? -There are several requirements that you must follow when defining a generic class. -Many of these result in `TypeError` being raised at runtime if they are violated. +There are several requirements that you must follow when defining a generic class. Many of these +result in `TypeError` being raised at runtime if they are violated. ## Examples @@ -31,8 +31,8 @@ class D(Generic[U, T]): ... # error # covariant type parameter used in a position that requires contravariance -class E(Generic[V]): # error - def set(self, value: V) -> None: ... +class E(Generic[V]): + def set(self, value: V) -> None: ... # error ``` ## References diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-generic-enum.md b/crates/ty_python_semantic/resources/lint_docs/invalid-generic-enum.md index 1d9394b351..bd09fa2a4c 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-generic-enum.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-generic-enum.md @@ -4,10 +4,9 @@ Checks for enum classes that are also generic. ## Why is this bad? -Enum classes cannot be generic. Python does not support generic enums: -attempting to create one will either result in an immediate `TypeError` -at runtime, or will create a class that cannot be specialized in the way -that a normal generic class can. +Enum classes cannot be generic. Python does not support generic enums: attempting to create one will +either result in an immediate `TypeError` at runtime, or will create a class that cannot be +specialized in the way that a normal generic class can. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-key.md b/crates/ty_python_semantic/resources/lint_docs/invalid-key.md index 064af26983..e419c4f5dd 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-key.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-key.md @@ -1,14 +1,13 @@ ## What it does -Checks for subscript accesses with invalid keys and `TypedDict` construction with an -unknown key. +Checks for subscript accesses with invalid keys and `TypedDict` construction with an unknown key. ## Why is this bad? Subscripting with an invalid key will raise a `KeyError` at runtime. -Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is -`closed=true` it also violates the expectations of the type. +Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is `closed=true` +it also violates the expectations of the type. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-legacy-positional-parameter.md b/crates/ty_python_semantic/resources/lint_docs/invalid-legacy-positional-parameter.md index c02ce7ed96..3a35965398 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-legacy-positional-parameter.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-legacy-positional-parameter.md @@ -1,20 +1,19 @@ ## What it does -Checks for parameters that appear to be attempting to use the legacy convention -to specify that a parameter is positional-only, but do so incorrectly. +Checks for parameters that appear to be attempting to use the legacy convention to specify that a +parameter is positional-only, but do so incorrectly. -The "legacy convention" for specifying positional-only parameters was -specified in [PEP 484][pep-484]. It states that parameters with names starting with -`__` should be considered positional-only by type checkers. [PEP 570][pep-570], introduced -in Python 3.8, added dedicated syntax for specifying positional-only parameters, -rendering the legacy convention obsolete. However, some codebases may still -use the legacy convention for compatibility with older Python versions. +The "legacy convention" for specifying positional-only parameters was specified in +[PEP 484][pep-484]. It states that parameters with names starting with `__` should be considered +positional-only by type checkers. [PEP 570][pep-570], introduced in Python 3.8, added dedicated +syntax for specifying positional-only parameters, rendering the legacy convention obsolete. However, +some codebases may still use the legacy convention for compatibility with older Python versions. ## Why is this bad? -In most cases, a type checker will not consider a parameter to be positional-only -if it comes after a positional-or-keyword parameter, even if its name starts with -`__`. This may be unexpected to the author of the code. +In most cases, a type checker will not consider a parameter to be positional-only if it comes after +a positional-or-keyword parameter, even if its name starts with `__`. This may be unexpected to the +author of the code. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-match-pattern.md b/crates/ty_python_semantic/resources/lint_docs/invalid-match-pattern.md index 37ce6384ce..d43fb5f7be 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-match-pattern.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-match-pattern.md @@ -4,8 +4,7 @@ Checks for invalid match patterns. ## Why is this bad? -Invalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime. -This includes: +Invalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime. This includes: - Using a non-type object in a class pattern. - Providing positional subpatterns when `__match_args__` is missing or has an invalid static type. diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-metaclass.md b/crates/ty_python_semantic/resources/lint_docs/invalid-metaclass.md index 6c0211d5dd..f3f1fe3e37 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-metaclass.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-metaclass.md @@ -4,9 +4,8 @@ Checks for arguments to `metaclass=` that are invalid. ## Why is this bad? -Python allows arbitrary expressions to be used as the argument to `metaclass=`. -These expressions, however, need to be callable and accept the same arguments -as `type.__new__`. +Python allows arbitrary expressions to be used as the argument to `metaclass=`. These expressions, +however, need to be callable and accept the same arguments as `type.__new__`. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-method-override.md b/crates/ty_python_semantic/resources/lint_docs/invalid-method-override.md index ca9799f986..f4946538ea 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-method-override.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-method-override.md @@ -1,20 +1,20 @@ ## What it does -Detects method overrides that violate the [Liskov Substitution Principle][liskov-substitution-principle] ("LSP"). +Detects method overrides that violate the +[Liskov Substitution Principle][liskov-substitution-principle] ("LSP"). -The LSP states that an instance of a subtype should be substitutable for an instance of its supertype. -Applied to Python, this means: +The LSP states that an instance of a subtype should be substitutable for an instance of its +supertype. Applied to Python, this means: -1. All argument combinations a superclass method accepts - must also be accepted by an overriding subclass method. -1. The return type of an overriding subclass method must be a subtype - of the return type of the superclass method. +1. All argument combinations a superclass method accepts must also be accepted by an overriding + subclass method. +1. The return type of an overriding subclass method must be a subtype of the return type of the + superclass method. ## Why is this bad? -Violating the Liskov Substitution Principle will lead to many of ty's assumptions and -inferences being incorrect, which will mean that it will fail to catch many possible -type errors in your code. +Violating the Liskov Substitution Principle will lead to many of ty's assumptions and inferences +being incorrect, which will mean that it will fail to catch many possible type errors in your code. ## Example @@ -56,8 +56,8 @@ accepts_super(Sub2()) ### Why does ty complain about my `__eq__` method? -`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary -objects as their second argument, for example: +`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary objects as their +second argument, for example: ```python class A: @@ -71,29 +71,28 @@ class A: return self.x == other.x ``` -If `A.__eq__` here were annotated as only accepting `A` instances for its second argument, -it would imply that you wouldn't be able to use `==` between instances of `A` and -instances of unrelated classes without an exception possibly being raised. While some -classes in Python do indeed behave this way, the strongly held convention is that it should -be avoided wherever possible. As part of this check, therefore, ty enforces that `__eq__` -and `__ne__` methods accept `object` as their second argument. +If `A.__eq__` here were annotated as only accepting `A` instances for its second argument, it would +imply that you wouldn't be able to use `==` between instances of `A` and instances of unrelated +classes without an exception possibly being raised. While some classes in Python do indeed behave +this way, the strongly held convention is that it should be avoided wherever possible. As part of +this check, therefore, ty enforces that `__eq__` and `__ne__` methods accept `object` as their +second argument. ### Why does ty disagree with Ruff about how to write my method? -Ruff has several rules that will encourage you to rename a parameter, or change its type -signature, if it thinks you're falling into a certain anti-pattern. For example, Ruff's -[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an -unused parameter should either be removed or renamed to start with `_`. Applying either of -these suggestions can cause ty to start reporting an `invalid-method-override` error if -the function in question is a method on a subclass that overrides a method on a superclass, -and the change would cause the subclass method to no longer accept all argument combinations -that the superclass method accepts. - -This can usually be resolved by adding [`@typing.override`][override] to your method -definition. Ruff knows that a method decorated with `@typing.override` is intended to -override a method by the same name on a superclass, and avoids reporting rules like ARG002 -for such methods; it knows that the changes recommended by ARG002 would violate the Liskov -Substitution Principle. +Ruff has several rules that will encourage you to rename a parameter, or change its type signature, +if it thinks you're falling into a certain anti-pattern. For example, Ruff's +[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an unused +parameter should either be removed or renamed to start with `_`. Applying either of these +suggestions can cause ty to start reporting an `invalid-method-override` error if the function in +question is a method on a subclass that overrides a method on a superclass, and the change would +cause the subclass method to no longer accept all argument combinations that the superclass method +accepts. + +This can usually be resolved by adding [`@typing.override`][override] to your method definition. +Ruff knows that a method decorated with `@typing.override` is intended to override a method by the +same name on a superclass, and avoids reporting rules like ARG002 for such methods; it knows that +the changes recommended by ARG002 would violate the Liskov Substitution Principle. Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-module-getattr-call.md b/crates/ty_python_semantic/resources/lint_docs/invalid-module-getattr-call.md new file mode 100644 index 0000000000..8f55d56d53 --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-module-getattr-call.md @@ -0,0 +1,24 @@ +## What it does + +Checks for imports that fail when calling a module-level `__getattr__` function. + +## Why is this bad? + +If a module defines `__getattr__`, Python calls it when a `from` import requests a name that is not +otherwise defined. The import raises an exception if `__getattr__` cannot accept the requested name. + +## Examples + +`module.py`: + +```python +def __getattr__() -> str: + return "fallback" +``` + +`main.py`: + +```python +# TypeError: __getattr__() takes 0 positional arguments but 1 was given +from module import missing # error +``` diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple-override.md b/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple-override.md index 14e3812de5..1823d39825 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple-override.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple-override.md @@ -4,14 +4,12 @@ Checks for subclass members that override inherited `NamedTuple` fields. ## Why is this bad? -Reusing an inherited `NamedTuple` field name in a subclass creates a -class where tuple indexing and `repr()` still reflect the original -field, while attribute access follows the subclass member. +Reusing an inherited `NamedTuple` field name in a subclass creates a class where tuple indexing and +`repr()` still reflect the original field, while attribute access follows the subclass member. ## Default level -This rule is a warning by default because these overrides do not make -the class invalid at runtime. +This rule is a warning by default because these overrides do not make the class invalid at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple.md b/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple.md index 690ed3c1fe..862e36aaed 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-named-tuple.md @@ -4,16 +4,14 @@ Checks for invalidly defined `NamedTuple` classes. ## Why is this bad? -An invalidly defined `NamedTuple` class may lead to the type checker -drawing incorrect conclusions. It may also lead to `TypeError`s or -`AttributeError`s at runtime. +An invalidly defined `NamedTuple` class may lead to the type checker drawing incorrect conclusions. +It may also lead to `TypeError`s or `AttributeError`s at runtime. ## Examples -A class definition cannot combine `NamedTuple` with other base classes -in multiple inheritance; doing so raises a `TypeError` at runtime. The sole -exception to this rule is `Generic[]`, which can be used alongside `NamedTuple` -in a class's bases list. +A class definition cannot combine `NamedTuple` with other base classes in multiple inheritance; +doing so raises a `TypeError` at runtime. The sole exception to this rule is `Generic[]`, which can +be used alongside `NamedTuple` in a class's bases list. ```pycon >>> from typing import NamedTuple @@ -30,9 +28,9 @@ Further, `NamedTuple` field names cannot start with an underscore: ValueError: Field names cannot start with an underscore: '_bar' ``` -`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, -`_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes -without a type annotation will raise an `AttributeError` at runtime. +`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, `_replace`, +etc.) that cannot be overwritten. Attempting to assign to these attributes without a type annotation +will raise an `AttributeError` at runtime. ```pycon >>> from typing import NamedTuple @@ -42,8 +40,8 @@ without a type annotation will raise an `AttributeError` at runtime. AttributeError: Cannot overwrite NamedTuple attribute _asdict ``` -Finally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type -qualifiers. These qualifiers also cause a runtime error when annotations are evaluated eagerly: +Finally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type qualifiers. These +qualifiers also cause a runtime error when annotations are evaluated eagerly: ```pycon >>> from typing import ClassVar, NamedTuple diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-overload.md b/crates/ty_python_semantic/resources/lint_docs/invalid-overload.md index 0aaf3f6080..25aa375473 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-overload.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-overload.md @@ -5,9 +5,9 @@ Checks for various invalid `@overload` usages. ## Why is this bad? The `@overload` decorator is used to define functions and methods that accepts different -combinations of arguments and return different types based on the arguments passed. This is -mainly beneficial for type checkers. But, if the `@overload` usage is invalid, the type -checker may not be able to provide correct type information. +combinations of arguments and return different types based on the arguments passed. This is mainly +beneficial for type checkers. But, if the `@overload` usage is invalid, the type checker may not be +able to provide correct type information. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-parameter-default.md b/crates/ty_python_semantic/resources/lint_docs/invalid-parameter-default.md index eed9adf772..9161cb931e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-parameter-default.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-parameter-default.md @@ -1,12 +1,11 @@ ## What it does -Checks for default values that can't be -assigned to the parameter's annotated type. +Checks for default values that can't be assigned to the parameter's annotated type. ## Why is this bad? -This breaks the rules of the type system and -weakens a type checker's ability to accurately reason about your code. +This breaks the rules of the type system and weakens a type checker's ability to accurately reason +about your code. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-protocol.md b/crates/ty_python_semantic/resources/lint_docs/invalid-protocol.md index e1198ff8f2..3379963ee5 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-protocol.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-protocol.md @@ -1,16 +1,15 @@ ## What it does -Checks for protocol classes that will raise `TypeError` at runtime. +Checks for protocol classes that are invalid at runtime or do not satisfy the typing specification. ## Why is this bad? -An invalidly defined protocol class may lead to the type checker inferring -unexpected things. It may also lead to `TypeError`s at runtime. +An invalidly defined protocol class may lead to the type checker inferring unexpected things or +accepting unsafe operations. Some invalid protocol definitions also raise `TypeError` at runtime. ## Examples -A `Protocol` class cannot inherit from a non-`Protocol` class; -this raises a `TypeError` at runtime: +A `Protocol` class cannot inherit from a non-`Protocol` class; this raises a `TypeError` at runtime: ```pycon >>> from typing import Protocol @@ -20,3 +19,20 @@ Traceback (most recent call last): class Foo(int, Protocol): ... TypeError: Protocols can only inherit from other protocols, got ``` + +A generic protocol's declared type-variable variance must match how that variable is used by its +protocol members. For example, a type variable that appears only in a method's return type must be +covariant: + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + + +class Source(Protocol[T]): # error: [invalid-protocol] + def read(self) -> T: ... +``` + +Although Python constructs this protocol successfully at runtime, it is invalid for static typing. +Declare the type variable with `TypeVar("T", covariant=True)` instead. diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-raise.md b/crates/ty_python_semantic/resources/lint_docs/invalid-raise.md index ec6802bdc0..d8bfe4a8a7 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-raise.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-raise.md @@ -1,11 +1,11 @@ -Checks for `raise` statements that raise non-exceptions or use invalid -causes for their raised exceptions. +Checks for `raise` statements that raise non-exceptions or use invalid causes for their raised +exceptions. ## Why is this bad? -Only subclasses or instances of `BaseException` can be raised. -For an exception's cause, the same rules apply, except that `None` is also -permitted. Violating these rules results in a `TypeError` at runtime. +Only subclasses or instances of `BaseException` can be raised. For an exception's cause, the same +rules apply, except that `None` is also permitted. Violating these rules results in a `TypeError` at +runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-return-type.md b/crates/ty_python_semantic/resources/lint_docs/invalid-return-type.md index 817adfb57d..8de2a3b641 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-return-type.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-return-type.md @@ -2,13 +2,13 @@ Detects returned values that can't be assigned to the function's annotated return type. -Note that the special case of a function with a non-`None` return type and an empty body -is handled by the separate `empty-body` error code. +Note that the special case of a function with a non-`None` return type and an empty body is handled +by the separate `empty-body` error code. ## Why is this bad? -Returning an object of a type incompatible with the annotated return type -is unsound, and will lead to ty inferring incorrect types elsewhere. +Returning an object of a type incompatible with the annotated return type is unsound, and will lead +to ty inferring incorrect types elsewhere. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-static-resource.md b/crates/ty_python_semantic/resources/lint_docs/invalid-static-resource.md index 557d11bba0..74c4779890 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-static-resource.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-static-resource.md @@ -4,10 +4,10 @@ Checks for basedpython static resource imports that cannot be read. ## Why is this bad? -`import "data/config.yaml" as config` says the file is part of the program. A -path that names nothing, a path that names a place on one machine, a file in a -format that is not `.json`, `.toml`, `.yaml` or `.yml`, and a document the -format's own parser rejects all leave the import with no value to bind. +`import "data/config.yaml" as config` says the file is part of the program. A path that names +nothing, a path that names a place on one machine, a file in a format that is not `.json`, `.toml`, +`.yaml` or `.yml`, and a document the format's own parser rejects all leave the import with no value +to bind. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-syntax-in-forward-annotation.md b/crates/ty_python_semantic/resources/lint_docs/invalid-syntax-in-forward-annotation.md index 6e66ff2090..44cd7a3ead 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-syntax-in-forward-annotation.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-syntax-in-forward-annotation.md @@ -1,18 +1,15 @@ ## What it does -Checks for string-literal annotations where the string cannot be -parsed as a Python expression. +Checks for string-literal annotations where the string cannot be parsed as a Python expression. ## Why is this bad? -Type annotations are expected to be Python expressions that -describe the expected type of a variable, parameter, attribute or -`return` statement. +Type annotations are expected to be Python expressions that describe the expected type of a +variable, parameter, attribute or `return` statement. -Type annotations are permitted to be string-literal expressions, in -order to enable forward references to names not yet defined. -However, it must be possible to parse the contents of that string -literal as a normal Python expression. +Type annotations are permitted to be string-literal expressions, in order to enable forward +references to names not yet defined. However, it must be possible to parse the contents of that +string literal as a normal Python expression. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-total-ordering.md b/crates/ty_python_semantic/resources/lint_docs/invalid-total-ordering.md index ce4b38dcdc..3338110cdd 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-total-ordering.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-total-ordering.md @@ -1,12 +1,12 @@ ## What it does -Checks for classes decorated with `@functools.total_ordering` that don't -define any ordering method (`__lt__`, `__le__`, `__gt__`, or `__ge__`). +Checks for classes decorated with `@functools.total_ordering` that don't define any ordering method +(`__lt__`, `__le__`, `__gt__`, or `__ge__`). ## Why is this bad? -The `@total_ordering` decorator requires the class to define at least one -ordering method. If none is defined, Python raises a `ValueError` at runtime. +The `@total_ordering` decorator requires the class to define at least one ordering method. If none +is defined, Python raises a `ValueError` at runtime. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-arguments.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-arguments.md index b349656694..3536375513 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-arguments.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-arguments.md @@ -4,10 +4,9 @@ Checks for invalid type arguments in explicit type specialization. ## Why is this bad? -Providing the wrong number of type arguments or type arguments that don't -satisfy the type variable's bounds or constraints will lead to incorrect -type inference and may indicate a misunderstanding of the generic type's -interface. +Providing the wrong number of type arguments or type arguments that don't satisfy the type +variable's bounds or constraints will lead to incorrect type inference and may indicate a +misunderstanding of the generic type's interface. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-checking-constant.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-checking-constant.md index 7033a5be5c..aafd299236 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-checking-constant.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-checking-constant.md @@ -1,15 +1,15 @@ ## What it does -Checks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an -annotation not assignable from `bool`. +Checks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an annotation not +assignable from `bool`. ## Why is this bad? -The name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional -code seen only by the type checker, and not at runtime. Normally this flag is imported from -`typing` or `typing_extensions`, but it can also be defined locally. If defined locally, it -must be assigned the value `False` at runtime; the type checker will consider its value to -be `True`. If annotated, it must be annotated as a type that can accept `bool` values. +The name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional code seen +only by the type checker, and not at runtime. Normally this flag is imported from `typing` or +`typing_extensions`, but it can also be defined locally. If defined locally, it must be assigned the +value `False` at runtime; the type checker will consider its value to be `True`. If annotated, it +must be annotated as a type that can accept `bool` values. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-form.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-form.md index 2d9b53a13d..0d15e35900 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-form.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-form.md @@ -1,12 +1,11 @@ ## What it does -Checks for expressions that are used as [type expressions] -but cannot validly be interpreted as such. +Checks for expressions that are used as [type expressions] but cannot validly be interpreted as +such. ## Why is this bad? -Such expressions cannot be understood by ty. -In some cases, they might raise errors at runtime. +Such expressions cannot be understood by ty. In some cases, they might raise errors at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-definition.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-definition.md index eaf2686017..dc106bf0b1 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-definition.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-definition.md @@ -1,12 +1,12 @@ ## What it does -Checks for type guard functions without -a first non-self-like non-keyword-only non-variadic parameter. +Checks for type guard functions without a first non-self-like non-keyword-only non-variadic +parameter. ## Why is this bad? -Type narrowing functions must accept at least one positional argument -(non-static methods must accept another in addition to `self`/`cls`). +Type narrowing functions must accept at least one positional argument (non-static methods must +accept another in addition to `self`/`cls`). Extra parameters/arguments are allowed but do not affect narrowing. diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-constraints.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-constraints.md index 3b367e1130..29cd276f12 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-constraints.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-constraints.md @@ -1,7 +1,7 @@ ## What it does -Checks for constrained [type variables] with only one constraint, -or that those constraints reference type variables. +Checks for constrained [type variables] with only one constraint, or that those constraints +reference type variables. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-default.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-default.md index 66ae071fe6..5182c42fc7 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-default.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-default.md @@ -1,13 +1,13 @@ ## What it does -Checks for [type variables] whose default type is not compatible with -the type variable's bound or constraints. +Checks for [type variables] whose default type is not compatible with the type variable's bound or +constraints. ## Why is this bad? -If a type variable has a bound, the default must be assignable to that -bound (see: [bound rules]). If a type variable has constraints, the default -must be one of the constraints (see: [constraint rules]). +If a type variable has a bound, the default must be assignable to that bound (see: [bound rules]). +If a type variable has constraints, the default must be one of the constraints (see: +[constraint rules]). ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-field.md b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-field.md index 5589adcc49..50a0a52029 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-field.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-field.md @@ -4,8 +4,8 @@ Detects invalid `TypedDict` field declarations. ## Why is this bad? -`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the -subtype guarantees that `TypedDict` inheritance is meant to preserve. +`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the subtype +guarantees that `TypedDict` inheritance is meant to preserve. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-header.md b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-header.md index 422c6b417e..29e7a6e476 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-header.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-header.md @@ -1,14 +1,12 @@ ## What it does -Detects errors in `TypedDict` class headers, such as unexpected arguments -or invalid base classes. +Detects errors in `TypedDict` class headers, such as unexpected arguments or invalid base classes. ## Why is this bad? -The typing spec states that `TypedDict`s are not permitted to have -custom metaclasses. Using `**` unpacking in a `TypedDict` header -is also prohibited by ty, as it means that ty cannot statically determine -whether keys in the `TypedDict` are intended to be required or optional. +The typing spec states that `TypedDict`s are not permitted to have custom metaclasses. Using `**` +unpacking in a `TypedDict` header is also prohibited by ty, as it means that ty cannot statically +determine whether keys in the `TypedDict` are intended to be required or optional. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-statement.md b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-statement.md index 333ae6fc85..d7d757c7e5 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-statement.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-typed-dict-statement.md @@ -4,10 +4,9 @@ Detects statements other than annotated declarations in `TypedDict` class bodies ## Why is this bad? -`TypedDict` class bodies aren't allowed to contain any other types of statements. For -example, method definitions and field values aren't allowed. None of these will be -available on "instances of the `TypedDict`" at runtime (as `dict` is the runtime class of -all "`TypedDict` instances"). +`TypedDict` class bodies aren't allowed to contain any other types of statements. For example, +method definitions and field values aren't allowed. None of these will be available on "instances of +the `TypedDict`" at runtime (as `dict` is the runtime class of all "`TypedDict` instances"). ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-yield.md b/crates/ty_python_semantic/resources/lint_docs/invalid-yield.md index b053a087c8..a981177440 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-yield.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-yield.md @@ -1,14 +1,13 @@ ## What it does -Detects `yield` and `yield from` expressions where the "yield" or "send" type -is incompatible with the generator function's annotated return type. +Detects `yield` and `yield from` expressions where the "yield" or "send" type is incompatible with +the generator function's annotated return type. ## Why is this bad? -Yielding a value of a type that doesn't match the generator's declared yield type, -or using `yield from` with a sub-iterator whose yield or send type is incompatible, -is a type error that may cause downstream consumers of the generator to receive -values of an unexpected type. +Yielding a value of a type that doesn't match the generator's declared yield type, or using +`yield from` with a sub-iterator whose yield or send type is incompatible, is a type error that may +cause downstream consumers of the generator to receive values of an unexpected type. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/isinstance-against-protocol.md b/crates/ty_python_semantic/resources/lint_docs/isinstance-against-protocol.md index 881e8b4518..b902eecbcb 100644 --- a/crates/ty_python_semantic/resources/lint_docs/isinstance-against-protocol.md +++ b/crates/ty_python_semantic/resources/lint_docs/isinstance-against-protocol.md @@ -1,9 +1,8 @@ ## What it does -Reports invalid runtime checks against `Protocol` classes. -This includes explicit calls `isinstance()`/`issubclass()` against -non-runtime-checkable protocols, `issubclass()` calls against protocols -that have non-method members, and implicit `isinstance()` checks against +Reports invalid runtime checks against `Protocol` classes. This includes explicit calls +`isinstance()`/`issubclass()` against non-runtime-checkable protocols, `issubclass()` calls against +protocols that have non-method members, and implicit `isinstance()` checks against non-runtime-checkable protocols via pattern matching. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/isinstance-against-typed-dict.md b/crates/ty_python_semantic/resources/lint_docs/isinstance-against-typed-dict.md index b4b7d2c37f..642c70051b 100644 --- a/crates/ty_python_semantic/resources/lint_docs/isinstance-against-typed-dict.md +++ b/crates/ty_python_semantic/resources/lint_docs/isinstance-against-typed-dict.md @@ -1,8 +1,7 @@ ## What it does -Reports runtime checks against `TypedDict` classes. -This includes explicit calls to `isinstance()`/`issubclass()` and implicit -checks performed by `match` class patterns. +Reports runtime checks against `TypedDict` classes. This includes explicit calls to +`isinstance()`/`issubclass()` and implicit checks performed by `match` class patterns. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md b/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md index 3bdd62559c..977cb670c0 100644 --- a/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md +++ b/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md @@ -1,19 +1,18 @@ ## What it does -Checks for functional typing definitions whose declared name does not match -the variable they are assigned to. +Checks for functional typing definitions whose declared name does not match the variable they are +assigned to. ## Why is this bad? -Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, -`TypedDict`, and `TypeAliasType` all take a name argument that is -normally expected to match the assigned variable. A mismatch is usually a -typo and makes later diagnostics harder to understand. +Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, `TypedDict`, and `TypeAliasType` +all take a name argument that is normally expected to match the assigned variable. A mismatch is +usually a typo and makes later diagnostics harder to understand. ## Default level -This rule is a warning by default because ty can usually recover and -continue understanding the resulting type. +This rule is a warning by default because ty can usually recover and continue understanding the +resulting type. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md b/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md new file mode 100644 index 0000000000..a585e06f2d --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md @@ -0,0 +1,73 @@ +## What it does + +Checks for imports from installable packages that the current project or PEP 723 script does not +declare as direct dependencies. + +The name used in dependency declarations can differ from the import name: for example, the `pillow` +package is imported as `PIL`. + +## Why is this bad? + +A dependency can be installed because another package requires it. Importing that dependency without +declaring it makes your code rely on another package's dependency list. If that package removes the +dependency, your imports can fail. + +Declare the packages that provide your imports in `project.dependencies` or +`project.optional-dependencies` in `pyproject.toml`. Non-package files, such as tests and +development scripts, can also use dependencies declared in dependency groups. + +See uv's [guide to managing dependencies](https://docs.astral.sh/uv/concepts/projects/dependencies/) +for how to add these declarations. + +## Rule status + +This rule is disabled by default and requires uv integration. + +For projects, enable uv workspace integration (`TY_UV=1`) and use an existing, synchronized +environment. Running [`uv check`](https://docs.astral.sh/uv/reference/cli/#uv-check) synchronizes +the environment automatically before invoking ty, unless `--no-sync` is passed. For these checks, ty +reads the dependency graph and module ownership returned by `uv workspace metadata` without changing +installed packages. uv may update the lockfile to match the current dependency declarations. uv +0.12.3 or later is required. + +For PEP 723 scripts, enable uv script integration with `TY_UV=scripts` or `TY_UV=1`. ty synchronizes +each script's environment and checks imports against its inline `dependencies` list. Declarations +and environments from the enclosing workspace or other scripts do not apply. + +## Known limitations + +The current workspace integration applies to directory checks. Explicit file arguments and +`--config-file` bypass uv workspace discovery. + +Imports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They +can use development-only dependencies, such as type stub packages, without requiring those packages +as runtime dependencies. + +Standard-library imports and imports whose owning package cannot be identified unambiguously are +also not reported. + +Imports of [namespace packages](https://docs.python.org/3/reference/import.html#namespace-packages) +themselves, such as `import ns`, are not reported: the namespace can contain modules from several +installable packages. Imports of their submodules, such as `import ns.child`, are checked when the +owning package is known. An `__init__.pyi` stub does not change this distinction. + +Native packages that ty can resolve only as namespace packages at runtime are also skipped. For +other native modules, ty can use stubs to resolve the import and uv's ownership map to identify +which package to declare. + +Some editable installations add the whole project directory to Python's import path, making both +package code and files such as `tests/test_app.py` importable. If uv does not identify which modules +belong to the installable package, ty allows dependency-group imports throughout that directory, +including in package code, to avoid incorrectly flagging imports in tests and scripts. + +## Examples + +With `requests` as a direct dependency, `urllib3` may also be installed because `requests` depends +on it: + +```python {data-mdtest="ignore"} +import requests +import urllib3 # error: [missing-direct-dependency] +``` + +Add `urllib3` to `project.dependencies` if your code imports it directly. diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-override-decorator.md b/crates/ty_python_semantic/resources/lint_docs/missing-override-decorator.md index d91ce62739..f27eb6c560 100644 --- a/crates/ty_python_semantic/resources/lint_docs/missing-override-decorator.md +++ b/crates/ty_python_semantic/resources/lint_docs/missing-override-decorator.md @@ -1,8 +1,10 @@ ## What it does -Checks for methods that override a method or attribute in a superclass but are not decorated with `@override`. +Checks for methods that override a method or attribute in a superclass but are not decorated with +`@override`. -This rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a project. +This rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a +project. ## Exemptions diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-slot.md b/crates/ty_python_semantic/resources/lint_docs/missing-slot.md new file mode 100644 index 0000000000..f3a07bb59a --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/missing-slot.md @@ -0,0 +1,70 @@ +## What it does + +Checks for assignments to declared attributes that have no matching `__slots__` entry on the class +or its bases, and no instance dictionary to store their values. + +## Why is this bad? + +Most Python objects store their attributes in an "instance dictionary". Assigning to a new attribute +adds an entry to this dictionary; deleting that attribute removes it again. Accordingly, most Python +objects allow for **arbitrary attributes to be set and read**. The advantage of this is that it +allows for many dynamic features; the disadvantage is that it can be costly in terms of memory, and +can easily allow for typos to slip in accidentally, e.g.: + +```py +class Foo: + def __init__(self, x): + self.x = x + + def update_x(self, x): + self.xx = x # oops, this was meant to be the same attribute set in `__init__`, + # but ended up being an entirely separate one! +``` + +Defining `__slots__` lets a class reserve space for a fixed set of instance attributes instead. +Unless an instance dictionary is inherited from a base class or requested by including `"__dict__"` +in `__slots__`, instances of the class have no dictionary in which to store additional attributes. +Attempting to assign to an attribute not declared in `__slots__` will often raise `AttributeError` +at runtime if the instance has no instance dictionary. + +## Examples + +### Class definitions + +```python +class Item: + __slots__ = () + value: int + + +Item().value = 1 # error: [missing-slot] +``` + +If you control the class, include the attribute in `__slots__` to make the assignment valid: + +```python +class Item: + __slots__ = ("value",) + value: int + + +Item().value = 1 +``` + +### Stub files + +Stub files can use properties to indicate that instances have attributes that are readable and +writable but do not appear in `__slots__`, for example: + +```pyi +class Item: + __slots__ = () + @property + def value(self) -> int: ... + @value.setter + def value(self, value: int) -> None: ... +``` + +## References + +- [Python data model: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-type-argument.md b/crates/ty_python_semantic/resources/lint_docs/missing-type-argument.md index 92a42a7a30..8e8baeab84 100644 --- a/crates/ty_python_semantic/resources/lint_docs/missing-type-argument.md +++ b/crates/ty_python_semantic/resources/lint_docs/missing-type-argument.md @@ -4,10 +4,9 @@ Checks for generic types used without type parameters in type expressions. ## Why is this bad? -Using a generic type without specifying its type parameters results in the -type parameters being implicitly filled with `Unknown`, reducing the -precision of type checking. Explicit type parameters make the intended types -clear and enable the type checker to catch more errors. +Using a generic type without specifying its type parameters results in the type parameters being +implicitly filled with `Unknown`, reducing the precision of type checking. Explicit type parameters +make the intended types clear and enable the type checker to catch more errors. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-typed-dict-key.md b/crates/ty_python_semantic/resources/lint_docs/missing-typed-dict-key.md index 3ef59a6849..3c8e820a87 100644 --- a/crates/ty_python_semantic/resources/lint_docs/missing-typed-dict-key.md +++ b/crates/ty_python_semantic/resources/lint_docs/missing-typed-dict-key.md @@ -4,8 +4,8 @@ Detects missing required keys in `TypedDict` constructor calls. ## Why is this bad? -`TypedDict` requires all non-optional keys to be provided during construction. -Missing items can lead to a `KeyError` at runtime. +`TypedDict` requires all non-optional keys to be provided during construction. Missing items can +lead to a `KeyError` at runtime. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/no-matching-overload.md b/crates/ty_python_semantic/resources/lint_docs/no-matching-overload.md index 90e5d0c841..6ce3fc446f 100644 --- a/crates/ty_python_semantic/resources/lint_docs/no-matching-overload.md +++ b/crates/ty_python_semantic/resources/lint_docs/no-matching-overload.md @@ -4,8 +4,8 @@ Checks for calls to an overloaded function that do not match any of the overload ## Why is this bad? -Failing to provide the correct arguments to one of the overloads will raise a `TypeError` -at runtime. +Failing to provide the correct arguments to one of the overloads will raise a `TypeError` at +runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/non-callable-init-subclass.md b/crates/ty_python_semantic/resources/lint_docs/non-callable-init-subclass.md index 6d15a2c220..cf35cccddf 100644 --- a/crates/ty_python_semantic/resources/lint_docs/non-callable-init-subclass.md +++ b/crates/ty_python_semantic/resources/lint_docs/non-callable-init-subclass.md @@ -1,12 +1,11 @@ ## What it does -Checks for class definitions that will fail due to non-callable `__init_subclass__` -methods. +Checks for class definitions that will fail due to non-callable `__init_subclass__` methods. ## Why is this bad? -If a class defines a non-callable `__init_subclass__` method/attribute, any attempt -to subclass that class will raise a `TypeError` at runtime. +If a class defines a non-callable `__init_subclass__` method/attribute, any attempt to subclass that +class will raise a `TypeError` at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/override-of-final-method.md b/crates/ty_python_semantic/resources/lint_docs/override-of-final-method.md index a3f31a40d7..c96ec48aba 100644 --- a/crates/ty_python_semantic/resources/lint_docs/override-of-final-method.md +++ b/crates/ty_python_semantic/resources/lint_docs/override-of-final-method.md @@ -4,8 +4,8 @@ Checks for methods on subclasses that override superclass methods decorated with ## Why is this bad? -Decorating a method with `@final` declares to the type checker that it should not be -overridden on any subclass. +Decorating a method with `@final` declares to the type checker that it should not be overridden on +any subclass. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/override-of-final-variable.md b/crates/ty_python_semantic/resources/lint_docs/override-of-final-variable.md index 855601a55c..714a2b0065 100644 --- a/crates/ty_python_semantic/resources/lint_docs/override-of-final-variable.md +++ b/crates/ty_python_semantic/resources/lint_docs/override-of-final-variable.md @@ -1,12 +1,12 @@ ## What it does -Checks for class variables on subclasses that override a superclass variable -that has been declared as `Final`. +Checks for class variables on subclasses that override a superclass variable that has been declared +as `Final`. ## Why is this bad? -Declaring a variable as `Final` indicates to the type checker that it should not be -overridden on any subclass. +Declaring a variable as `Final` indicates to the type checker that it should not be overridden on +any subclass. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-attribute.md b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-attribute.md index f79763bcee..2c582d7f7e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-attribute.md +++ b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-attribute.md @@ -8,8 +8,7 @@ Attempting to access a missing attribute will raise an `AttributeError` at runti ## Rule status -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-implicit-call.md b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-implicit-call.md index a5cd976902..dd1d21f48d 100644 --- a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-implicit-call.md +++ b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-implicit-call.md @@ -4,9 +4,8 @@ Checks for implicit calls to possibly missing methods. ## Why is this bad? -Expressions such as `x[y]` and `x * y` call methods -under the hood (`__getitem__` and `__mul__` respectively). -Calling a missing method will raise an `AttributeError` at runtime. +Expressions such as `x[y]` and `x * y` call methods under the hood (`__getitem__` and `__mul__` +respectively). Calling a missing method will raise an `AttributeError` at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-import.md b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-import.md index 76835cc9e2..d44a860008 100644 --- a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-import.md +++ b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-import.md @@ -4,13 +4,11 @@ Checks for imports of symbols that may be missing. ## Why is this bad? -Importing a missing module or name will raise a `ModuleNotFoundError` -or `ImportError` at runtime. +Importing a missing module or name will raise a `ModuleNotFoundError` or `ImportError` at runtime. ## Rule status -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-submodule.md b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-submodule.md index 5761dcab5d..638ddc9a2d 100644 --- a/crates/ty_python_semantic/resources/lint_docs/possibly-missing-submodule.md +++ b/crates/ty_python_semantic/resources/lint_docs/possibly-missing-submodule.md @@ -4,9 +4,9 @@ Checks for accesses of submodules that might not've been imported. ## Why is this bad? -When module `a` has a submodule `b`, `import a` isn't generally enough to let you access -`a.b.` You either need to explicitly `import a.b`, or else you need the `__init__.py` file -of `a` to include `from . import b`. Without one of those, `a.b` is an `AttributeError`. +When module `a` has a submodule `b`, `import a` isn't generally enough to let you access `a.b.` You +either need to explicitly `import a.b`, or else you need the `__init__.py` file of `a` to include +`from . import b`. Without one of those, `a.b` is an `AttributeError`. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/possibly-unresolved-reference.md b/crates/ty_python_semantic/resources/lint_docs/possibly-unresolved-reference.md index decead1d39..0b39aed631 100644 --- a/crates/ty_python_semantic/resources/lint_docs/possibly-unresolved-reference.md +++ b/crates/ty_python_semantic/resources/lint_docs/possibly-unresolved-reference.md @@ -8,8 +8,7 @@ Using an undefined variable will raise a `NameError` at runtime. ## Rule status -This rule is currently disabled by default because of the number of -false positives it can produce. +This rule is currently disabled by default because of the number of false positives it can produce. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md index 28fc08187f..6dbece0fcc 100644 --- a/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md +++ b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md @@ -22,5 +22,5 @@ class User(BaseModel): user = User(name="Alice", admni=True) # error: [pydantic-discarded-extra-argument] ``` -If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument, -or explicitly configure the model with `extra="allow"`. +If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra +argument, or explicitly configure the model with `extra="allow"`. diff --git a/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md b/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md index 3a2e63de25..80137f3e1b 100644 --- a/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md +++ b/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md @@ -4,11 +4,11 @@ Checks for redundant combinations of the `ClassVar` and `Final` type qualifiers. ## Why is this bad? -An attribute that is marked `Final` in a class body is implicitly a class variable. -Marking it as `ClassVar` is therefore redundant. +An attribute that is marked `Final` in a class body is implicitly a class variable. Marking it as +`ClassVar` is therefore redundant. -Note that this diagnostic is not emitted for dataclass fields or protocol members, -where `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. +Note that this diagnostic is not emitted for dataclass fields or protocol members, where +`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/refutable-destructuring.md b/crates/ty_python_semantic/resources/lint_docs/refutable-destructuring.md index 418c896874..09b8e5f807 100644 --- a/crates/ty_python_semantic/resources/lint_docs/refutable-destructuring.md +++ b/crates/ty_python_semantic/resources/lint_docs/refutable-destructuring.md @@ -1,16 +1,16 @@ ## What it does -Checks for a basedpython destructuring binder whose pattern may not match the -value it destructures, with nothing to handle the failure. +Checks for a basedpython destructuring binder whose pattern may not match the value it destructures, +with nothing to handle the failure. ## Why is this bad? -A destructuring binder — a `let` statement, a `for` target, a `with` item, a -parameter — binds its captures unconditionally. A pattern that does not match -leaves them unbound, which is a `NameError` at the first use. +A destructuring binder — a `let` statement, a `for` target, a `with` item, a parameter — binds its +captures unconditionally. A pattern that does not match leaves them unbound, which is a `NameError` +at the first use. -A `let` statement can handle the failure with an `else` block, but only if the -block diverges: control that falls out of it reaches the same unbound captures. +A `let` statement can handle the failure with an `else` block, but only if the block diverges: +control that falls out of it reaches the same unbound captures. ## Examples @@ -25,8 +25,7 @@ def g(value: int | str) -> int: return n # error: [possibly-unresolved-reference] ``` -Use a pattern that matches every value of the type, or an `else` block that -diverges: +Use a pattern that matches every value of the type, or an `else` block that diverges: ```by def f(value: int | str) -> int: diff --git a/crates/ty_python_semantic/resources/lint_docs/refutable-unpacking.md b/crates/ty_python_semantic/resources/lint_docs/refutable-unpacking.md index 32cb92b87d..f045410432 100644 --- a/crates/ty_python_semantic/resources/lint_docs/refutable-unpacking.md +++ b/crates/ty_python_semantic/resources/lint_docs/refutable-unpacking.md @@ -1,25 +1,24 @@ ## What it does -Checks for an unpacking assignment whose value is not known to have the number -of elements the targets require. +Checks for an unpacking assignment whose value is not known to have the number of elements the +targets require. ## Why is this bad? -`a, b = value` binds both names unconditionally, but the unpacking only succeeds -if `value` yields exactly two elements. A `tuple[int, ...]`, a `list[int]`, or -any other iterable whose length is not part of its type satisfies the annotation -at every length, so nothing rules out a `ValueError` at runtime. - -A starred target absorbs any number of elements, so it only requires the ones -around it: `a, *rest = value` still needs at least one element, and reports for -the same reason. A splatted argument is the same question against a parameter -list: `f(*value)` binds the parameters positionally, so a length that does not -match raises `TypeError` rather than `ValueError`. - -Three values are left alone: one whose type is `Any`, which has opted out of -checking altogether; one whose element type is `Unknown`, which ty fills in -where the code said nothing at all; and an unannotated parameter, whose type is -bounded by what its function's body asks of it — including the unpacking itself. +`a, b = value` binds both names unconditionally, but the unpacking only succeeds if `value` yields +exactly two elements. A `tuple[int, ...]`, a `list[int]`, or any other iterable whose length is not +part of its type satisfies the annotation at every length, so nothing rules out a `ValueError` at +runtime. + +A starred target absorbs any number of elements, so it only requires the ones around it: +`a, *rest = value` still needs at least one element, and reports for the same reason. A splatted +argument is the same question against a parameter list: `f(*value)` binds the parameters +positionally, so a length that does not match raises `TypeError` rather than `ValueError`. + +Three values are left alone: one whose type is `Any`, which has opted out of checking altogether; +one whose element type is `Unknown`, which ty fills in where the code said nothing at all; and an +unannotated parameter, whose type is bounded by what its function's body asks of it — including the +unpacking itself. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/shadowed-type-variable.md b/crates/ty_python_semantic/resources/lint_docs/shadowed-type-variable.md index 1325d790e2..0df73e8ede 100644 --- a/crates/ty_python_semantic/resources/lint_docs/shadowed-type-variable.md +++ b/crates/ty_python_semantic/resources/lint_docs/shadowed-type-variable.md @@ -1,7 +1,7 @@ ## What it does -Checks for type variables in nested generic classes or functions that shadow type variables -from an enclosing scope. +Checks for type variables in nested generic classes or functions that shadow type variables from an +enclosing scope. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/static-assert-error.md b/crates/ty_python_semantic/resources/lint_docs/static-assert-error.md index 296fe7f6fd..640dfb936e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/static-assert-error.md +++ b/crates/ty_python_semantic/resources/lint_docs/static-assert-error.md @@ -4,9 +4,8 @@ Makes sure that the argument of `static_assert` is statically known to be true. ## Why is this bad? -A `static_assert` call represents an explicit request from the user -for the type checker to emit an error if the argument cannot be verified -to evaluate to `True` in a boolean context. +A `static_assert` call represents an explicit request from the user for the type checker to emit an +error if the argument cannot be verified to evaluate to `True` in a boolean context. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/subclass-of-dataclass-with-order.md b/crates/ty_python_semantic/resources/lint_docs/subclass-of-dataclass-with-order.md index 513d0bf2ba..38cc451423 100644 --- a/crates/ty_python_semantic/resources/lint_docs/subclass-of-dataclass-with-order.md +++ b/crates/ty_python_semantic/resources/lint_docs/subclass-of-dataclass-with-order.md @@ -4,13 +4,13 @@ Checks for classes that inherit from a dataclass with `order=True`. ## Why is this bad? -When a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`) -are generated that compare instances as tuples of their fields. These methods raise a -`TypeError` at runtime when comparing instances of different classes in the inheritance -hierarchy, even if one is a subclass of the other. +When a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`) are +generated that compare instances as tuples of their fields. These methods raise a `TypeError` at +runtime when comparing instances of different classes in the inheritance hierarchy, even if one is a +subclass of the other. -This violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class instances cannot be -used in all contexts where parent class instances are expected. +This violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class +instances cannot be used in all contexts where parent class instances are expected. ## Example @@ -31,7 +31,8 @@ class Child(Parent): # error # Child(1) < Parent(2) ``` -Consider using [`functools.total_ordering`][total_ordering] instead, which does not have this limitation. +Consider using [`functools.total_ordering`][total_ordering] instead, which does not have this +limitation. [liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle [total_ordering]: https://docs.python.org/3/library/functools.html#functools.total_ordering diff --git a/crates/ty_python_semantic/resources/lint_docs/type-assertion-failure.md b/crates/ty_python_semantic/resources/lint_docs/type-assertion-failure.md index 8cf321c619..ea44101862 100644 --- a/crates/ty_python_semantic/resources/lint_docs/type-assertion-failure.md +++ b/crates/ty_python_semantic/resources/lint_docs/type-assertion-failure.md @@ -1,7 +1,7 @@ ## What it does -Checks for `assert_type()` and `assert_never()` calls where the actual type -is not the same as the asserted type. +Checks for `assert_type()` and `assert_never()` calls where the actual type is not the same as the +asserted type. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/unavailable-implicit-super-arguments.md b/crates/ty_python_semantic/resources/lint_docs/unavailable-implicit-super-arguments.md index 96024cffd6..01d252e327 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unavailable-implicit-super-arguments.md +++ b/crates/ty_python_semantic/resources/lint_docs/unavailable-implicit-super-arguments.md @@ -1,12 +1,13 @@ ## What it does -Detects invalid `super()` calls where implicit arguments like the enclosing class or first method argument are unavailable. +Detects invalid `super()` calls where implicit arguments like the enclosing class or first method +argument are unavailable. ## Why is this bad? -When `super()` is used without arguments, Python tries to find two things: -the nearest enclosing class and the first argument of the immediately enclosing function (typically self or cls). -If either of these is missing, the call will fail at runtime with a `RuntimeError`. +When `super()` is used without arguments, Python tries to find two things: the nearest enclosing +class and the first argument of the immediately enclosing function (typically self or cls). If +either of these is missing, the call will fail at runtime with a `RuntimeError`. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unbound-type-variable.md b/crates/ty_python_semantic/resources/lint_docs/unbound-type-variable.md index 5a234496c5..e38a5acc5c 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unbound-type-variable.md +++ b/crates/ty_python_semantic/resources/lint_docs/unbound-type-variable.md @@ -1,7 +1,7 @@ ## What it does -Checks for type variables that are used in a scope where they are not bound -to any enclosing generic context. +Checks for type variables that are used in a scope where they are not bound to any enclosing generic +context. ## Why is this bad? diff --git a/crates/ty_python_semantic/resources/lint_docs/unresolved-attribute.md b/crates/ty_python_semantic/resources/lint_docs/unresolved-attribute.md index 4fc0d31fbb..33e80b57a5 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unresolved-attribute.md +++ b/crates/ty_python_semantic/resources/lint_docs/unresolved-attribute.md @@ -4,9 +4,9 @@ Checks for unresolved attributes. ## Why is this bad? -Accessing an unbound attribute will raise an `AttributeError` at runtime. -An unresolved attribute is not guaranteed to exist from the type alone, -so this could also indicate that the object is not of the type that the user expects. +Accessing an unbound attribute will raise an `AttributeError` at runtime. An unresolved attribute is +not guaranteed to exist from the type alone, so this could also indicate that the object is not of +the type that the user expects. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unresolved-global.md b/crates/ty_python_semantic/resources/lint_docs/unresolved-global.md index 426a6a426c..6d493f9594 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unresolved-global.md +++ b/crates/ty_python_semantic/resources/lint_docs/unresolved-global.md @@ -1,13 +1,13 @@ ## What it does -Detects variables declared as `global` in an inner scope that have no explicit -bindings or declarations in the global scope. +Detects variables declared as `global` in an inner scope that have no explicit bindings or +declarations in the global scope. ## Why is this bad? -Function bodies with `global` statements can run in any order (or not at all), which makes -it hard for static analysis tools to infer the types of globals without -explicit definitions or declarations. +Function bodies with `global` statements can run in any order (or not at all), which makes it hard +for static analysis tools to infer the types of globals without explicit definitions or +declarations. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/unresolved-import.md b/crates/ty_python_semantic/resources/lint_docs/unresolved-import.md index 613f562ec0..bc018f0cce 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unresolved-import.md +++ b/crates/ty_python_semantic/resources/lint_docs/unresolved-import.md @@ -4,8 +4,7 @@ Checks for import statements for which the module cannot be resolved. ## Why is this bad? -Importing a module that cannot be resolved will raise a `ModuleNotFoundError` -at runtime. +Importing a module that cannot be resolved will raise a `ModuleNotFoundError` at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unsound-assignment.md b/crates/ty_python_semantic/resources/lint_docs/unsound-assignment.md new file mode 100644 index 0000000000..2a4264879d --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/unsound-assignment.md @@ -0,0 +1,112 @@ +## What it does + +Detects variable assignments that unsoundly assign a type that is not a [subtype] of a variable's +declared type. + +This rule is a stricter version of `invalid-assignment`. Whereas that rule also flags assignments to +attributes and subscripts, however, this rule is only applied to variable assignments. + +This rule has no effect on stub files. + +## Why is this bad? + +By default, type checkers consider an assignment valid if the inferred type of the assigned value is +[assignable] to the target's declared type. However, this makes it easy for incorrect types to +percolate through your code unexpectedly due to a single expression being inferred as `Any`. This +can easily lead to runtime errors that are not caught by the type checker: + +```py +from typing import Any + + +def returns_any() -> Any: + return "not an integer" + + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +my_integer: int = returns_any() + +# Fails at runtime, even though the type checker infers both operands as being of type `int`! +my_integer + 42 +``` + +This rule treats ["fully static"][fully-static] declared types as "typed boundaries" for your code. +With this rule enabled, ty would emit an error on the `my_integer: int = returns_any()` assignment, +since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of +`int`. This helps prevent the unsoundness from spreading far from its original source (in this case, +the return type of the `returns_any` function). + +Note that this rule is only applied to assignments where the declared type is +[fully static][fully-static]. It will not trigger if `Any` or `Unknown` appear anywhere in the +declared type, either implicitly or explicitly: + +```py +from typing import Any + + +def returns_any() -> Any: + return "not an integer" + + +explicitly_dynamic: Any = returns_any() # no error +also_dynamic: list[Any] = returns_any() # no error + +# no `unsound-assignment` error, since `list` is implicitly the same as `list[Unknown]` +# (which is what the `missing-type-argument` error is complaining about) +# +# error: [missing-type-argument] +implicitly_dynamic: list = returns_any() +``` + +This rule works especially well when combined with ty's `missing-type-argument` rule. + +## Examples + +```py +from typing import Any + + +def returns_any() -> Any: + return 42 + + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +my_integer: int = returns_any() + +another_integer: int + +# error: "Unsound assignment: `Any` is not a subtype of `int`" +another_integer = returns_any() +``` + +Narrow the value before assigning it to fix the diagnostics: + +```py +from typing import Any + + +def returns_any() -> Any: + return 42 + + +value = returns_any() +assert isinstance(value, int) +my_integer: int = value # no error: `Any & int` is a subtype of `int` +``` + +## Default level + +This rule is disabled by default. It is intended for advanced users wanting additional soundness +checks from their type checker, not for users who have just started to use type checkers on their +Python code. + +## See also + +- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather + than unsound assignments +- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound + assignments + +[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable +[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type +[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype diff --git a/crates/ty_python_semantic/resources/lint_docs/unsound-return-statement.md b/crates/ty_python_semantic/resources/lint_docs/unsound-return-statement.md index 85e5cf5fc8..98e0fedae4 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsound-return-statement.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsound-return-statement.md @@ -31,14 +31,14 @@ returns_int() + 42 ``` This rule allows you to use ["fully static"][fully-static] return types as "typed boundaries" for -your code. With this rule enabled, ty would emit an error on the `return returns_any()` statement -in `returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not -a subtype of `int`. This helps prevent the unsoundness from spreading far from its original source -(in this case, the return type of the `returns_any` function). +your code. With this rule enabled, ty would emit an error on the `return returns_any()` statement in +`returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a +subtype of `int`. This helps prevent the unsoundness from spreading far from its original source (in +this case, the return type of the `returns_any` function). -Note that this rule is only applied to functions annotated as returning -[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in -your return type, either implicitly or explicitly: +Note that this rule is only applied to functions annotated as returning [fully static][fully-static] +types. It will not trigger if `Any` or `Unknown` appear anywhere in your return type, either +implicitly or explicitly: ```py from typing import Any @@ -60,12 +60,12 @@ def returns_list_of_any() -> list[Any]: return returns_any() ``` -This rule works especially well when combined with ty's -`missing-type-argument` rule, and the Ruff rules [`ANN201`][ann201], -[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all -these rules at once effectively makes it much less likely that a `return` statement can lead to -unsoundness "leaking" out of a function unless that function has been *explicitly* annotated with -a dynamic type in some way (`-> Any` or `-> tuple[Any]`, for example). +This rule works especially well when combined with ty's `missing-type-argument` and +`unsound-assignment` rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202], +[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once +effectively makes it much less likely that a `return` statement can lead to unsoundness "leaking" +out of a function unless that function has been *explicitly* annotated with a dynamic type in some +way (`-> Any` or `-> tuple[Any]`, for example). This rule is analogous to mypy's [`no-any-return`][no-any-return] error code, which is enabled by mypy’s [`--strict`][mypy-strict] mode and can also be enabled on its own using mypy’s @@ -112,7 +112,9 @@ Python code. ## See also -- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound `return` statements +- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound + `return` statements +- `unsound-assignment` is a similar rule that triggers on unsound assignments [ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ [ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ diff --git a/crates/ty_python_semantic/resources/lint_docs/unsound-yield.md b/crates/ty_python_semantic/resources/lint_docs/unsound-yield.md index 0944ad3e51..2c21a8d2f2 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsound-yield.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsound-yield.md @@ -8,10 +8,9 @@ This lint is a stricter version of `invalid-yield`. ## Why is this bad? By default, type checkers consider a yielded value valid if its inferred type is [assignable] to the -generator's annotated yield type. However, this -makes it easy for incorrect types to percolate through your code unexpectedly due to a single -expression being inferred as `Any`. This can easily lead to runtime errors that are not caught by -the type checker: +generator's annotated yield type. However, this makes it easy for incorrect types to percolate +through your code unexpectedly due to a single expression being inferred as `Any`. This can easily +lead to runtime errors that are not caught by the type checker: ```py from typing import Any, Generator @@ -30,14 +29,16 @@ def integers() -> Generator[int]: sum(integers()) ``` -This rule treats [fully static][fully-static] yield types as "typed boundaries" for your code. With this rule enabled, ty would emit an error on the `yield returns_any()` statement -in `integers`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not -a subtype of `int`. This helps prevent the unsoundness from spreading far from its original source -(in this case, the return type of the `returns_any` function). +This rule treats ["fully static"][fully-static] yield types as "typed boundaries" for your code. +With this rule enabled, ty would emit an error on the `yield returns_any()` statement in `integers`, +since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of +`int`. This helps prevent the unsoundness from spreading far from its original source (in this case, +the return type of the `returns_any` function). -Note that this rule is only applied to functions annotated as yielding -[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in -your function's yield type, either implicitly or explicitly. It will still trigger on functions that have non-fully-static send and/or return types, however: +Note that this rule is only applied to functions annotated as yielding [fully static][fully-static] +types. It will not trigger if `Any` or `Unknown` appear anywhere in your function's yield type, +either implicitly or explicitly. It will still trigger on functions that have non-fully-static send +and/or return types, however: ```py from typing import Any, Generator @@ -48,6 +49,7 @@ def returns_any() -> Any: def dynamic_yield_type() -> Generator[Any]: + # no error yield returns_any() @@ -56,12 +58,12 @@ def static_yield_type() -> Generator[int, Any, Any]: yield returns_any() ``` -This rule works especially well when combined with ty's -`missing-type-argument` rule, and the Ruff rules [`ANN201`][ann201], -[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all -these rules at once effectively makes it much less likely that a `yield` expression can lead to -unsoundness "leaking" out of a function unless that function has been *explicitly* annotated with -a dynamic type in some way (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example). +This rule works especially well when combined with ty's `missing-type-argument` and +`unsound-assignment` rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202], +[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once +effectively makes it much less likely that a `yield` expression can lead to unsoundness "leaking" +out of a function unless that function has been *explicitly* annotated with a dynamic type in some +way (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example). ## Examples @@ -115,7 +117,9 @@ generator boundaries. ## See also -- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather than unsound `yield` expressions +- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather + than unsound `yield` expressions +- `unsound-assignment` is a similar rule that triggers on unsound assignments [ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/ [ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/ diff --git a/crates/ty_python_semantic/resources/lint_docs/unsupported-base.md b/crates/ty_python_semantic/resources/lint_docs/unsupported-base.md index 6a9a7a4996..6c1e9eab5f 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsupported-base.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsupported-base.md @@ -4,10 +4,9 @@ Checks for class definitions that have bases which are unsupported by ty. ## Why is this bad? -If a class has a base that is an instance of a complex type such as a union type, -ty will not be able to resolve the [method resolution order] (MRO) for the class. -This will lead to an inferior understanding of your codebase and unpredictable -type-checking behavior. +If a class has a base that is an instance of a complex type such as a union type, ty will not be +able to resolve the [method resolution order] (MRO) for the class. This will lead to an inferior +understanding of your codebase and unpredictable type-checking behavior. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unsupported-bool-conversion.md b/crates/ty_python_semantic/resources/lint_docs/unsupported-bool-conversion.md index df0cb7675b..8f0921ed34 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsupported-bool-conversion.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsupported-bool-conversion.md @@ -4,8 +4,8 @@ Checks for bool conversions where the object doesn't correctly implement `__bool ## Why is this bad? -If an exception is raised when you attempt to evaluate the truthiness of an object, -using the object in a boolean context will fail at runtime. +If an exception is raised when you attempt to evaluate the truthiness of an object, using the object +in a boolean context will fail at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unsupported-dynamic-base.md b/crates/ty_python_semantic/resources/lint_docs/unsupported-dynamic-base.md index b8f15edce5..179ca83cf2 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsupported-dynamic-base.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsupported-dynamic-base.md @@ -1,22 +1,20 @@ ## What it does -Checks for dynamic class definitions (using `type()`) that have bases -which are unsupported by ty. +Checks for dynamic class definitions (using `type()`) that have bases which are unsupported by ty. -This is equivalent to `unsupported-base` but applies to classes created -via `type()` rather than `class` statements. +This is equivalent to `unsupported-base` but applies to classes created via `type()` rather than +`class` statements. ## Why is this bad? -If a dynamically created class has a base that is an unsupported type -such as `type[T]`, ty will not be able to resolve the -[method resolution order] (MRO) for the class. This may lead to an inferior +If a dynamically created class has a base that is an unsupported type such as `type[T]`, ty will not +be able to resolve the [method resolution order] (MRO) for the class. This may lead to an inferior understanding of your codebase and unpredictable type-checking behavior. ## Default level -This rule is disabled by default because it will not cause a runtime error, -and may be noisy on codebases that use `type()` in highly dynamic ways. +This rule is disabled by default because it will not cause a runtime error, and may be noisy on +codebases that use `type()` in highly dynamic ways. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unsupported-operator.md b/crates/ty_python_semantic/resources/lint_docs/unsupported-operator.md index 6fda689ba5..aa41c5ab17 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unsupported-operator.md +++ b/crates/ty_python_semantic/resources/lint_docs/unsupported-operator.md @@ -1,12 +1,11 @@ ## What it does -Checks for binary expressions, comparisons, and unary expressions where -the operands don't support the operator. +Checks for binary expressions, comparisons, and unary expressions where the operands don't support +the operator. ## Why is this bad? -Attempting to use an unsupported operator will raise a `TypeError` at -runtime. +Attempting to use an unsupported operator will raise a `TypeError` at runtime. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unusable-resource-key.md b/crates/ty_python_semantic/resources/lint_docs/unusable-resource-key.md index a59fb53665..e1dd1e8439 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unusable-resource-key.md +++ b/crates/ty_python_semantic/resources/lint_docs/unusable-resource-key.md @@ -4,14 +4,12 @@ Checks for keys in an imported static resource that python cannot name. ## Why is this bad? -A static resource is read through attributes, so a key that is not a valid -python identifier — `build-backend`, `class`, `2` — has no attribute to be read -through, and is left out of the value the import binds. The document still holds -it; nothing in the program can reach it. - -Names with two leading underscores are left out for the same reason: python -mangles `__x` inside a class body, so the attribute the reader would write is -not the one that would exist. +A static resource is read through attributes, so a key that is not a valid python identifier — +`build-backend`, `class`, `2` — has no attribute to be read through, and is left out of the value +the import binds. The document still holds it; nothing in the program can reach it. + +Names with two leading underscores are left out for the same reason: python mangles `__x` inside a +class body, so the attribute the reader would write is not the one that would exist. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md b/crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md index c33fa99952..fa5a817706 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md +++ b/crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md @@ -1,13 +1,12 @@ ## What it does -Checks for awaitable objects (such as coroutines) used as expression -statements without being awaited. +Checks for awaitable objects (such as coroutines) used as expression statements without being +awaited. ## Why is this bad? -Calling an `async def` function returns a coroutine object. If the -coroutine is never awaited, the body of the async function will never -execute, which is almost always a bug. Python emits a +Calling an `async def` function returns a coroutine object. If the coroutine is never awaited, the +body of the async function will never execute, which is almost always a bug. Python emits a `RuntimeWarning: coroutine was never awaited` at runtime in this case. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/unused-ignore-comment.md b/crates/ty_python_semantic/resources/lint_docs/unused-ignore-comment.md index 0cde877967..aabee7b954 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unused-ignore-comment.md +++ b/crates/ty_python_semantic/resources/lint_docs/unused-ignore-comment.md @@ -4,8 +4,8 @@ Checks for `ty: ignore` directives that are no longer applicable. ## Why is this bad? -A `ty: ignore` directive that no longer matches any diagnostic violations is likely -included by mistake, and should be removed to avoid confusion. +A `ty: ignore` directive that no longer matches any diagnostic violations is likely included by +mistake, and should be removed to avoid confusion. ## Examples @@ -22,5 +22,6 @@ a = 20 / 2 ## Options -Set [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) +Set +[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) to `false` to prevent this rule from reporting unused `type: ignore` comments. diff --git a/crates/ty_python_semantic/resources/lint_docs/unused-type-ignore-comment.md b/crates/ty_python_semantic/resources/lint_docs/unused-type-ignore-comment.md index 9ec3c11432..87b733b738 100644 --- a/crates/ty_python_semantic/resources/lint_docs/unused-type-ignore-comment.md +++ b/crates/ty_python_semantic/resources/lint_docs/unused-type-ignore-comment.md @@ -4,8 +4,8 @@ Checks for `type: ignore` directives that are no longer applicable. ## Why is this bad? -A `type: ignore` directive that no longer matches any diagnostic violations is likely -included by mistake, and should be removed to avoid confusion. +A `type: ignore` directive that no longer matches any diagnostic violations is likely included by +mistake, and should be removed to avoid confusion. ## Examples @@ -22,5 +22,6 @@ a = 20 / 2 ## Options -This rule is skipped if [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) +This rule is skipped if +[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments) to `false`. diff --git a/crates/ty_python_semantic/resources/lint_docs/useless-overload-body.md b/crates/ty_python_semantic/resources/lint_docs/useless-overload-body.md index 739799a7ed..274a0d906e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/useless-overload-body.md +++ b/crates/ty_python_semantic/resources/lint_docs/useless-overload-body.md @@ -4,10 +4,10 @@ Checks for various `@overload`-decorated functions that have non-stub bodies. ## Why is this bad? -Functions decorated with `@overload` are ignored at runtime; they are overridden -by the implementation function that follows the series of overloads. While it is -not illegal to provide a body for an `@overload`-decorated function, it may indicate -a misunderstanding of how the `@overload` decorator works. +Functions decorated with `@overload` are ignored at runtime; they are overridden by the +implementation function that follows the series of overloads. While it is not illegal to provide a +body for an `@overload`-decorated function, it may indicate a misunderstanding of how the +`@overload` decorator works. ## Example diff --git a/crates/ty_python_semantic/resources/lint_docs/zero-stepsize-in-slice.md b/crates/ty_python_semantic/resources/lint_docs/zero-stepsize-in-slice.md index 9abc8d2276..5a071fb30e 100644 --- a/crates/ty_python_semantic/resources/lint_docs/zero-stepsize-in-slice.md +++ b/crates/ty_python_semantic/resources/lint_docs/zero-stepsize-in-slice.md @@ -8,9 +8,9 @@ Python's built-in sequence types raise a `ValueError` when sliced with a step si ## Known problems -This check is not exhaustive. It reports zero-step slices for certain built-in sequence -types where the operation is known to fail. A custom `__getitem__` implementation can -accept or reject such a slice, so ty cannot detect every runtime failure. +This check is not exhaustive. It reports zero-step slices for certain built-in sequence types where +the operation is known to fail. A custom `__getitem__` implementation can accept or reject such a +slice, so ty cannot detect every runtime failure. ## Examples diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/annotated.md b/crates/ty_python_semantic/resources/mdtest/annotations/annotated.md index f5ddf9639b..bdf4b0aad9 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/annotated.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/annotated.md @@ -22,6 +22,30 @@ def _(x: Annotated[tuple[str, int], bytes]): reveal_type(x) # revealed: tuple[str, int] ``` +## String annotations + +Metadata in a string annotation can include calls with unpacked dictionaries. The metadata does not +affect the annotated type, regardless of where the annotation appears. + +```py +from typing_extensions import Annotated + +value: "Annotated[int, dict(**{})]" + +def convert(value: "Annotated[str, dict(**{'name': 'value'})]") -> "Annotated[int, dict(**{})]": + reveal_type(value) # revealed: str + return 1 +``` + +Conditional expressions are also valid metadata and do not affect the annotated type. + +```py +def flag() -> bool: + return True + +conditional_value: "Annotated[int, 1 if flag() else 2]" = 1 +``` + ## Inside `type[...]` `Annotated` can wrap a class or specialized generic class inside `type[...]` without changing the diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index fb4f3769c0..2580ed2879 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -69,6 +69,75 @@ def _(c: Callable[[...], int]): reveal_type(c) # revealed: (...) -> int ``` +The invalid parameter list also offers an autofix that replaces the list with an ellipsis. + +```py +def fixable(callback: Callable[[...], int]): ... # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: `[...]` is not a valid parameter list for `Callable` + --> src/mdtest_snippet.py:17:32 + | +17 | def fixable(callback: Callable[[...], int]): ... # snapshot: invalid-type-form + | ^^^^^ Did you mean `Callable[..., int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace `[...]` with `...` + | +16 | reveal_type(c) # revealed: (...) -> int + - def fixable(callback: Callable[[...], int]): ... # snapshot: invalid-type-form +17 + def fixable(callback: Callable[..., int]): ... # snapshot: invalid-type-form +18 | def with_comments( + | +note: This is an unsafe fix and may change runtime behavior +``` + +A multiline parameter list can contain comments, so its brackets are not removed automatically. + +```py +def with_comments( + callback: Callable[ + [ # snapshot: invalid-type-form + # The callable accepts arbitrary arguments. + ..., # The parameter description remains documented. + ], + int, + ], +): ... +``` + +```snapshot +error[invalid-type-form]: `[...]` is not a valid parameter list for `Callable` + --> src/mdtest_snippet.py:20:9 + | +20 | / [ # snapshot: invalid-type-form +21 | | # The callable accepts arbitrary arguments. +22 | | ..., # The parameter description remains documented. +23 | | ], + | |_________^ Did you mean `Callable[..., int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +A quoted callable annotation still receives the diagnostic, but its parsed source range cannot be +rewritten directly. + +```py +# snapshot: invalid-type-form +def quoted(callback: "Callable[[...], int]"): ... +``` + +```snapshot +error[invalid-type-form]: `[...]` is not a valid parameter list for `Callable` + --> src/mdtest_snippet.py:28:32 + | +28 | def quoted(callback: "Callable[[...], int]"): ... + | ^^^^^ Did you mean `Callable[..., int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + ```py # error: [invalid-type-form] "`...` is not allowed in this context in a parameter annotation" def _(c: Callable[[int, ...], int]): @@ -491,9 +560,8 @@ def f_okay(c: Callable[[], None]): if hasattr(c, "__qualname__"): reveal_type(c.__qualname__) # revealed: object - # TODO: should be `property` - # (or complain that we don't know that `type(c)` has the attribute at all!) - reveal_type(type(c).__qualname__) # revealed: @Todo(Intersection meta-type) + # This is the class object's own qualified name, not the instance's descriptor. + reveal_type(type(c).__qualname__) # revealed: str # `hasattr` only guarantees that an attribute is readable. # @@ -504,8 +572,8 @@ def f_okay(c: Callable[[], None]): # into a writable attribute...? What would that look like? Something like this? if ( hasattr(type(c), "__qualname__") - and isinstance(type(c).__qualname__, property) - and type(c).__qualname__.fset is not None + and isinstance(descriptor := type(c).__qualname__, property) + and descriptor.fset is not None ): c.__qualname__ = "my_callable" # error: [invalid-assignment] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/generic_alias.md b/crates/ty_python_semantic/resources/mdtest/annotations/generic_alias.md index c694d1cb9d..e1036281de 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/generic_alias.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/generic_alias.md @@ -1,4 +1,6 @@ -# GenericAlias in type expressions +# Generic aliases + +## Type expressions We recognize if a `types.GenericAlias` instance is created by specializing a generic class. We don't explicitly mention it in our type display, but `list[int]` in the example below is a `GenericAlias` @@ -38,3 +40,62 @@ However, using such a `GenericAlias` instance in a type expression is currently def _(strings: Strings) -> None: reveal_type(strings) # revealed: Unknown ``` + +## Attributes of `type` aliases + +The alias objects `type[Any]` and `typing.Type[Any]` delegate attribute access to their origin, +`type`. They do not have arbitrary attributes, even though their type argument is dynamic. + +```py +from typing import Any, Type, TypeAlias + +Modern: TypeAlias = type[Any] +Legacy: TypeAlias = Type[Any] + +Modern.missing # error: [unresolved-attribute] +Legacy.missing # error: [unresolved-attribute] + +reveal_type(Modern.__name__) # revealed: str +reveal_type(Legacy.__name__) # revealed: str +``` + +Attributes belonging to the alias itself remain accessible. + +```py +reveal_type(Modern.__args__) # revealed: tuple[Any, ...] +reveal_type(Legacy.__args__) # revealed: tuple[Any, ...] +Modern.__origin__ +Legacy.__origin__ +Modern.__mro_entries__((object,)) +``` + +The origin is `type` regardless of the type argument. Attributes of `int` are not available on the +alias object `type[int]`. + +```py +Integers = type[int] +LegacyIntegers = Type[int] + +Integers.bit_length # error: [unresolved-attribute] +LegacyIntegers.bit_length # error: [unresolved-attribute] +``` + +When these aliases are used as annotations, their inhabitants can be arbitrary class objects. +Accessing an unknown attribute through such a parameter remains valid. + +```py +def dynamic_class(modern: Modern, legacy: Legacy) -> None: + reveal_type(modern.missing) # revealed: Any + reveal_type(legacy.missing) # revealed: Any +``` + +## Attributes of arbitrary `GenericAlias` instances + +When the origin is unknown, we allow arbitrary attribute access through a `GenericAlias` instance. + +```py +from types import GenericAlias + +def unknown_origin(alias: GenericAlias) -> None: + reveal_type(alias.missing) # revealed: Any +``` diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md index d68304aa18..a7b2d3ac88 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md @@ -204,6 +204,110 @@ annotation: x: "[[foo]]" ``` +## Invalid subscript operands in string annotations + +Invalid subscript operands in string annotations must not be evaluated. In particular, lambda +defaults and functional `TypedDict` arguments should not produce cascading unresolved-reference +diagnostics, and assignment expressions should not cause a panic. + +`runtime.py`: + +```py +from typing_extensions import TypedDict + +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +a: "(lambda value=missing: None)[int]" +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +b: "(lambda value=(name := int): None)[int]" +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +c: "TypedDict('T', {}, extra_items=missing)[int]" +``` + +The same error-recovery behavior applies to annotations in stub files: + +`stub.pyi`: + +```pyi +from typing_extensions import TypedDict + +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +a: "(lambda value=missing: None)[int]" +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +b: "(lambda value=(name := int): None)[int]" +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +c: "TypedDict('T', {}, extra_items=missing)[int]" +``` + +## Invalid subscript arguments in string annotations + +Even when a subscript's operand is a valid name, it might not be a generic type. We reject such +specializations without evaluating their arguments in string annotations. Unsupported `type[...]` +arguments are checked as type expressions while retaining their existing fallback type. + +`runtime.py`: + +```py +from typing import Any, Tuple + +# error: [invalid-type-form] "Non-generic class `int` cannot be specialized" +a: "int[(name := missing)]" +# error: [invalid-type-form] "Non-generic class `int` cannot be specialized" +b: "type[int[(name := missing)]]" +# error: [invalid-type-form] "Named expressions are not allowed" +c: "type[(name := missing)]" +# error: [invalid-type-form] "Named expressions are not allowed" +d: "type[Any[(name := missing)]]" +# error: [invalid-type-form] "`lambda` expressions are not allowed" +e: "type[Tuple[lambda default=(name := missing): None]]" +# error: [invalid-type-form] "`lambda` expressions are not allowed" +f: "type[lambda default=(name := missing): None]" +``` + +Stub files use the same error recovery. + +`stub.pyi`: + +```pyi +from typing import Any, Tuple + +# error: [invalid-type-form] "Non-generic class `int` cannot be specialized" +a: "int[(name := missing)]" +# error: [invalid-type-form] "Non-generic class `int` cannot be specialized" +b: "type[int[(name := missing)]]" +# error: [invalid-type-form] "Named expressions are not allowed" +c: "type[(name := missing)]" +# error: [invalid-type-form] "Named expressions are not allowed" +d: "type[Any[(name := missing)]]" +# error: [invalid-type-form] "`lambda` expressions are not allowed" +e: "type[Tuple[lambda default=(name := missing): None]]" +# error: [invalid-type-form] "`lambda` expressions are not allowed" +f: "type[lambda default=(name := missing): None]" +``` + +## Invalid subscript arguments in evaluated annotations + +For annotations that are evaluated, we report invalid type arguments and errors encountered while +evaluating them. + +```toml +[environment] +python-version = "3.13" +``` + +```py +# error: [invalid-type-form] "Non-generic class `int` cannot be specialized" +# error: [unresolved-reference] "Name `missing` used when not defined" +a: int[(name := missing)] + +# error: [invalid-type-form] "Named expressions are not allowed" +# error: [unresolved-reference] "Name `other_missing` used when not defined" +b: type[(other := other_missing)] + +# error: [invalid-type-form] "Function calls are not allowed" +# error: [unresolved-reference] "Name `missing_call` used when not defined" +c: type[missing_call()] +``` + ## Multiple starred expressions in a `tuple` specialization @@ -370,10 +474,10 @@ class name_4[name_1: [{}]]: ## Diagnostics for common errors - - ### Module-literal used when you meant to use a class from that module + + It's pretty common in Python to accidentally use a module-literal type in a type expression when you *meant* to use a class by the same name that comes from that module. We emit a nice subdiagnostic for this case: @@ -400,55 +504,861 @@ from PIL import Image def g(x: Image): ... # error: [invalid-type-form] ``` -### List-literal used when you meant to use a list +### Collection literals used as type expressions + +Collection literals are not valid type expressions. When the intended collection type is clear, we +suggest a subscripted builtin and offer an unsafe fix when that builtin is available. + +#### List literals + +A list literal with one element suggests a `list` annotation. We offer a fix in both parameter and +return annotations. ```py def _( - x: [int], # error: [invalid-type-form] -) -> [int]: # error: [invalid-type-form] + x: [int], # snapshot: invalid-type-form +) -> [int]: # snapshot: invalid-type-form return x +``` -# No special hints for these: it's unclear what the user meant: +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a parameter annotation + --> src/mdtest_snippet.py:2:8 + | +2 | x: [int], # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `list[...]` + | +1 | def _( + - x: [int], # snapshot: invalid-type-form +2 + x: list[int], # snapshot: invalid-type-form +3 | ) -> [int]: # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: List literals are not allowed in this context in a return type annotation + --> src/mdtest_snippet.py:3:6 + | +3 | ) -> [int]: # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `list[...]` + | +2 | x: [int], # snapshot: invalid-type-form + - ) -> [int]: # snapshot: invalid-type-form +3 + ) -> list[int]: # snapshot: invalid-type-form +4 | return x + | +note: This is an unsafe fix and may change runtime behavior +``` + +A list literal with several elements is ambiguous, so we do not suggest a replacement. + +```py def _( - x: [int, str], # error: [invalid-type-form] -) -> [int, str]: # error: [invalid-type-form] + x: [int, str], # snapshot: invalid-type-form +) -> [int, str]: # snapshot: invalid-type-form return x ``` -### Tuple-literal used when you meant to use a tuple +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a parameter annotation + --> src/mdtest_snippet.py:6:8 + | +6 | x: [int, str], # snapshot: invalid-type-form + | ^^^^^^^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: List literals are not allowed in this context in a return type annotation + --> src/mdtest_snippet.py:7:6 + | +7 | ) -> [int, str]: # snapshot: invalid-type-form + | ^^^^^^^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +#### Tuple literals + +An empty tuple literal suggests `tuple[()]`, the type of an empty tuple. ```py def _( - x: (), # error: [invalid-type-form] -) -> (): # error: [invalid-type-form] + x: (), # snapshot: invalid-type-form +) -> (): # snapshot: invalid-type-form return x ``` +```snapshot +error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation + --> src/mdtest_snippet.py:2:8 + | +2 | x: (), # snapshot: invalid-type-form + | ^^ Did you mean `tuple[()]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +1 | def _( + - x: (), # snapshot: invalid-type-form +2 + x: tuple[()], # snapshot: invalid-type-form +3 | ) -> (): # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation + --> src/mdtest_snippet.py:3:6 + | +3 | ) -> (): # snapshot: invalid-type-form + | ^^ Did you mean `tuple[()]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +2 | x: (), # snapshot: invalid-type-form + - ) -> (): # snapshot: invalid-type-form +3 + ) -> tuple[()]: # snapshot: invalid-type-form +4 | return x + | +note: This is an unsafe fix and may change runtime behavior +``` + +A tuple literal with one element suggests a fixed-length tuple with one element. + ```py def _( - x: (int,), # error: [invalid-type-form] -) -> (int,): # error: [invalid-type-form] + x: (int,), # snapshot: invalid-type-form +) -> (int,): # snapshot: invalid-type-form return x ``` +```snapshot +error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation + --> src/mdtest_snippet.py:6:8 + | +6 | x: (int,), # snapshot: invalid-type-form + | ^^^^^^ Did you mean `tuple[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +5 | def _( + - x: (int,), # snapshot: invalid-type-form +6 + x: tuple[int], # snapshot: invalid-type-form +7 | ) -> (int,): # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation + --> src/mdtest_snippet.py:7:6 + | +7 | ) -> (int,): # snapshot: invalid-type-form + | ^^^^^^ Did you mean `tuple[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +6 | x: (int,), # snapshot: invalid-type-form + - ) -> (int,): # snapshot: invalid-type-form +7 + ) -> tuple[int]: # snapshot: invalid-type-form +8 | return x + | +note: This is an unsafe fix and may change runtime behavior +``` + +A tuple literal with several elements suggests a fixed-length tuple with the corresponding element +types. + ```py def _( - x: (int, str), # error: [invalid-type-form] -) -> (int, str): # error: [invalid-type-form] + x: (int, str), # snapshot: invalid-type-form +) -> (int, str): # snapshot: invalid-type-form return x ``` -### Dict-literal or set-literal when you meant to use `dict[]`/`set[]` +```snapshot +error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation + --> src/mdtest_snippet.py:10:8 + | +10 | x: (int, str), # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +9 | def _( + - x: (int, str), # snapshot: invalid-type-form +10 + x: tuple[int, str], # snapshot: invalid-type-form +11 | ) -> (int, str): # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation + --> src/mdtest_snippet.py:11:6 + | +11 | ) -> (int, str): # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +10 | x: (int, str), # snapshot: invalid-type-form + - ) -> (int, str): # snapshot: invalid-type-form +11 + ) -> tuple[int, str]: # snapshot: invalid-type-form +12 | return x + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Dict and set literals + +A dictionary literal with one entry suggests `dict[Key, Value]`, and a set literal with one element +suggests `set[Element]`. ```py def _( - x: {int: str}, # error: [invalid-type-form] - y: {str}, # error: [invalid-type-form] + x: {int: str}, # snapshot: invalid-type-form + y: {str}, # snapshot: invalid-type-form ): ... ``` +```snapshot +error[invalid-type-form]: Dict literals are not allowed in parameter annotations + --> src/mdtest_snippet.py:2:8 + | +2 | x: {int: str}, # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `dict[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `dict[...]` + | +1 | def _( + - x: {int: str}, # snapshot: invalid-type-form +2 + x: dict[int, str], # snapshot: invalid-type-form +3 | y: {str}, # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Set literals are not allowed in parameter annotations + --> src/mdtest_snippet.py:3:8 + | +3 | y: {str}, # snapshot: invalid-type-form + | ^^^^^ Did you mean `set[str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `set[...]` + | +2 | x: {int: str}, # snapshot: invalid-type-form + - y: {str}, # snapshot: invalid-type-form +3 + y: set[str], # snapshot: invalid-type-form +4 | ): ... + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Parenthesized collection elements + +Rewriting a tuple literal preserves parentheses around its first and last elements, including nested +parentheses and parentheses around the entire tuple. + +```py +# fmt: off +first: ((int), str) # snapshot: invalid-type-form +last: (int, (str)) # snapshot: invalid-type-form +single: (((int)),) # snapshot: invalid-type-form +outer: (((int), (str))) # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:2:8 + | +2 | first: ((int), str) # snapshot: invalid-type-form + | ^^^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +1 | # fmt: off + - first: ((int), str) # snapshot: invalid-type-form +2 + first: tuple[(int), str] # snapshot: invalid-type-form +3 | last: (int, (str)) # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:3:7 + | +3 | last: (int, (str)) # snapshot: invalid-type-form + | ^^^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +2 | first: ((int), str) # snapshot: invalid-type-form + - last: (int, (str)) # snapshot: invalid-type-form +3 + last: tuple[int, (str)] # snapshot: invalid-type-form +4 | single: (((int)),) # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:4:9 + | +4 | single: (((int)),) # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `tuple[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +3 | last: (int, (str)) # snapshot: invalid-type-form + - single: (((int)),) # snapshot: invalid-type-form +4 + single: tuple[((int))] # snapshot: invalid-type-form +5 | outer: (((int), (str))) # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:5:9 + | +5 | outer: (((int), (str))) # snapshot: invalid-type-form + | ^^^^^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `tuple[...]` + | +4 | single: (((int)),) # snapshot: invalid-type-form + - outer: (((int), (str))) # snapshot: invalid-type-form +5 + outer: (tuple[(int), (str)]) # snapshot: invalid-type-form +6 | # fmt: off + | +note: This is an unsafe fix and may change runtime behavior +``` + +Dictionary keys and values, set elements, and list elements also retain their parentheses. + +```py +# fmt: off +key: {(int): str} # snapshot: invalid-type-form +value: {int: ((str)),} # snapshot: invalid-type-form +items: {((int)),} # snapshot: invalid-type-form +values: [((int))] # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:7:6 + | +7 | key: {(int): str} # snapshot: invalid-type-form + | ^^^^^^^^^^^^ Did you mean `dict[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `dict[...]` + | +6 | # fmt: off + - key: {(int): str} # snapshot: invalid-type-form +7 + key: dict[(int), str] # snapshot: invalid-type-form +8 | value: {int: ((str)),} # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:8:8 + | +8 | value: {int: ((str)),} # snapshot: invalid-type-form + | ^^^^^^^^^^^^^^^ Did you mean `dict[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `dict[...]` + | +7 | key: {(int): str} # snapshot: invalid-type-form + - value: {int: ((str)),} # snapshot: invalid-type-form +8 + value: dict[int, ((str))] # snapshot: invalid-type-form +9 | items: {((int)),} # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:9:8 + | +9 | items: {((int)),} # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `set[...]` + | +8 | value: {int: ((str)),} # snapshot: invalid-type-form + - items: {((int)),} # snapshot: invalid-type-form +9 + items: set[((int))] # snapshot: invalid-type-form +10 | values: [((int))] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:10:9 + | +10 | values: [((int))] # snapshot: invalid-type-form + | ^^^^^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `list[...]` + | +9 | items: {((int)),} # snapshot: invalid-type-form + - values: [((int))] # snapshot: invalid-type-form +10 + values: list[((int))] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Required parentheses in collection elements + +Some expressions require parentheses inside a subscript. Although `yield` expressions are not valid +type expressions, the fix preserves their parentheses to avoid introducing a syntax error. + +```py +def generator(): + yielded_key: {(yield int): str} # snapshot: invalid-type-form + yielded_value: {int: (yield str)} # snapshot: invalid-type-form + yielded_element: {(yield int)} # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:2:18 + | +2 | yielded_key: {(yield int): str} # snapshot: invalid-type-form + | ^^^^^^^^^^^^^^^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `dict[...]` + | +1 | def generator(): + - yielded_key: {(yield int): str} # snapshot: invalid-type-form +2 + yielded_key: dict[(yield int), str] # snapshot: invalid-type-form +3 | yielded_value: {int: (yield str)} # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:3:20 + | +3 | yielded_value: {int: (yield str)} # snapshot: invalid-type-form + | ^^^^^^^^^^^^^^^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `dict[...]` + | +2 | yielded_key: {(yield int): str} # snapshot: invalid-type-form + - yielded_value: {int: (yield str)} # snapshot: invalid-type-form +3 + yielded_value: dict[int, (yield str)] # snapshot: invalid-type-form +4 | yielded_element: {(yield int)} # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:4:22 + | +4 | yielded_element: {(yield int)} # snapshot: invalid-type-form + | ^^^^^^^^^^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `set[...]` + | +3 | yielded_value: {int: (yield str)} # snapshot: invalid-type-form + - yielded_element: {(yield int)} # snapshot: invalid-type-form +4 + yielded_element: set[(yield int)] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Collection literal fixes require Python 3.9 or later + +Builtin collection types cannot be subscripted on Python 3.8, so their literals do not receive fixes +that would introduce unsupported subscripts. + +```toml +[environment] +python-version = "3.8" +``` + +```py +as_list: [int] # snapshot: invalid-type-form +as_tuple: (int,) # snapshot: invalid-type-form +as_dict: {str: int} # snapshot: invalid-type-form +as_set: {int} # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:1:10 + | +1 | as_list: [int] # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:2:11 + | +2 | as_tuple: (int,) # snapshot: invalid-type-form + | ^^^^^^ Did you mean `tuple[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:3:10 + | +3 | as_dict: {str: int} # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `dict[str, int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:4:9 + | +4 | as_set: {int} # snapshot: invalid-type-form + | ^^^^^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +#### Collection literal fixes are omitted for starred elements + +Starred subscripts are unavailable before Python 3.11, so starred collection elements cannot be +rewritten into subscripts when targeting Python 3.10. + +```toml +[environment] +python-version = "3.10" +``` + +```py +types = (int, str) + +as_list: [*types] # snapshot: invalid-type-form +as_tuple: (*types,) # snapshot: invalid-type-form +as_set: {*types} # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:3:10 + | +3 | as_list: [*types] # snapshot: invalid-type-form + | ^^^^^^^^ Did you mean `list[tuple[Unknown, ...]]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:4:11 + | +4 | as_tuple: (*types,) # snapshot: invalid-type-form + | ^^^^^^^^^ Did you mean `tuple[tuple[Unknown, ...]]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:5:9 + | +5 | as_set: {*types} # snapshot: invalid-type-form + | ^^^^^^^^ Did you mean `set[tuple[Unknown, ...]]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +#### Collection literal fixes are omitted for multiline annotations + +Multiline collection literals can contain comments that would be removed by replacing their +delimiters, so we do not offer an autofix. + +`list.py`: + +```py +values: [ # snapshot: invalid-type-form + # The element must not be discarded. + int, +] +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/list.py:1:9 + | +1 | values: [ # snapshot: invalid-type-form + | _________^ +2 | | # The element must not be discarded. +3 | | int, +4 | | ] + | |_^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +The same restriction applies to tuple literals. + +`tuple.py`: + +```py +value: ( # snapshot: invalid-type-form + # The first type remains documented. + int, + str, # The final type remains documented. +) +``` + +```snapshot +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/tuple.py:1:8 + | +1 | value: ( # snapshot: invalid-type-form + | ________^ +2 | | # The first type remains documented. +3 | | int, +4 | | str, # The final type remains documented. +5 | | ) + | |_^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +A dictionary literal may have comments around its key, colon, value, or trailing comma. + +`dict.py`: + +```py +mapping: { # snapshot: invalid-type-form + # The key remains documented. + str: # The separator remains documented. + # The value remains documented. + int, # The trailing comma remains documented. +} +``` + +```snapshot +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/dict.py:1:10 + | +1 | mapping: { # snapshot: invalid-type-form + | __________^ +2 | | # The key remains documented. +3 | | str: # The separator remains documented. +4 | | # The value remains documented. +5 | | int, # The trailing comma remains documented. +6 | | } + | |_^ Did you mean `dict[str, int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +Set literals can likewise contain comments around their element. + +`set.py`: + +```py +items: { # snapshot: invalid-type-form + # The element remains documented. + int, # The trailing comma remains documented. +} +``` + +```snapshot +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/set.py:1:8 + | +1 | items: { # snapshot: invalid-type-form + | ________^ +2 | | # The element remains documented. +3 | | int, # The trailing comma remains documented. +4 | | } + | |_^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +#### Class attributes do not shadow collection builtins in methods + +A class attribute named `set` is not visible when resolving names in a method body, so it does not +prevent an annotation from being rewritten with the builtin `set`. + +```py +class Container: + set = 42 + + def check(self): + value: {int} # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:5:16 + | +5 | value: {int} # snapshot: invalid-type-form + | ^^^^^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `set[...]` + | +4 | def check(self): + - value: {int} # snapshot: invalid-type-form +5 + value: set[int] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Class attributes in nested annotation scopes + +A generic type alias can access attributes of its enclosing class through its type-parameter scope. +A class attribute named `list` therefore shadows the builtin in the alias's value. + +```toml +[environment] +python-version = "3.12" +``` + +```py +class C: + list = 42 + + # TODO: `visible_ancestor_scopes` skips the class through nested annotation scopes, + # so we incorrectly offer a fix that resolves `list` to `C.list`. + type Alias[T] = [int] # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type alias value + --> src/mdtest_snippet.py:6:21 + | +6 | type Alias[T] = [int] # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `list[...]` + | +5 | # so we incorrectly offer a fix that resolves `list` to `C.list`. + - type Alias[T] = [int] # snapshot: invalid-type-form +6 + type Alias[T] = list[int] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior +``` + +#### Collection literal fixes with project-level builtin overrides + +A project-level `__builtins__.pyi` can replace `list` while leaving the standard `set` builtin +available. We suppress only the fix that would reference the overridden builtin. + +```py +overridden: [int] # snapshot: invalid-type-form +standard: {int} # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:1:13 + | +1 | overridden: [int] # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:2:11 + | +2 | standard: {int} # snapshot: invalid-type-form + | ^^^^^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +help: Replace with `set[...]` + | +1 | overridden: [int] # snapshot: invalid-type-form + - standard: {int} # snapshot: invalid-type-form +2 + standard: set[int] # snapshot: invalid-type-form + | +note: This is an unsafe fix and may change runtime behavior +``` + +`__builtins__.pyi`: + +```pyi +list: object +``` + +#### Collection literal fixes are omitted in string annotations + +Collection literals parsed from quoted annotations do not have source ranges that can be rewritten +directly, so their diagnostics do not offer collection-literal fixes. + +```py +quoted_list: "[int]" # snapshot: invalid-type-form +quoted_tuple: "(int, str)" # snapshot: invalid-type-form +quoted_dict: "{int: str}" # snapshot: invalid-type-form +quoted_set: "{int}" # snapshot: invalid-type-form +``` + +```snapshot +error[invalid-type-form]: List literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:1:15 + | +1 | quoted_list: "[int]" # snapshot: invalid-type-form + | ^^^^^ Did you mean `list[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Tuple literals are not allowed in this context in a type expression + --> src/mdtest_snippet.py:2:16 + | +2 | quoted_tuple: "(int, str)" # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `tuple[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Dict literals are not allowed in type expressions + --> src/mdtest_snippet.py:3:15 + | +3 | quoted_dict: "{int: str}" # snapshot: invalid-type-form + | ^^^^^^^^^^ Did you mean `dict[int, str]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions + + +error[invalid-type-form]: Set literals are not allowed in type expressions + --> src/mdtest_snippet.py:4:14 + | +4 | quoted_set: "{int}" # snapshot: invalid-type-form + | ^^^^^ Did you mean `set[int]`? +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + ### Special-cased diagnostic for `callable` used in a type expression + + ```py # error: [invalid-type-form] # error: [invalid-type-form] @@ -458,6 +1368,8 @@ def decorator(fn: callable) -> callable: ### AST nodes that are only valid inside `Literal` + + ```py def bad( # error: [invalid-type-form] diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/literal.md b/crates/ty_python_semantic/resources/mdtest/annotations/literal.md index f9df1e65c4..a25e47348c 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/literal.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/literal.md @@ -359,3 +359,69 @@ from typing import Literal def _(x: Literal): reveal_type(x) # revealed: Unknown ``` + +## Invalid expressions in string annotations + +Invalid `Literal` arguments in string annotations are checked without looking up assignment +expressions that are absent from the semantic index. Missing names retain their diagnostics. + +`runtime.py`: + +```py +from typing import Literal + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +a: "Literal[int[(name := missing)]]" +b: "Literal[(name := int)[0]]" # error: [invalid-type-form] +c: "Literal[(name := 0).real]" # error: [invalid-type-form] + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_nested` used when not defined" +value: "Literal[int[missing_nested]]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_call` used when not defined" +call: "Literal[int[missing_call()]]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_left` used when not defined" +# error: [unresolved-reference] "Name `missing_right` used when not defined" +arguments: "Literal[int[(missing_left, missing_right)]]" + +def valid(value: "Literal[Literal[1], None]"): + reveal_type(value) # revealed: Literal[1] | None +``` + +The same error recovery applies in stub files. + +`stub.pyi`: + +```pyi +from typing import Literal + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +a: "Literal[int[(name := missing)]]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_nested` used when not defined" +b: "Literal[int[missing_nested]]" +``` + +## Invalid expressions in evaluated annotations + +When an annotation is evaluated, errors in its invalid arguments are still reported. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Literal + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +value: Literal[int[(name := missing)]] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/literal_string.md b/crates/ty_python_semantic/resources/mdtest/annotations/literal_string.md index 4d36fdf892..41e4c9ab22 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/literal_string.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/literal_string.md @@ -63,6 +63,87 @@ error[invalid-type-form]: `LiteralString` expects no type parameter | Did you mean `Literal`? ``` +### Parameterized string annotations + +Since `LiteralString` cannot be parameterized, its arguments are checked without looking up +assignment expressions that are absent from the semantic index. Missing names retain their +diagnostics. + +`runtime.py`: + +```py +from typing_extensions import LiteralString + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +a: "LiteralString[(name := missing)]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +# error: [not-subscriptable] +b: "LiteralString[int[(name := missing)]]" +c: "LiteralString[(name := 0).real]" # error: [invalid-type-form] + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +d: "LiteralString[lambda default=missing: None]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_direct` used when not defined" +direct: "LiteralString[missing_direct]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_nested` used when not defined" +# error: [not-subscriptable] +nested: "LiteralString[int[missing_nested]]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_call` used when not defined" +# error: [unresolved-reference] "Name `missing_argument` used when not defined" +call: "LiteralString[missing_call(missing_argument)]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_binary` used when not defined" +binary: "LiteralString[missing_binary + 1]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_list` used when not defined" +collection: "LiteralString[[missing_list]]" +``` + +The same error recovery applies in stub files. + +`stub.pyi`: + +```pyi +from typing_extensions import LiteralString + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +value: "LiteralString[(name := missing)]" + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing_direct` used when not defined" +direct: "LiteralString[missing_direct]" +``` + +### Parameterized evaluated annotations + +Evaluated annotations still report errors in their arguments. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing_extensions import LiteralString + +# error: [invalid-type-form] +# error: [unresolved-reference] "Name `missing` used when not defined" +value: LiteralString[(name := missing)] +``` + ### As a base class Subclassing `LiteralString` leads to a runtime error. @@ -73,6 +154,66 @@ from typing_extensions import LiteralString class C(LiteralString): ... # error: [invalid-base] ``` +### Literal suggestions in string annotations + +A literal alias is valid as a `Literal` argument, so a parameterized `LiteralString` with a literal +alias suggests `Literal`. + +```py +from typing_extensions import Literal, LiteralString + +Alias = Literal["value"] + +# snapshot: invalid-type-form +alias: "LiteralString[Alias]" +``` + +```snapshot +error[invalid-type-form]: `LiteralString` expects no type parameter + --> src/mdtest_snippet.py:6:9 + | +6 | alias: "LiteralString[Alias]" + | -------------^^^^^^^ + | | + | Did you mean `Literal`? +``` + +Aliases containing multiple literal values are also valid `Literal` arguments. + +```py +MultipleValues = Literal["a", "b"] + +# snapshot: invalid-type-form +multiple_values: "LiteralString[MultipleValues]" +``` + +```snapshot +error[invalid-type-form]: `LiteralString` expects no type parameter + --> src/mdtest_snippet.py:10:19 + | +10 | multiple_values: "LiteralString[MultipleValues]" + | -------------^^^^^^^^^^^^^^^^ + | | + | Did you mean `Literal`? +``` + +Ordinary variables are not valid `Literal` arguments, even if their values are strings. + +```py +value = "value" + +# snapshot: invalid-type-form +variable: "LiteralString[value]" +``` + +```snapshot +error[invalid-type-form]: `LiteralString` expects no type parameter + --> src/mdtest_snippet.py:14:12 + | +14 | variable: "LiteralString[value]" + | ^^^^^^^^^^^^^^^^^^^^ +``` + ## Inference ### Common operations diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/self.md b/crates/ty_python_semantic/resources/mdtest/annotations/self.md index 8f66d5fb2f..5c92a287fe 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/self.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/self.md @@ -560,6 +560,20 @@ def _(c: MyClass): c.field = c ``` +A generic type alias preserves `Self` when it is used in an attribute annotation: + +```py +type Identity[T] = T + +class AliasedNode: + parent: Identity[Self] + + def __init__(self) -> None: + self.parent = self + +reveal_type(AliasedNode().parent) # revealed: AliasedNode +``` + Self from class body annotations and method signatures represent the same logical type variable. When a method returns an attribute annotated with `Self` in the class body, the class-body `Self` and the method's `Self` should be considered the same type, even though they have different binding @@ -664,6 +678,25 @@ reveal_type(int_container) # revealed: Container[int] reveal_type(int_container.set_value(1)) # revealed: Container[int] ``` +## Unbound inherited methods on generic classes + +When an inherited method returns `Self`, its return type is the type of the instance passed to it. +This includes the subclass and its type arguments, even when the call uses `Child` rather than +`Child[int]`. + +```py +from typing import Self + +class Parent[T]: + def get_self(self) -> Self: + return self + +class Child[U](Parent[U]): ... + +def _(child: Child[int]): + reveal_type(Child.get_self(child)) # revealed: Child[int] +``` + ## Generic class with bounded type variable This is a regression test for . @@ -1405,6 +1438,48 @@ reveal_type(D().instance_method) reveal_type(D.class_method) ``` +A generic type alias does not prevent binding `Self` in the method signature: + +```py +from typing import Self + +type Identity[T] = T + +class Aliased: + def copy(self, other: Identity[Self]) -> Identity[Self]: + return other + +# revealed: bound method Aliased.copy(other: Aliased) -> Aliased +reveal_type(Aliased().copy) +``` + +`Self` also binds in a parameter annotation when the return type does not contain `Self`: + +```py +class ParameterOnly: + def consume(self, other: Identity[Self]) -> None: ... + +# revealed: bound method ParameterOnly.consume(other: ParameterOnly) +reveal_type(ParameterOnly().consume) + +ParameterOnly().consume(ParameterOnly()) +ParameterOnly().consume(object()) # error: [invalid-argument-type] +``` + +Nested uses of the same alias still bind `Self` to the concrete receiver, including when a subclass +inherits the method: + +```py +class NestedAlias: + def copy(self, other: Identity[Identity[Self]]) -> Identity[Identity[Self]]: + return other + +class NestedChild(NestedAlias): ... + +# revealed: bound method NestedChild.copy(other: NestedChild) -> NestedChild +reveal_type(NestedChild().copy) +``` + In nested functions `self` binds to the method. So in the following example the `self` in `C.b` is bound at `C.f`. diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/starred.md b/crates/ty_python_semantic/resources/mdtest/annotations/starred.md index 2d5e4dd108..6bd1b1290d 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/starred.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/starred.md @@ -5,7 +5,7 @@ python-version = "3.11" ``` -Type annotations for `*args` can be starred expressions themselves: +An unpacked type variable tuple keeps the types of positional arguments passed to `*args`. ```py from typing_extensions import TypeVarTuple @@ -17,14 +17,20 @@ def append_int(*args: *Ts) -> tuple[*Ts, int]: return (*args, 1) -# TODO should be tuple[Literal[True], Literal["a"], int] -reveal_type(append_int(True, "a")) # revealed: tuple[*tuple[Unknown, ...], int] -# TODO should be tuple[int] -reveal_type(append_int()) # revealed: tuple[*tuple[Unknown, ...], int] +reveal_type(append_int(True, "a")) # revealed: tuple[Literal[True], Literal["a"], int] +reveal_type(append_int()) # revealed: tuple[int] +``` + +A concrete starred tuple checks its fixed first argument, remaining argument types, and arity. +```py def first_arg_int(*args: *tuple[int, *tuple[str, ...]]): ... first_arg_int(42, "42", "42") # fine -first_arg_int("not an int", "42", "42") # error: [invalid-argument-type] -first_arg_int(56, "42", 56) # error: [invalid-argument-type] +# error: [invalid-argument-type] "Argument to function `first_arg_int` is incorrect: Expected `int`" +first_arg_int("not an int", "42", "42") +# error: [invalid-argument-type] "Argument to function `first_arg_int` is incorrect: Expected `str`, found `Literal[56]`" +first_arg_int(56, "42", 56) +# error: [missing-argument] "No argument provided for required parameter `*args` of function `first_arg_int`" +first_arg_int() ``` diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/string.md b/crates/ty_python_semantic/resources/mdtest/annotations/string.md index 6fed825953..deb634fa3c 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/string.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/string.md @@ -134,6 +134,46 @@ if TYPE_CHECKING: def f(x: "int" | "None"): ... ``` +### Protocol metaclasses + +A source protocol's default `_ProtocolMeta` does not supply a string-accepting `__or__` method. +Partially stringified unions with protocol classes and their subclasses fail at runtime before +Python 3.14. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Protocol + +class P(Protocol): ... +class Child(P): ... + +def f( + # error: [unsupported-operator] + x: P | "P", + # error: [unsupported-operator] + y: "Child" | Child, +): ... +``` + +A custom metaclass can accept strings in `__or__`. Deriving it from `type(Protocol)` also makes it +compatible with the protocol's runtime metaclass. + +```py +from typing import Any + +class Meta(type(Protocol)): + def __or__(cls, other: str) -> Any: + return other + +class Custom(P, metaclass=Meta): ... + +def g(x: Custom | "Custom"): ... +``` + ### Python less than 3.14 in a stub file This error is never emitted on stub files, because they are never executed at runtime: diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/union.md b/crates/ty_python_semantic/resources/mdtest/annotations/union.md index 40aac6afb8..9719494762 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/union.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/union.md @@ -85,3 +85,46 @@ X = int | str def f(y: X): reveal_type(y) # revealed: int | str ``` + +## Runtime class + +### Python 3.13 and earlier + +`typing.Union` is an instance of `typing._SpecialForm`, so it is not a class. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Union + +reveal_type(type(Union)) # revealed: + +def takes_type(cls: type) -> None: ... + +takes_type(Union) # error: [invalid-argument-type] +``` + +### Python 3.14 and later + +`typing.Union` is a class, as is its re-export from `typing_extensions`. + +```toml +[environment] +python-version = "3.14" +``` + +```py +from typing import Union +from typing_extensions import Union as ExtensionsUnion + +reveal_type(type(Union)) # revealed: +reveal_type(type(ExtensionsUnion)) # revealed: + +def takes_type(cls: type) -> None: ... + +takes_type(Union) +takes_type(ExtensionsUnion) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index d71db28642..c7d85d2f7c 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -426,23 +426,24 @@ def update(counter: Counter | None) -> None: counter.count += 1 ``` -An augmented assignment should not define an otherwise missing instance attribute, because it must -read an existing value before writing its result. We currently treat it like an ordinary -self-referential assignment instead. +An augmented assignment cannot define an otherwise missing instance attribute, because it must read +an existing value before writing its result. ```py class UninitializedCounter: def increment(self) -> None: - # TODO: Report an unresolved-attribute error instead of implicitly defining the attribute. + # error: [unresolved-attribute] self.value += 1 -reveal_type(UninitializedCounter().value) # revealed: Divergent +# error: [unresolved-attribute] +reveal_type(UninitializedCounter().value) # revealed: Unknown ``` ## Dynamically provided attributes -A dynamic attribute hook can provide the initial value read by an augmented assignment. The -assignment currently infers a divergent attribute type instead of preserving the hook's return type. +A dynamic attribute hook can provide the initial value read by an augmented assignment. Ordinary +attribute lookup preserves the hook's return type, but the resulting assignment is not yet +recognized as establishing instance storage. ```py class DynamicCounter: @@ -450,10 +451,11 @@ class DynamicCounter: return 0 def increment(self) -> None: + # TODO: Recognize the instance attribute established after reading from a dynamic hook. + # error: [unresolved-attribute] self.value += 1 -# TODO: Infer `int` from the dynamic attribute hook. -reveal_type(DynamicCounter().value) # revealed: Divergent +reveal_type(DynamicCounter().value) # revealed: int ``` The same behavior applies when the attribute is provided by `__getattribute__`. @@ -464,9 +466,11 @@ class InterceptedCounter: return 0 def increment(self) -> None: + # TODO: Recognize the instance attribute established after reading from a dynamic hook. + # error: [unresolved-attribute] self.value += 1 -reveal_type(InterceptedCounter().value) # revealed: Divergent +reveal_type(InterceptedCounter().value) # revealed: int ``` ## Class-level defaults in diamond inheritance diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/multi_target.md b/crates/ty_python_semantic/resources/mdtest/assignment/multi_target.md index 285f31f685..b9b952cc1e 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/multi_target.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/multi_target.md @@ -7,3 +7,86 @@ x = y = 1 reveal_type(x) # revealed: Literal[1] reveal_type(y) # revealed: Literal[1] ``` + +## Assignment expressions in shared values + +A value assigned to multiple targets can contain an assignment expression whose value is a lambda. +The shared assignment expression should bind its name once and give every target the same callable +type. + +```py +first = second = (named := lambda: 0) + +reveal_type(first) # revealed: () -> Literal[0] +reveal_type(second) # revealed: () -> Literal[0] +reveal_type(named) # revealed: () -> Literal[0] +``` + +## Assignment expressions with unpacking targets + +An unpacking target and a simple target share both the value and any assignment expressions inside +it. + +```py +(first, second) = pair = ((named := 0), lambda: 1) + +reveal_type(first) # revealed: Literal[0] +reveal_type(second) # revealed: () -> Literal[1] +reveal_type(pair) # revealed: tuple[Literal[0], () -> Literal[1]] +reveal_type(named) # revealed: Literal[0] +``` + +## Assignment expressions with subscript targets + +Subscript targets infer the shared value separately from name targets, but its nested binding still +belongs to the same assignment. + +```py +callbacks = [lambda: 0] +first = callbacks[0] = (named := lambda: 0) + +reveal_type(first) # revealed: () -> Literal[0] +reveal_type(named) # revealed: () -> Literal[0] +``` + +## Contextual inference in shared lambdas + +Each assignment target provides its own context to a shared lambda, even when the targets have +different parameter types. + +```py +from collections.abc import Callable + +first: Callable[[int], int] +second: Callable[[str], int] +first = second = lambda value: 0 + +reveal_type(first) # revealed: (value: int) -> Literal[0] +reveal_type(second) # revealed: (value: str) -> Literal[0] +``` + +## Contextual inference in assignment expressions + +A declared type on the assignment-expression target still supplies context to its lambda. + +```py +from collections.abc import Callable + +named: Callable[[int], int] +first = second = (named := lambda value: value.bit_length()) + +reveal_type(first) # revealed: (value: int) -> int +reveal_type(second) # revealed: (value: int) -> int +reveal_type(named) # revealed: (value: int) -> int +``` + +## Assignment expressions in lambda defaults + +A lambda default executes in the enclosing assignment, so an assignment expression in that default +creates a binding owned by the shared assignment statement. + +```py +first = second = lambda value=(named := 1): value + +reveal_type(named) # revealed: Literal[1] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/unsound.md b/crates/ty_python_semantic/resources/mdtest/assignment/unsound.md new file mode 100644 index 0000000000..3c0cc72385 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/assignment/unsound.md @@ -0,0 +1,735 @@ +# Unsound assignments + +In addition to `invalid-assignment`, we also offer a disabled-by-default stricter rule +`unsound-assignment`. This rule forbids assigning a value of type `A` to a variable with a fully +static declared type `B` unless `A` is a *subtype* of `B`. Assignments to attributes and subscripts +are outside its scope. + +```toml +[rules] +unsound-assignment = "error" +``` + +## Basics + +An assignment that is valid according to the usual assignability rules can still be unsound. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +# snapshot: unsound-assignment +value: int = returns_any() +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:14 + | +7 | value: int = returns_any() + | --- ^^^^^^^^^^^^^ Inferred as `Any` + | | + | Expected a subtype of `int` because of this annotation +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +A nested dynamic type causes the same problem, while genuinely incompatible values cause us to emit +only `invalid-assignment`. + +```py +# snapshot: unsound-assignment +nested_value: tuple[tuple[int, int]] = ((42, returns_any()),) + +invalid_value: int = "not an integer" # error: [invalid-assignment] +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:9:40 + | +9 | nested_value: tuple[tuple[int, int]] = ((42, returns_any()),) + | ---------------------- ^^^^^^^^^^^^^^^^^^^^^^ Inferred as `tuple[tuple[Literal[42], Any]]` + | | + | Expected a subtype of `tuple[tuple[int, int]]` because of this annotation +info: `tuple[tuple[Literal[42], Any]]` is assignable to `tuple[tuple[int, int]]`, but not a subtype of `tuple[tuple[int, int]]` +info: the first tuple element is not compatible: `tuple[Literal[42], Any]` is not a subtype of `tuple[int, int]` +info: └── the second tuple element is not compatible: `Any` is not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +Narrowing a dynamic value before assigning it makes the assignment sound. + +```py +dynamic_value = returns_any() +assert isinstance(dynamic_value, int) +narrowed_value: int = dynamic_value +``` + +## Unsound assignments to gradually typed targets + +The rule applies only when the target's declared type is fully static. An explicit `Any`, an alias +of `Any`, or an `Any` nested inside the annotation disables the strict check. + +```py +from typing import Any +from typing_extensions import Never, TypeAliasType + +AnyAlias = TypeAliasType("AnyAlias", Any) + +def returns_any() -> Any: + return "not an integer" + +dynamic_target: Any = returns_any() # no `unsound-assignment` error +aliased_dynamic_target: AnyAlias = returns_any() # no `unsound-assignment` error +nested_dynamic_target: tuple[int, Any] = returns_any() # no `unsound-assignment` error + +# error: [missing-type-argument] +unknown_target: list = returns_any() # no `unsound-assignment` error +``` + +`Never`, on the other hand, is fully static, so assigning `Any` to it is unsound. + +```py +never_target: Never = returns_any() # error: [unsound-assignment] +``` + +## Unsound assignments to an existing annotation + +The same check applies when an assignment's target was annotated separately. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +value: int + +# snapshot: unsound-assignment +value = returns_any() +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:9:9 + | +6 | value: int + | --- Expected a subtype of `int` because of this annotation +7 | +8 | # snapshot: unsound-assignment +9 | value = returns_any() + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +Subsequent reassignments of an annotated variable are also checked for soundness. + +```py +another_value: int = 42 +another_value = returns_any() # error: [unsound-assignment] +``` + +## Unsound assignments to annotated parameters + +Reassigning an annotated parameter points to its original type annotation. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +def update(value: int) -> None: + value = returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:13 + | +6 | def update(value: int) -> None: + | --- Expected a subtype of `int` because of this annotation +7 | value = returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to variadic positional parameters + +A variadic positional parameter's annotation describes its arguments, while the parameter itself is +a tuple. + +```py +from typing import Any + +def returns_any() -> Any: + return "not a tuple" + +def update(*values: int) -> None: + values = returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:14 + | +6 | def update(*values: int) -> None: + | --- Variadic parameter annotation declares the type as `tuple[int, ...]` +7 | values = returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `tuple[int, ...]`, but not a subtype of `tuple[int, ...]` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments with same-named types + +A variadic parameter's annotation uses the same qualified type names as the rest of the diagnostic. + +`first.py`: + +```py +class Value: ... +``` + +`second.py`: + +```py +class Value: ... +``` + +```py +from typing import Any +import first +import second + +def returns_any() -> Any: + return "not a Value" + +def update(*values: first.Value | second.Value) -> None: + values = (first.Value(), returns_any()) # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:9:14 + | +8 | def update(*values: first.Value | second.Value) -> None: + | -------------------------- Variadic parameter annotation declares the type as `tuple[first.Value | second.Value, ...]` +9 | values = (first.Value(), returns_any()) # snapshot: unsound-assignment + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Inferred as `tuple[first.Value, Any]` +info: `tuple[first.Value, Any]` is assignable to `tuple[first.Value | second.Value, ...]`, but not a subtype of `tuple[first.Value | second.Value, ...]` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to variadic keyword parameters + +A variadic keyword parameter's annotation describes its values, while the parameter itself is a +dictionary. + +```py +from typing import Any + +def returns_any() -> Any: + return "not a dictionary" + +def update(**values: int) -> None: + values = returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:14 + | +6 | def update(**values: int) -> None: + | --- Keyword-variadic parameter annotation declares the type as `dict[str, int]` +7 | values = returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `dict[str, int]`, but not a subtype of `dict[str, int]` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments with conflicting declarations + +When conflicting annotations contribute to the declared type, the diagnostic does not identify any +one annotation as the declared type. + +```py +from typing import Any + +def returns_any() -> Any: + return "not necessarily an integer or a string" + +def update(flag: bool) -> None: + if flag: + value: int + else: + value: str + + # error: [conflicting-declarations] + value = returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:13:13 + | +13 | value = returns_any() # snapshot: unsound-assignment + | ----- ^^^^^^^^^^^^^ Inferred as `Any` + | | + | Expected a subtype of `int | str` because of its declared type +info: `Any` is assignable to `int | str`, but not a subtype of `int | str` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments with equivalent declarations + +Distinct branches declaring the same type still establish a fully static assignment boundary. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +def update(flag: bool) -> None: + if flag: + value: int + else: + value: int + + value = returns_any() # error: [unsound-assignment] +``` + +## Unsound named and unpacked assignments + +Assignment expressions are also checked against an existing annotation: + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +named_value: int + +if named_value := returns_any(): # snapshot: unsound-assignment + pass +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:8:19 + | +6 | named_value: int + | --- Expected a subtype of `int` because of this annotation +7 | +8 | if named_value := returns_any(): # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +An unpacked assignment points to the individual expression that supplies its unsound value. + +```py +unpacked_value: int +unpacked_value, other_value = (returns_any(), "hello") # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:11:32 + | +10 | unpacked_value: int + | --- Expected a subtype of `int` because of this annotation +11 | unpacked_value, other_value = (returns_any(), "hello") # snapshot: unsound-assignment + | -------------- ^^^^^^^^^^^^^ Inferred as `Any` + | | + | Assigned to this variable +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to nested unpacking targets + +A nested tuple target points to the corresponding dynamic expression in the nested value. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +value: int +other, (value, last) = (0, (returns_any(), 1)) # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:29 + | +6 | value: int + | --- Expected a subtype of `int` because of this annotation +7 | other, (value, last) = (0, (returns_any(), 1)) # snapshot: unsound-assignment + | ----- ^^^^^^^^^^^^^ Inferred as `Any` + | | + | Assigned to this variable +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to starred unpacking targets + +A starred unpacking target identifies the dynamic expression collected into the assigned list. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +middle: list[int] +first, *middle, last = (0, returns_any(), 1) # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:28 + | +6 | middle: list[int] + | --------- Expected a subtype of `list[int]` because of this annotation +7 | first, *middle, last = (0, returns_any(), 1) # snapshot: unsound-assignment + | ------ ^^^^^^^^^^^^^ Iterable element inferred as `Any` (expected a subtype of `int`) + | | + | Assigned to this variable +info: `list[Any]` is assignable to `list[int]`, but not a subtype of `list[int]` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Multiple unsound values assigned to starred unpacking targets + +When a starred target collects multiple values, the unsound-assignment diagnostic covers the entire +collected slice without including the surrounding unpacked values. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +middle: list[int] +first, *middle, last = (0, 1, returns_any(), 2, 3) # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:28 + | +6 | middle: list[int] + | --------- Expected a subtype of `list[int]` because of this annotation +7 | first, *middle, last = (0, 1, returns_any(), 2, 3) # snapshot: unsound-assignment + | ------ ^^^^^^^^^^^^^^^^^^^ Iterable element inferred as `int | Any` (expected a subtype of `int`) + | | + | Assigned to this variable +info: `list[int | Any]` is assignable to `list[int]`, but not a subtype of `list[int]` +info: element `Any` of union `int | Any` is not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to for-loop targets + +An unsound loop assignment points to the target's earlier type annotation. + +```py +from typing import Any, cast + +value: int + +for value in cast(list[Any], []): # snapshot: unsound-assignment + pass +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:5:5 + | +3 | value: int + | --- Expected a subtype of `int` because of this annotation +4 | +5 | for value in cast(list[Any], []): # snapshot: unsound-assignment + | ^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound assignments to context-manager targets + +Context-manager targets are checked against their earlier type annotations. + +```py +from contextlib import nullcontext +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +value: int + +with nullcontext(returns_any()) as value: # error: [unsound-assignment] + pass +``` + +## Unsound assignments to global and nonlocal variables + +Assignments redirected by `global` or `nonlocal` are checked against the owning scope's declared +type. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +global_value: int = 42 + +def update_global() -> None: + global global_value + global_value = returns_any() # snapshot: unsound-assignment + +def outer() -> None: + nonlocal_value: int = 42 + + def update_nonlocal() -> None: + nonlocal nonlocal_value + nonlocal_value = returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:10:20 + | + 6 | global_value: int = 42 + | --- Expected a subtype of `int` because of this annotation + 7 | + 8 | def update_global() -> None: + 9 | global global_value +10 | global_value = returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it + + +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:17:26 + | +13 | nonlocal_value: int = 42 + | --- Expected a subtype of `int` because of this annotation +14 | +15 | def update_nonlocal() -> None: +16 | nonlocal nonlocal_value +17 | nonlocal_value = returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Unsound augmented assignments + +An augmented assignment highlights the dynamic right-hand operand. + +```py +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +value: int = 42 +value += returns_any() # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:7:10 + | +6 | value: int = 42 + | --- Expected a subtype of `int` because of this annotation +7 | value += returns_any() # snapshot: unsound-assignment + | ^^^^^^^^^^^^^ Inferred as `Any` +info: `Any` is assignable to `int`, but not a subtype of `int` +help: Consider using an `assert` to narrow the type before assigning it +``` + +When an in-place operator returns `Any`, the diagnostic highlights the full operation instead of +incorrectly attributing that type to a statically typed operand. + +```py +class Counter: + def __iadd__(self, other: int) -> Any: + return "not a Counter" + +counter: Counter = Counter() +counter += 1 # snapshot: unsound-assignment +``` + +```snapshot +error[unsound-assignment]: Unsound assignment + --> src/mdtest_snippet.py:13:1 + | +12 | counter: Counter = Counter() + | ------- Expected a subtype of `Counter` because of this annotation +13 | counter += 1 # snapshot: unsound-assignment + | ^^^^^^^^^^^^ Augmented assignment produces a value of type `Any` +info: `Any` is assignable to `Counter`, but not a subtype of `Counter` +help: Consider using an `assert` to narrow the type before assigning it +``` + +## Assignments to attributes + +The rule does not check assignments to attributes, even when the attribute has a fully static +declared type. This includes annotated assignments and augmented assignments. + +```py +from typing import Any + +class Example: + def __init__(self, value: Any) -> None: + self.value: int = value + + def update(self, value: Any) -> None: + self.value = value + self.value += value +``` + +## Assignments to subscripts + +The rule does not check assignments to subscripts, including augmented assignments, even when the +container's element type is fully static. + +```py +from typing import Any + +def update(values: list[int], value: Any) -> None: + values[0] = value + values[0] += value +``` + +## Assignments in dataclass bodies + +Assignments directly in a dataclass body are ignored by `unsound-assignment` because of how heavily +dataclass field specifiers are special-cased by ty and other type checkers. ty considers +`dataclasses.Field[str]` assignable to `str` in order to avoid emitting a diagnostic for +`x: str = dataclasses.field(default="foo")` in a dataclass class body, but limits this special case +to assignability: it does not consider `dataclasses.Field[str]` a *subtype* of `str`. You might +think that we could workaround this with a narrow special case for just `dataclasses.Field`, but +this alone would not be sufficient: third-party libraries often wrap `dataclasses.field()` and +annotate their field specifiers as returning `Any`, so the inferred assignment type no longer +identifies the underlying `Field`. For example: + +- [`betterproto` explicitly explains why its field specifiers return `Any`](https://github.com/danielgtaylor/python-betterproto/blob/098989e9e93c97e16e10257b1b3575f987180f8c/src/betterproto/__init__.py#L192-L220). +- [Expression's `case()` and `tag()` field specifiers do the same](https://github.com/cognitedata/Expression/blob/d0bcfbe1ce12634ef74531b4404d1bed6c05a090/expression/core/tagged_union.py#L190-L197). + +Flagging those assignments would report a huge number of dataclasses as being unsound, making it +untenable for users to enable the rule. + +```py +from dataclasses import dataclass, field +from typing import Any + +def returns_any() -> Any: + return "not an integer" + +def wrapped_field() -> Any: + return field() + +@dataclass +class Example: + required: int = field() + without_init: int = field(init=False) + with_default: int = field(default=42) + with_factory: list[int] = field(default_factory=lambda: [42]) + with_none: int | None = field(default=None) + + wrapped: int = wrapped_field() + dynamic_value: int = returns_any() + invalid_default: int = field(default="not an integer") # error: [invalid-assignment] + + def method(self) -> None: + value: int = returns_any() # error: [unsound-assignment] + + class Nested: + value: int = returns_any() # error: [unsound-assignment] +``` + +An ordinary class does not receive the dataclass-body exemption. + +```py +class Ordinary: + value: int = returns_any() # error: [unsound-assignment] +``` + +## Assignments in dataclass-transform class bodies + +The body of a class inheriting from a `dataclass_transform` base is ignored even when its field +specifier is not registered with the transform. + +```py +from typing import Any, TypeVar +from typing_extensions import dataclass_transform + +def custom_field() -> Any: + return 42 + +@dataclass_transform() +class Model: ... + +class CustomExample(Model): + value: int = custom_field() + + def method(self) -> None: + value: int = custom_field() # error: [unsound-assignment] + + class Nested: + value: int = custom_field() # error: [unsound-assignment] +``` + +The same exemption applies when a class becomes dataclass-like through its decorator. + +```py +T = TypeVar("T") + +@dataclass_transform() +def transform(cls: type[T]) -> type[T]: + return cls + +@transform +class DecoratedModel: + value: int = custom_field() +``` + +A dataclass-transform metaclass also makes its class body exempt. + +```py +@dataclass_transform() +class ModelMetaclass(type): ... + +class MetaclassModel(metaclass=ModelMetaclass): + value: int = custom_field() +``` + +## Assignments in stub files + +In stub files, assigning to an ellipsis (`= ...`) is a syntactic special case that is allowed +regardless of the declared type. We do not emit `unsound-assignment` for this: + +```pyi +def f(x: int = ...): ... # no error + +x: int = ... # no error +y: str +y = ... # no error +``` diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 19b3cad560..e708b0ebc7 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -195,6 +195,19 @@ reveal_type(C.inferred_from_value) # revealed: Unknown C.inferred_from_value = "overwritten on class" ``` +#### Bound methods assigned on narrowed receivers + +A bound method keeps the receiver that was used to access it. If `self` is narrowed before the bound +method is assigned to an inferred instance attribute, the captured receiver remains assignable to +the receiver used by the inferred attribute type. + +```py +class C: + def method(self) -> None: + if not isinstance(self, str): + self.saved_method = self.method +``` + #### Variable defined in multiple methods If we see multiple un-annotated assignments to a single attribute (`self.x` below), we build the @@ -259,8 +272,8 @@ reveal_type(c_instance.b) # revealed: int #### Augmented assignments -An augmented assignment contributes its result to the inferred type of an unannotated instance -attribute. +An augmented assignment contributes its result to an instance attribute that already has an +independent binding. ```py class Weird: @@ -276,6 +289,381 @@ class C: reveal_type(C().w) # revealed: Weird | str ``` +#### Augmented assignments with stable recursive inference + +An independently initialized buffer updated from multiple methods must retain its concrete type, +even when augmented assignments recursively look up that attribute. + +```toml +[rules] +unsound-return-statement = "error" +``` + +```py +class Buffer: + def __init__(self) -> None: + self.reset() + + def append(self, value: bytes) -> None: + if value: + self.content += b"," + self.content += value + + def reset(self) -> None: + self.content = bytearray() + + def finish(self) -> bytearray: + self.content += b"]" + return self.content + +reveal_type(Buffer().content) # revealed: bytearray +``` + +The same cycle recovery also preserves a concrete integer attribute. + +```py +class Counter: + def __init__(self) -> None: + self.reset() + + def increment(self, value: int) -> None: + self.value += value + + def reset(self) -> None: + self.value = 0 + + def finish(self) -> int: + self.value += 1 + return self.value + +reveal_type(Counter().value) # revealed: int +``` + +#### Augmented assignments to narrowed optional attributes + +Once an optional attribute has been narrowed to its non-`None` value, augmented assignments must not +introduce `Unknown` into its instance attribute type. + +```toml +[rules] +unsound-return-statement = "error" +``` + +```py +class Counter: + def __init__(self, value: int | None) -> None: + self.value = value + + def update(self, decrement: bool) -> None: + if self.value is None: + return + + if decrement: + self.value -= 1 + else: + self.value += 1 + + def current(self) -> int | None: + return self.value + +reveal_type(Counter(0).value) # revealed: int | None +``` + +#### Augmented assignments to unannotated class-level defaults + +An unannotated class-level default can supply the initial value read by an augmented assignment. The +instance attribute can then contain either the original value or the result of the operation. + +```py +class After: + def __iadd__(self, other: int) -> "After": + return self + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class C: + value = Before() + + def update(self) -> None: + self.value += 1 # error: [invalid-assignment] + +reveal_type(C().value) # revealed: Before | After +``` + +#### Augmented assignments to conditionally defined class-level defaults + +A conditional class default must not hide the dynamic fallback used when that default is absent. + +```py +class After: + def __iadd__(self, other: int) -> "After": + return self + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class FallbackAfter: + def __iadd__(self, other: int) -> "FallbackAfter": + return self + +class Fallback: + def __iadd__(self, other: int) -> FallbackAfter: + return FallbackAfter() + +def flag() -> bool: + return True + +class C: + if flag(): + value = Before() + + def __getattr__(self, name: str) -> Fallback: + return Fallback() + + def update(self) -> None: + # error: [invalid-assignment] + # error: [possibly-missing-attribute] + self.value += 1 + +reveal_type(C().value) # revealed: Before | After | FallbackAfter | Fallback +``` + +#### Augmented assignments with expanding generic results + +An augmented assignment can repeatedly expand a generic attribute's type arguments. Inference must +still converge when the initial value comes from a class-level default. + +```py +from __future__ import annotations + +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Grow(Generic[T]): + def __iadd__(self, other: int) -> Grow[list[T]]: + raise NotImplementedError + +class Counter: + value = Grow[int]() + + def update(self) -> None: + self.value += 1 # error: [invalid-assignment] + +reveal_type(Counter().value) # revealed: Grow[int] | Grow[list[int]] +``` + +An independently initialized attribute must use the same bounded cycle recovery. + +```py +class InitializedCounter: + def __init__(self) -> None: + self.value = Grow[int]() + + def update(self) -> None: + self.value += 1 # error: [invalid-assignment] + +reveal_type(InitializedCounter().value) # revealed: Grow[int] | Grow[list[int]] +``` + +#### Augmented assignments with expanding tuple results + +Repeatedly nesting an independently initialized tuple must converge instead of exhausting Salsa's +cycle-iteration limit. + +```py +class C: + def __init__(self) -> None: + self.value = (1,) + + def update(self) -> None: + # TODO: this follows from the cycle settling one iteration short, above + # error: [invalid-assignment] + self.value += (self.value,) + +# TODO: the second arm should be the homogeneous `tuple[Divergent, ...]` the cycle settles on, not +# one iteration of it. the growth *is* detected — the rounds go from length 1 to length 2 — and the +# collapse runs, but the union keeps the uncollapsed arm. same family as the lambda default depth +# in `cycle/basic.md`, which upstream's cycle recovery also moved +reveal_type(C().value) # revealed: tuple[int] | tuple[int, Divergent] +``` + +#### Augmented assignments to inherited instance attributes + +An instance attribute established by a superclass can supply the initial value read by an augmented +assignment in a subclass. + +```py +class After: + def __iadd__(self, other: int) -> "After": + return self + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class Base: + def __init__(self) -> None: + self.value = Before() + +class Child(Base): + def update(self) -> None: + self.value += 1 + +reveal_type(Child().value) # revealed: Before | After +``` + +#### Augmented assignments preserve inherited instance bindings beneath class defaults + +A superclass initializer writes instance storage even when a subclass defines a class-level default +with the same name. Both initial values and their augmented-assignment results remain possible. + +```py +class AfterA: + def __iadd__(self, other: int) -> "AfterA": + return self + +class AfterB: + def __iadd__(self, other: int) -> "AfterB": + return self + +class BeforeA: + def __iadd__(self, other: int) -> AfterA: + return AfterA() + +class BeforeB: + def __iadd__(self, other: int) -> AfterB: + return AfterB() + +class Base: + def __init__(self) -> None: + self.value = BeforeA() + +class Child(Base): + value = BeforeB() + + def update(self) -> None: + self.value += 1 # error: [invalid-assignment] + +reveal_type(Child().value) # revealed: BeforeB | AfterB | AfterA | BeforeA +``` + +#### Augmented assignments preserve subclass attribute bindings + +An augmented assignment inherited from an intermediate class must not discard instance attributes +that subclasses establish independently. + +```py +from typing import Any + +class Base: + value = 0 + +class Middle(Base): + def increment(self) -> None: + self.value += 1 + +class Child(Middle): + def set(self, value: Any) -> None: + self.value = value + +reveal_type(Child().value) # revealed: int | Any +``` + +An untyped subclass binding is likewise preserved. + +```py +class UnknownChild(Middle): + def set(self, value) -> None: + self.value = value + +reveal_type(UnknownChild().value) # revealed: int | value@set +``` + +An explicitly annotated class-level default also preserves subclass bindings. + +```py +class AnnotatedBase: + value: int = 0 + +class AnnotatedMiddle(AnnotatedBase): + def increment(self) -> None: + self.value += 1 + +class AnnotatedChild(AnnotatedMiddle): + def set(self, value: Any) -> None: + self.value = value + +reveal_type(AnnotatedChild().value) # revealed: int | Any +``` + +#### Augmented assignments with gradual operands + +An augmented assignment with an `Any` or untyped operand contributes its gradual result to the +inferred instance attribute. + +```py +from typing import Any + +class C: + def __init__(self, any_value: Any, unknown_value) -> None: + self.from_any = 0.0 + self.from_any += any_value + + self.from_unknown = 0 + self.from_unknown += unknown_value + +reveal_type(C(0, 0).from_any) # revealed: int | float | Any +reveal_type(C(0, 0).from_unknown) # revealed: int | Unknown +``` + +#### Augmented assignments to possible data descriptors + +An augmented assignment to a data descriptor passes its result to `__set__` rather than creating +instance storage. When a class default might be a descriptor, preserve the existing attribute types +without exposing the descriptor's write-only result. + +```py +class After: + def __iadd__(self, other: int) -> "After": + return self + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class DescriptorAfter: + def __iadd__(self, other: int) -> "DescriptorAfter": + return self + +class DescriptorValue: + def __iadd__(self, other: int) -> DescriptorAfter: + return DescriptorAfter() + +class Descriptor: + def __get__(self, instance: object, owner: type[object]) -> DescriptorValue: + return DescriptorValue() + + def __set__(self, instance: object, value: DescriptorAfter) -> None: ... + +def flag() -> bool: + return True + +class C: + value = Descriptor() if flag() else Before() + + def update(self) -> None: + # error: [invalid-assignment] + # error: [invalid-assignment] + self.value += 1 + +# TODO: Include `After` from the non-descriptor branch without including `DescriptorAfter`. +reveal_type(C().value) # revealed: DescriptorValue | Before +``` + #### Nested augmented assignments after narrowing Augmented assignments to nested attributes (e.g., `self.inner.value += ...`) should work correctly @@ -341,7 +729,7 @@ class C: c_instance = C() reveal_type(c_instance.a) # revealed: int -reveal_type(c_instance.b) # revealed: list[Literal[2, 3]] +reveal_type(c_instance.b) # revealed: list[int] ``` #### Attributes defined in for-loop (unpacking) @@ -602,7 +990,11 @@ reveal_type(D().x) # revealed: Unknown If `staticmethod` is something else, that should not influence the behavior: ```py -def staticmethod(f): +from typing import TypeVar + +T = TypeVar("T") + +def staticmethod(f: T) -> T: return f class C: @@ -834,6 +1226,53 @@ reveal_type(c_instance.pure_class_variable) # revealed: str c_instance.pure_class_variable = "value set on instance" ``` +#### Augmented assignments in class methods + +A classmethod can establish an implicit class variable and then augment it with an operation that +changes its type. Both the initial value and the augmented result remain possible. + +```py +class After: ... + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class Example: + @classmethod + def update(cls) -> None: + cls.value = Before() + cls.value += 1 + +reveal_type(Example.value) # revealed: Before | After +``` + +#### Augmented assignments to inherited class variables + +A classmethod can read an inherited class variable before storing its augmented result on the +subclass. Class-member lookup must preserve the deferred assignment until it finds that inherited +value. + +```py +class After: + def __iadd__(self, other: int) -> "After": + return self + +class Before: + def __iadd__(self, other: int) -> After: + return After() + +class Parent: + value = Before() + +class Child(Parent): + @classmethod + def update(cls) -> None: + cls.value += 1 + +reveal_type(Child.value) # revealed: Before | After +``` + ### Instance variables with class-level default values These are instance attributes, but the fact that we can see that they have a binding (not a @@ -871,6 +1310,29 @@ reveal_type(C.variable_with_class_default1) # revealed: Literal["overwritten on reveal_type(c_instance.variable_with_class_default1) # revealed: Literal["value set on instance"] ``` +#### Augmented assignments to overriding class-level defaults + +An unannotated class-level default supplies the initial value for an augmented assignment, even when +another branch of a diamond declares a wider instance attribute. + +```py +class Base: + value: int | None = None + +class First(Base): ... + +class Second(Base): + value: int | None + +class Child(First, Second): + value = 1 + + def update(self) -> None: + self.value |= 2 + +reveal_type(Child().value) # revealed: int +``` + #### Descriptor attributes as class variables Whether they are explicitly qualified as `ClassVar`, or just have a class level default, we treat @@ -1301,6 +1763,26 @@ class InitializedDerived(DeclaringBase, metaclass=DerivedInitializingMeta): ... reveal_type(InitializedDerived.inherited_attr) # revealed: int ``` +An attribute initialized by the metaclass also takes precedence over an inherited generic +declaration. Access through the generic subclass refers to the ordinary `int` attribute installed by +the metaclass, so reads, writes, and deletion are allowed. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class GenericDeclaringBase(Generic[T]): + inherited_attr: T | int + +class GenericInitializedDerived(GenericDeclaringBase[T], metaclass=DerivedInitializingMeta): ... + +reveal_type(GenericInitializedDerived.inherited_attr) # revealed: int +reveal_type(GenericInitializedDerived[str].inherited_attr) # revealed: int +GenericInitializedDerived[str].inherited_attr = 2 +del GenericInitializedDerived[str].inherited_attr +``` + An assignment through `cls` in an arbitrary metaclass method also writes to the constructed class object if that method is called. Class-object lookup currently treats such an inferred write as definitely present and drops an inherited value. @@ -1572,6 +2054,47 @@ class UsesGeneratedDescriptor(metaclass=DescriptorMeta): reveal_type(UsesGeneratedDescriptor().generated_descriptor) # revealed: Literal["descriptor"] ``` +An augmented assignment to a data descriptor on a metaclass calls the descriptor's `__set__` method. +It does not store an attribute on the class, so the attribute is unavailable on instances. + +```py +class AugmentedDescriptor: + def __get__(self, instance: object, owner: type[object]) -> int: + return 1 + + def __set__(self, instance: object, value: int) -> None: ... + +class AugmentedDescriptorMeta(type): + descriptor_value = AugmentedDescriptor() + + def update(cls) -> None: + cls.descriptor_value += 1 + +class UsesAugmentedDescriptor(metaclass=AugmentedDescriptorMeta): ... + +# error: [unresolved-attribute] +reveal_type(UsesAugmentedDescriptor().descriptor_value) # revealed: Unknown +``` + +A metaclass default that might be a data descriptor likewise must not expose a class attribute on +constructed instances. + +```py +def choose_descriptor() -> bool: + return True + +class MaybeAugmentedDescriptorMeta(type): + descriptor_value = AugmentedDescriptor() if choose_descriptor() else 1 + + def update(cls) -> None: + cls.descriptor_value += 1 # error: [invalid-assignment] + +class UsesMaybeAugmentedDescriptor(metaclass=MaybeAugmentedDescriptorMeta): ... + +# error: [unresolved-attribute] +reveal_type(UsesMaybeAugmentedDescriptor().descriptor_value) # revealed: Unknown +``` + When a metaclass declaration uses a union, only the data descriptors in that union take precedence over an instance attribute. A non-descriptor member and the instance attribute both remain possible: @@ -1598,6 +2121,22 @@ class UsesMaybeGeneratedDescriptorWithDynamicBase(DynamicGeneratedBase, metaclas reveal_type(UsesMaybeGeneratedDescriptorWithDynamicBase().generated_descriptor) # revealed: Literal["descriptor"] | Any ``` +A union alias must not hide a non-descriptor member: the same dynamic fallback remains possible +after expanding it: + +```py +from typing_extensions import TypeAliasType + +GeneratedDescriptorOrInt = TypeAliasType("GeneratedDescriptorOrInt", GeneratedDescriptor | int) + +class AliasedDescriptorMeta(MaybeDescriptorMeta): + generated_descriptor: GeneratedDescriptorOrInt | GeneratedDescriptor + +class UsesAliasedDescriptorWithDynamicBase(DynamicGeneratedBase, metaclass=AliasedDescriptorMeta): ... + +reveal_type(UsesAliasedDescriptorWithDynamicBase().generated_descriptor) # revealed: Literal["descriptor"] | Any +``` + Dynamic bases are ignored when descriptor detection requires a concrete `__get__` method: ```py @@ -3629,6 +4168,26 @@ class Foo: ... reveal_type(Foo.__class__) # revealed: ``` +## `__class__` on recursive aliases + +For a recursive alias that contains both instances and classes, `value.__class__` agrees with +`type(value)`. Repeated queries retain both the instance classes and their possible metaclasses. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Meta[T] = type[T] +type Recursive = int | Meta[Recursive] + +def recursive_class(value: Recursive): + reveal_type(type(value)) # revealed: type[int | type] + reveal_type(value.__class__) # revealed: type[int | type] + reveal_type(type(value)) # revealed: type[int | type] +``` + ## Module attributes ### Basic @@ -4287,6 +4846,7 @@ declarations. from unknown_library import unknown_decorator class C: + # error: [dynamic-function-decorator-return] @unknown_decorator def f(self): self.x: int = 1 @@ -4299,6 +4859,7 @@ class D: def __init__(self): self.x: int = 1 + # error: [dynamic-function-decorator-return] @unknown_decorator def f(self): self.x = 2 @@ -4419,6 +4980,19 @@ class F: reveal_type(F().x) # revealed: tuple[Divergent, ...] ``` +A homogeneous tuple of `Divergent` has gradual length, so it is assignable to a fixed-length tuple. +This allows a recursively inferred instance attribute to retain an empty tuple as its class default: + +```py +class G: + x = () + + def f(self): + self.x = tuple(self.x) + +reveal_type(G().x) # revealed: tuple[Divergent, ...] +``` + ## Attributes of standard library modules that aren't yet defined For attributes of stdlib modules that exist in future versions, we can give better diagnostics. diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_block_scoping.md b/crates/ty_python_semantic/resources/mdtest/basedpython_block_scoping.md index fea99131d9..625ee2c832 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_block_scoping.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_block_scoping.md @@ -232,9 +232,12 @@ Each clause of a `try` statement is its own block. An `except` clause is entered the `try` block, so it takes the names that block declared out of scope on that edge too. ```by +def risky() -> int: + raise ValueError + def f(): try: - let attempted = 1 + let attempted = risky() except ValueError: print(attempted) # error: [unresolved-reference] else: @@ -243,6 +246,9 @@ def f(): print(succeeded) # error: [unresolved-reference] ``` +The `try` body has to be able to raise for any of this to matter: a handler for a body that cannot +is never entered, and nothing in it is analysed at all. + ## an exception raised inside a nested block still leaves it ```by diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md b/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md index f1bb17ab88..1bae73cea4 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md @@ -201,6 +201,21 @@ def f(c: C): reveal_type(c) # revealed: C[str] ``` +## a range accepts a default that names the type variable + +A later type parameter's default can be written in terms of an earlier one, as `B = Box[T]` is here. +At the point that default is checked, `T` has no binding context yet, so the check binds a copy of +the default before measuring it against the range. Without that, the bare type variable rather than +the type it stands for reaches the lower end, and the specialization is rejected. + +```by +class Box[T: str..object]: ... + +class Holder[T: str..object, B = Box[T]]: ... + +reveal_type(Holder[str]()) # revealed: final Holder[str, Box[str]] +``` + ## `..` outside a bound says so Anywhere but a type parameter's bound, `Lower..Upper` is not a range. Left alone it parses as two diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md b/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md index 4e724f46f8..058136b949 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md @@ -106,13 +106,20 @@ def main(): ## else and finally are not protected by the handlers +The `try` body has to be able to raise, or its handler is never entered and nothing after it is +analysed either. + ```by +def risky() -> int: + raise ValueError + def f(): raise TypeError def main(): try: - pass + # error: [unhandled-exception] "`ValueError` can escape `main`, the entry point" + risky() except TypeError: pass else: diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md b/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md index 7f469bb3c8..da3df54f0d 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md @@ -163,7 +163,7 @@ caller actually wrote. class A: init(a: int) -# error: [invalid-argument-type] "Argument to class `A` is incorrect: Expected `int`, found `"s"`" +# error: [invalid-argument-type] "Argument to `A.__init__` is incorrect: Expected `int`, found `"s"`" A("s") # error: [missing-argument] "No argument provided for required parameter `a` of class `A`" diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_reified_generics.md b/crates/ty_python_semantic/resources/mdtest/basedpython_reified_generics.md index 040414356b..6d31b39c82 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_reified_generics.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_reified_generics.md @@ -515,20 +515,46 @@ f() f[bool]() ``` -## an unfilled variadic is the empty run +## a variadic is solved from the arguments A variadic never forces the specialization step the way a plain reified parameter does: supplying it -nothing is a complete answer, not a missing one, so a bare call stays legal and binds the empty run. -Inference does not solve a variadic from the arguments, so a non-empty run has to be written out: +nothing is a complete answer, not a missing one, so a bare call stays legal. The run it binds is +solved from the arguments, the same way a lone type parameter and a keyword pack are, so writing the +step out and leaving it off reach the same answer: ```by def f[*Ts](*args: *Ts) -> None: - assert Ts == () or Ts == (int, str) + assert Ts == (int, str) -f(1, "a") # Ts is (), not (int, str) +f(1, "a") f[int, str](1, "a") ``` +Each element of the run is the argument's runtime type, so a literal widens to its class under the +file's numeric model. `2.0` binds `float` — not the `int | float` that a float argument is merely +*accepted* as: + +```by +def g[*Ts](*args: *Ts) -> None: + assert Ts == (int, float) + +g(1, 2.0) +``` + +Inference can only fill the step with types that have a runtime spelling at the call site. A class +local to a function does not, so rather than bind a run naming something the call cannot see, the +call is rejected and the step has to be written out: + +```by +def h[*Ts](*args: *Ts) -> None: + print(Ts) + +def make() -> None: + class Local: ... + # error: [unspecialized-reified-generic] + h(Local()) +``` + ## a keyword-variadic pack reifies to its fields A `**Kwargs` pack is an ordered mapping of field name to type, so its runtime value is that mapping. diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_safe_variance.md b/crates/ty_python_semantic/resources/mdtest/basedpython_safe_variance.md index 974ea8ccf1..75c623b0bb 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_safe_variance.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_safe_variance.md @@ -75,10 +75,9 @@ Storage is invariant in its own type, so a write has to be valid for every type could really have. Nothing is, which is why the write type is `Never`. Privacy is what exempts `t` from constraining variance; the public `g` below still consumes a `T`, -so the class is separately reported for not honouring its own `out`. +so `g` is separately reported for not honouring the class's own `out`. ```by -# error: [invalid-generic-class] "Variance of type variable `T` is incompatible with its usage in `A`" class A[out T]: private t: T @@ -86,7 +85,7 @@ class A[out T]: # error: [invalid-assignment] "Object of type `1` is not assignable to attribute `t` of type `Never`" other.t = 1 - def g(self, other: A[object], t: T): + def g(self, other: A[object], t: T): # error: [invalid-generic-class] "Variance of type variable `T` is incompatible with method `g`" # a real `T` gets no further: it is `self`'s parameter, and says nothing about the one # `other` is hiding # error: [invalid-assignment] "Object of type `T@A` is not assignable to attribute `t` of type `Never`" @@ -98,11 +97,10 @@ class A[out T]: `a` below is an `A[object]` and its storage has nothing to do with `self`'s `T`. ```by -# error: [invalid-generic-class] "Variance of type variable `T` is incompatible with its usage in `A`" class A[out T]: private t: T - def f(self, t: T): + def f(self, t: T): # error: [invalid-generic-class] "Variance of type variable `T` is incompatible with method `f`" a = A[object]() # error: [invalid-assignment] "Object of type `1` is not assignable to attribute `t` of type `Never`" a.t = 1 @@ -117,17 +115,16 @@ parameters are that receiver's — so the member keeps its declared type, un-era can be written to it. This holds through a capture in a nested function too. ```by -# error: [invalid-generic-class] "Variance of type variable `T` is incompatible with its usage in `A`" class A[out T]: private t: T - def f(self: A[object], t: T): + def f(self: A[object], t: T): # error: [invalid-generic-class] "Variance of type variable `T` is incompatible with method `f`" reveal_type(self.t) # revealed: T@A self.t = t # error: [invalid-assignment] "Object of type `"asdf"` is not assignable to attribute `t` of type `T@A`" self.t = "asdf" - def g(self: A[object], t: T): + def g(self: A[object], t: T): # error: [invalid-generic-class] "Variance of type variable `T` is incompatible with method `g`" def inner(): self.t = t ``` @@ -258,6 +255,49 @@ def f(a: A[int]): reveal_type(a._anything) # revealed: int ``` +## a generated member is not private + +A member a code generator supplies belongs to the surface that construct gives every one of its +classes, so it is public however its name is spelled. A named tuple is the case that matters: Python +underscores `_asdict`, `_replace`, `_make` and `_fields` to keep the field namespace clear, not to +hide them. They specialize like any other member, through the class that declares the fields and +through a subclass of it. + +```py +from typing import NamedTuple + +class Box[T](NamedTuple): + value: T + +class Child[T](Box[T]): + pass + +def f(box: Box[int], child: Child[int]): + reveal_type(box._asdict()) # revealed: dict[str, Any] + reveal_type(child._asdict()) # revealed: dict[str, Any] + reveal_type(box._replace) # revealed: (*, value: int = ...) -> Box[int] +``` + +## a declared member of the same name is still private + +The exemption turns on where the member comes from, not on what it is called. A subclass that +declares a private member sharing a generated name is keeping a member of its own, so it erases like +any other private attribute — a declaration in the class body is what the lookup resolves to, and +the generator never supplies it. + +```py +from typing import NamedTuple + +class Box[T](NamedTuple): + value: T + +class Shadow[T](Box[T]): + _replace: T + +def f(shadow: Shadow[int]): + reveal_type(shadow._replace) # revealed: object +``` + ## a non-generic class is unaffected ```py diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_template_literal_types.md b/crates/ty_python_semantic/resources/mdtest/basedpython_template_literal_types.md index abae1863fb..8cd5a802a1 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_template_literal_types.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_template_literal_types.md @@ -95,10 +95,10 @@ def f(a: f"the {Outer}", b: f"[{Tagged}]") -> None: ## an alias that names itself is followed only once ```by -type Loop = Loop | "q" +type Loop = Loop | "q" # error: [cyclic-type-alias-definition] "Cyclic definition of `Loop`" -type Left = Right | int -type Right = Left | str +type Left = Right | int # error: [cyclic-type-alias-definition] "Cyclic definition of `Left`" +type Right = Left | str # error: [cyclic-type-alias-definition] "Cyclic definition of `Right`" def f(a: f"a{Loop}b", b: f"a{Right}b") -> None: reveal_type(a) # revealed: "aqb" diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 25755ac980..1924655516 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -157,6 +157,89 @@ class Record(TypedDict): value: int ``` +### Deferred forward references in overloaded calls + +Selecting an overload must not recursively infer a loop-carried forward reference before its +declared `TypedDict` is available. + +```py +from __future__ import annotations +from typing import Never, TypedDict, overload + +@overload +def inspect(value: str) -> Never: ... +@overload +def inspect(value: object) -> int: ... +def inspect(value: object) -> int: + return 1 + +for _ in range(2): + record: Record + record = {"value": 1} + inspect(record) + reveal_type(record) # revealed: Record + record["missing"] # error: [invalid-key] + +class Record(TypedDict): + value: int +``` + +### Deferred forward references in nested non-function loops + +A loop-carried forward reference keeps its declared type when the module-level loop is nested inside +a conditional. + +```py +from __future__ import annotations +from typing import Never, TypedDict, overload + +@overload +def inspect(value: str) -> Never: ... +@overload +def inspect(value: object) -> int: ... +def inspect(value: object) -> int: + return 1 + +if bool(input()) and bool(input()): + for _ in range(2): + record: ModuleRecord + record = {"value": 1} + inspect(record) + reveal_type(record) # revealed: ModuleRecord + +class ModuleRecord(TypedDict): + value: int +``` + +A statically known outer branch also preserves the loop-carried declaration. + +```py +if 1 + 1 == 2: + for _ in range(2): + record: StaticModuleRecord + record = {"value": 1} + inspect(record) + reveal_type(record) # revealed: StaticModuleRecord + +class StaticModuleRecord(TypedDict): + value: int +``` + +TODO: In class-body `while` loops, eager collection cycle recovery loses the declared `TypedDict` +context. + +```py +class Container: + while bool(input()): + record: ClassRecord # error: [invalid-declaration] + record = {"value": 1} + inspect(record) + reveal_type(record) # revealed: dict[str, int] + +class ClassRecord(TypedDict): + value: int +``` + ### Deferred forward references on Python 3.14 Annotations are deferred by default in Python 3.14 and later. @@ -530,6 +613,14 @@ x3: tuple[list[Literal[1]], ...] = 3 * ((singleton(1),) + (singleton(1),)) reveal_type(x3) ``` +Type context also reaches mutable elements inside a starred list literal. Preserving their positions +in the resulting tuple does not discard the element annotations. + +```py +x4: tuple[list[Literal[1]], ...] = (*[[1], []],) +reveal_type(x4) # revealed: tuple[list[Literal[1]], list[Literal[1]]] +``` + ## Generator expressions ```py @@ -1207,6 +1298,25 @@ def mean(data: DataFrame) -> float: x23: Mapping[Hashable, AggregateSpec] = {"col1": ["sum", mean], "col2": mean} ``` +## Recursive aliases remain stable in invariant collection contexts + +An invariant collection context can infer the same recursive type as both bounds. Because recursive +inference introduces `Divergent`, intersecting those bounds should not discard any element of the +union. + +```py +from collections.abc import MutableMapping, MutableSequence +from typing import TypeAlias, TypedDict + +class Leaf(TypedDict, total=False): + path: str + +RecursiveValue: TypeAlias = int | Leaf | MutableSequence["RecursiveValue | None"] | MutableMapping[str, "RecursiveValue | None"] +RecursiveMapping: TypeAlias = MutableMapping[str, RecursiveValue | None] + +recursive: RecursiveMapping = {} +``` + ## Implicit generic class specialization Callable type context is also used to inform the implicit specialization of a generic class: @@ -1427,6 +1537,58 @@ x8: EitherList = list(("1", "2", "3")) reveal_type(x8) # revealed: list[int | str] ``` +## Literal union context for generic calls + +Narrowing a small literal union preserves the precise result of a nested generic call, including +when the return type also contains `Any`: + +```py +from typing import Any, Literal, assert_type + +def singleton[T](value: T) -> list[T]: + return [value] + +def first[T](values: list[T]) -> T: + return values[0] + +def first_gradual[T](values: list[T]) -> Any | T: + return values[0] + +precise: Literal["a", "b"] = first(singleton("a")) +assert_type(precise, Literal["a"]) + +gradual: Literal["a", "b"] = reveal_type(first_gradual(singleton("a"))) # revealed: Any | Literal["a"] +``` + +A result can contain several literal alternatives without including every member of the declared +union. The surrounding optional type does not add `None` to this result: + +```py +def literal_union(value: Literal["a", "b"]) -> None: + result: Literal["a", "b"] | None = first(singleton(value)) + assert_type(result, Literal["a", "b"]) +``` + +A generic collection alternative still provides context when it follows literal alternatives. The +empty list is inferred as `list[str]`: + +```py +type Values = Literal[0, 1, 2] + +def identity[T](value: T) -> T: + return value + +collection: Values | list[str] | None = identity([]) +assert_type(collection, list[str]) +``` + +The result also stays precise when the matching literal is the last member of the declared union: + +```py +late: Values = first(singleton(2)) +assert_type(late, Literal[2]) +``` + ## Assignability diagnostics ignore declared type The type displayed in an invalid assignment diagnostic should account for the type context, e.g., to @@ -1516,6 +1678,16 @@ def _(xy: X | Y): xy.x = reveal_type([1]) # revealed: list[int] ``` +Unannotated lambda parameters do not prevent the inferred attribute type from providing context to +the dictionary literals: + +```py +class Callbacks: + def __init__(self): + self.values = [{"x": 0}, {"x": lambda x: 0}] + self.identities = [{"x": 0}, {"x": lambda x: x}] +``` + ## Overload evaluation The type context of all matching overloads are considered during argument inference: @@ -1649,7 +1821,7 @@ class A: A(f(1)) -# error: [invalid-argument-type] "Argument to class `A` is incorrect: Expected `list[int | str]`, found `list[list[Unknown]]`" +# error: [invalid-argument-type] "Argument to constructor `A.__new__` is incorrect: Expected `list[int | str]`, found `list[list[Unknown]]`" A(f([])) ``` @@ -1689,7 +1861,7 @@ def from_or(values: list[str] | None) -> None: reveal_type(value) # revealed: str def constructor_fallback(values: list[int] | None) -> None: - reveal_type(values or list()) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown] + reveal_type(values or list()) # revealed: list[int] def from_and(values: list[str]) -> None: reveal_type(values and []) # revealed: list[str] @@ -1830,7 +2002,7 @@ reveal_type(f8) # revealed: (int, /) -> None # An optional keyword-only parameter does not prevent `*args` from accepting the positional # suffix in a `Callable` annotation. f9: Callable[[*tuple[int, ...], int], None] = lambda *args, x=1: None -reveal_type(f9) # revealed: (*args, *, x: int = 1) -> None +reveal_type(f9) # revealed: (*args, x: int = 1) -> None f10: Callable[[str, int, str], tuple[str, int, str]] = lambda x, y, z: reveal_type((x, y, z)) # revealed: tuple[str, int, str] reveal_type(f10) # revealed: (x: str, y: int, z: str) -> tuple[str, int, str] @@ -2007,6 +2179,52 @@ class C: reveal_type(i) # revealed: int ``` +## Lambda contextual inference through type aliases + +A lambda parameter is inferred from a callable behind a type alias, including aliases that resolve +to unions and aliases used as elements of another union: + +```py +from typing import Callable +from typing_extensions import TypeAliasType + +type IntCallback = Callable[[int], None] +type IntCallbackOrInt = Callable[[int], None] | int +IntCallbackOrIntAliasType = TypeAliasType("IntCallbackOrIntAliasType", Callable[[int], None] | int) +IntCallbackAliasType = TypeAliasType("IntCallbackAliasType", Callable[[int], None]) + +def consume(value: int) -> None: + pass + +x1: Callable[[int], None] | str = lambda value: consume(reveal_type(value)) # revealed: int +x2: IntCallbackOrInt | str = lambda value: consume(reveal_type(value)) # revealed: int +x3: IntCallbackOrIntAliasType | str = lambda value: consume(reveal_type(value)) # revealed: int +x4: IntCallback = lambda value: consume(reveal_type(value)) # revealed: int +x5: IntCallbackAliasType = lambda value: consume(reveal_type(value)) # revealed: int +``` + +## Lambda contextual inference through `TypeAliasType` on Python 3.11 + +```toml +[environment] +python-version = "3.11" +``` + +On Python 3.11, `typing_extensions.TypeAliasType` provides the same alias semantics without the +`type` statement: + +```py +from typing import Callable +from typing_extensions import TypeAliasType + +IntCallbackOrInt = TypeAliasType("IntCallbackOrInt", Callable[[int], None] | int) + +def consume(value: int) -> None: + pass + +y1: IntCallbackOrInt | str = lambda value: consume(reveal_type(value)) # revealed: int +``` + ## Unified call inference Generic call arguments are inferred under fixpoint iteration, allowing constraints from call @@ -2232,6 +2450,23 @@ def _(callback: TakesInt) -> None: reveal_type(x2) # revealed: str ``` +A structural type context can infer a gradual lower bound and a static upper bound before dictionary +values contribute their constraints. The preliminary solution should retain the gradual lower bound. + +```py +T_co = TypeVar("T_co", covariant=True) + +class DictLike(Protocol[T_co]): + def __getitem__(self, key: str, /) -> T_co: ... + def __setitem__(self, key: str, value: Any, /) -> None: ... + +class Command: ... + +def _(command: Any): + # revealed: dict[str, Any] + mapping: DictLike[type[Command]] = reveal_type({"command": command}) +``` + Note that long chains of callables with constraint dependencies in reverse source-order may require multiple fixpoint iterations. @@ -2318,6 +2553,137 @@ diagnostic_pair(non_generic(missing_argument), [1]) diagnostic_pair(non_generic(suppressed_argument), [1]) # ty: ignore[unresolved-reference] ``` +## Dynamic type context + +A lambda parameter can use type context containing `Any`: + +```py +from typing import Any, Callable + +def callable_pair[T](pair: tuple[Callable[[T], int], list[T]]) -> None: + function, values = pair + function(values[0]) + +def _(values: list[list[Any]]): + callable_pair((lambda value: len(reveal_type(value)), values)) # revealed: list[Any] +``` + +List literals widen based on dynamic type context contributed from a sibling argument: + +```py +def append[T](items: list[T], value: T) -> None: + items.append(value) + +def example(value: str | Any) -> None: + append([1], value) +``` + +This also applies when a generic base class contributes gradual type context: + +```py +class GenericBase[T]: ... +class Specialized(GenericBase[str]): ... +class Mixed(Specialized, GenericBase[Any]): ... + +def g[T](values: list[T], base: GenericBase[T]) -> list[T]: + return values + +reveal_type(g([1], Specialized())) # revealed: list[int | str] +reveal_type(g([1], Mixed())) # revealed: list[int | str | Any] +``` + +Dynamic arguments also participate when inferring the specialization for a nested collection +literal: + +```py +from ty_extensions._internal import Unknown + +def merge[K, V](*maps: dict[K, V]) -> tuple[K, V]: + raise NotImplementedError + +def _(dynamic: Unknown): + # TODO: The key and value types should also include `Unknown`. + reveal_type(merge({"a": 1}, {2: "b"}, dynamic)) # revealed: tuple[str | int, int | str] + reveal_type(merge(dynamic, {"a": 1}, {2: "b"})) # revealed: tuple[str | int, int | str] + +def _(dynamic: Any): + # TODO: The key and value types should also include `Any`. + reveal_type(merge({"a": 1}, {2: "b"}, dynamic)) # revealed: tuple[str | int, int | str] + reveal_type(merge(dynamic, {"a": 1}, {2: "b"})) # revealed: tuple[str | int, int | str] +``` + +## Lambda parameter cycles + +The return context can determine the types of mutually dependent identity callbacks. Unresolved +parameter types do not contribute `Unknown` to the inferred return type: + +```toml +[environment] +python-version = "3.13" + +[rules] +unsound-return-statement = "error" +``` + +```py +from collections.abc import Callable + +def f[T, U, V](extract: Callable[[U], V], store: Callable[[V], U], key: Callable[[T], str]) -> list[V]: + raise NotImplementedError + +def _[T](key: Callable[[T], str]) -> list[T]: + return f(key=key, extract=lambda x: x, store=lambda x: x) +``` + +Gradual types from arguments still propagate through an identity lambda: + +```py +from typing import Any +from ty_extensions._internal import Unknown + +def apply[T, R](function: Callable[[T], R], value: T) -> R: + return function(value) + +def _(value: Any): + reveal_type(apply(lambda x: x, value)) # revealed: Any + +def _(value: Unknown): + reveal_type(apply(lambda x: x, value)) # revealed: Unknown +``` + +## Type-variable defaults as type context + +An explicit type-variable default can provide fallback context when arguments do not determine a +type: + +```py +from collections.abc import Callable + +class Base[T]: ... +class Child[T](Base[T]): ... + +class Container[T = Base[str]]: + def __init__(self, factory: Callable[[], T] | None = None) -> None: ... + +reveal_type(Container()) # revealed: Container[Base[str]] + +x1 = Container(Child) +reveal_type(x1) # revealed: Container[Child[str]] +reveal_type(Container(Child)) # revealed: Container[Child[str]] +``` + +An inferred argument type takes precedence over the default: + +```py +reveal_type(Container(Child[int])) # revealed: Container[Child[int]] + +def create[T = Base[str]](factory: Callable[[], T], value: T) -> T: + return factory() + +def _(value: Child[int]): + reveal_type(create(Child, value)) # revealed: Child[int] +``` + ## Dunder Calls The key and value parameters types are used as type context for `__setitem__` dunder calls: diff --git a/crates/ty_python_semantic/resources/mdtest/binary/unions.md b/crates/ty_python_semantic/resources/mdtest/binary/unions.md index 97ff9d387c..5958d3c969 100644 --- a/crates/ty_python_semantic/resources/mdtest/binary/unions.md +++ b/crates/ty_python_semantic/resources/mdtest/binary/unions.md @@ -57,3 +57,13 @@ def f5(m: int, n: Literal[-1, 0, 1]): # error: [division-by-zero] "Cannot divide object of type `int` by zero" return m / n ``` + +Binary-operator diagnostics share state across union alternatives, so several combinations that +divide by zero produce only one warning per expression. Each expression has its own state, so +repeating the operation still produces a warning: + +```py +def f6(m: Literal[1, 2], n: Literal[0, 1]): + m / n # error: [division-by-zero] + m / n # error: [division-by-zero] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md b/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md index 45b120627c..6286b718a4 100644 --- a/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md +++ b/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md @@ -137,7 +137,7 @@ def _(flag: bool): def _(flag: bool, possibly_falsy_int: int, possibly_falsy_str: str): (flag and (x := possibly_falsy_int)) or (x := possibly_falsy_str) - reveal_type(x) # revealed: int | str + reveal_type(x) # revealed: (int & ~AlwaysFalsy) | str def _(flag: bool): (flag or (x := 0)) and (x := 2) @@ -192,3 +192,321 @@ def match_guard(flag: bool, subject: object): def comprehension_filter(flag: bool): [reveal_type(x) for _ in range(1) if flag and (x := 1)] # revealed: Literal[1] ``` + +## Reachability of compound conditions + +An `and` condition with an always-falsy operand cannot ever take the truthy branch. Similarly, an +`or` condition with an always-truthy operand cannot ever take the falsy branch. + +This perhaps seems obvious, but it's not! Given the expression `value and False`, `value` could be +some object whose `__bool__` can return `False` on one call and `True` on the next. The evaluation +of `value and False` tests `value`, gets `False`, and short-circuits, meaning the entire expression +`value and False` evaluates to `value`. Now if we re-check truthiness of `value`, we can't +necessarily assume we get `False` again. + +For code which saves the `and` expression to a variable, this is correct, and we do model this +possibility: + +```py +def saved_condition(value: object): + saved = value and False + + # We know that `saved` is not always truthy; we don't know that it's always falsy. + reveal_type(saved) # revealed: ~AlwaysTruthy + + if saved: + # So this branch is reachable: + reveal_type(value) # revealed: object +``` + +But if the condition is tested directly, it works differently (at least in CPython). A short-circuit +within a branch condition doesn't just short-circuit to an evaluation of the expression; it +short-circuits directly to a control-flow decision, bypassing a final evaluation of the entire +condition expression, and avoiding the need for a second `__bool__` check. We model this +distinction: + +```py +def conditions(value: object): + if value and False: + # This branch is not reachable; `value.__bool__` is only tested once. If it's false, this + # branch is skipped immediately, if it's true, `False` is always false and this branch is + # still skipped. + reveal_type(value) # revealed: Never + + if value or True: + pass + else: + reveal_type(value) # revealed: Never + + if not (value and False): + pass + else: + reveal_type(value) # revealed: Never + + if (value and False) or not (value or True): + reveal_type(value) # revealed: Never +``` + +Short-circuiting also skips later operands within a condition, including after nested boolean +operations. + +```py +def nested_operands(value: object): + if (value and False) and reveal_type(value): # revealed: Never + pass + + if (value or True) or reveal_type(value): # revealed: Never + pass + + if not (value or True) and reveal_type(value): # revealed: Never + pass +``` + +The same short-circuit rules apply to loop conditions, assertions, conditional expressions, +comprehension filters, and match guards. + +```py +def other_conditions(value: object): + while value and False: + reveal_type(value) # revealed: Never + + assert value or True, reveal_type(value) # revealed: Never + + reveal_type(value) if value and False else None # revealed: Never + + [reveal_type(item) for item in range(1) if value and False] # revealed: Never + + match value: + case _ if value and False: + reveal_type(value) # revealed: Never + + assert value and False + reveal_type(value) # revealed: Never +``` + +## Conditions with impossible operands + +Narrowing can make a later operand impossible to evaluate. A `bool` cannot also be a `str`, so +`value` has type `Never` when it is tested again in each condition below. Only the earlier +short-circuit path can complete: falsy for `and`, truthy for `or`. We reveal an unrelated `marker` +to check that the whole branch is unreachable, independently of narrowing `value` itself. + +```py +def impossible_operands(value: bool, marker: int): + if isinstance(value, str) and value: + reveal_type(marker) # revealed: Never + + if not isinstance(value, str) or value: + pass + else: + reveal_type(marker) # revealed: Never + + if isinstance(value, str) and not value: + reveal_type(marker) # revealed: Never +``` + +These outcomes are preserved inside larger conditions, even when another operand has mutable +truthiness. + +```py +def nested_impossible_operands(other: object, value: bool, marker: int): + if other and (isinstance(value, str) and value): + reveal_type(marker) # revealed: Never + + if other or (not isinstance(value, str) or value): + pass + else: + reveal_type(marker) # revealed: Never +``` + +## Conditions with aliased `Never` operands + +A call cannot produce a result when its return type is an alias of `Never`. Only the preceding +short-circuit path can complete. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Never + +type Bottom = Never + +def stop() -> Bottom: + raise RuntimeError + +def aliased_operand(flag: bool, marker: int): + if flag and stop(): + reveal_type(marker) # revealed: Never + + if flag or stop(): + pass + else: + reveal_type(marker) # revealed: Never +``` + +A union of aliases of `Never` still cannot produce a result. + +```py +type OtherBottom = Never +type BottomUnion = Bottom | OtherBottom + +def stop_union() -> BottomUnion: + raise RuntimeError + +def union_operand(flag: bool, marker: int): + if flag and stop_union(): + reveal_type(marker) # revealed: Never +``` + +## Conditional expressions used as conditions + +When a conditional expression (an `if/else` expression) is itself a condition, its selected branch +is evaluated as a condition too. The unselected branch does not affect whether the condition is +truthy. + +```py +def conditional_expressions(value: object, flag: bool): + if (value and False) if flag else False: + reveal_type(value) # revealed: Never + + if True if flag else (value or True): + pass + else: + reveal_type(value) # revealed: Never + + if True if value and False else False: + reveal_type(value) # revealed: Never +``` + +A branch narrowed to `Never` cannot contribute a result. The other branch alone determines the +conditional expression's truthiness. + +```py +def impossible_branches(value: bool, marker: int): + if value if isinstance(value, str) else False: + reveal_type(marker) # revealed: Never + + if True if not isinstance(value, str) else value: + pass + else: + reveal_type(marker) # revealed: Never +``` + +## Chained comparison conditions + +A comparison chain used as a condition is falsy if any comparison is always falsy, even if an +earlier comparison returns an arbitrary object. + +```py +class Comparable: + def __lt__(self, other: int) -> object: + return object() + +def comparisons(value: Comparable): + if value < 1 < 0: + reveal_type(value) # revealed: Never + + if value < 1 < 0 < 1: + reveal_type(value) # revealed: Never + + if (value < 1 < 0) and reveal_type(value): # revealed: Never + pass + + if not (value < 1 < 0): + pass + else: + reveal_type(value) # revealed: Never +``` + +Saving the result of a comparison chain can cause a non-boolean comparison result to be tested +twice. Its truthiness can change between those tests, so the truthy branch remains reachable. +References to `value` in these branches retain its type; in unreachable code they would have type +`Never`. + +```py +def saved_comparison(value: Comparable): + result = value < 1 < 0 + if result: + reveal_type(value) # revealed: Comparable + + if result := value < 1 < 0: + reveal_type(value) # revealed: Comparable +``` + +An unreachable assignment does not affect the inferred type of a loop variable. Inferring `value` +and deciding whether its assignment is reachable depend on each other, but `1 < 0` still makes the +branch unreachable. + +```py +def loop_condition(flag: bool): + value = 0 + while flag: + if value < 1 < 0: + value = Comparable() + reveal_type(value) # revealed: Literal[0] +``` + +## Re-testing boolean expression results + +Saving the result of `value and False` and then testing it can call `value.__bool__` twice. The +second call may return a different result, so the truthy branch remains reachable. Assignment +expressions and nested boolean operations in value contexts can also cause this extra test. + +```py +class MutableTruthiness: + truthy: bool = False + + def __bool__(self) -> bool: + self.truthy = not self.truthy + return self.truthy + +def expressions(value: MutableTruthiness, flag: bool): + saved = value and False + if saved: + reveal_type(value) # revealed: MutableTruthiness + + if saved := value and False: + reveal_type(value) # revealed: MutableTruthiness & ~AlwaysFalsy + + saved = (value and False) if flag else False + if saved: + reveal_type(value) # revealed: MutableTruthiness + + result = (value and False) and reveal_type(value) # revealed: MutableTruthiness & ~AlwaysFalsy + result = (not (value or True)) or reveal_type(value) # revealed: MutableTruthiness +``` + +Call arguments are evaluated as values, even when the call is itself used as a condition. Nested +boolean operations in an argument can therefore re-test an intermediate result. + +```py +def call_argument(value: MutableTruthiness): + if bool((value and False) and reveal_type(value)): # revealed: MutableTruthiness & ~AlwaysFalsy + pass +``` + +An assignment expression evaluates its right-hand side as a value before testing the assigned +object. Nested boolean operations on that right-hand side can therefore re-test an intermediate +result, even when the assignment expression is a condition. + +```py +def assignment_expression(value: MutableTruthiness, marker: int): + if saved := (value and False) and reveal_type(marker): # revealed: int + pass +``` + +A comprehension's filters are conditions, but its element is evaluated as a value, even when the +comprehension itself controls a branch. + +```py +def comprehension_element(value: MutableTruthiness, marker: int): + if [ + (value and False) and reveal_type(marker) # revealed: int + for _ in range(1) + if value or True + ]: + pass +``` diff --git a/crates/ty_python_semantic/resources/mdtest/boundness_declaredness/public.md b/crates/ty_python_semantic/resources/mdtest/boundness_declaredness/public.md index d61309e0a7..b57d6be857 100644 --- a/crates/ty_python_semantic/resources/mdtest/boundness_declaredness/public.md +++ b/crates/ty_python_semantic/resources/mdtest/boundness_declaredness/public.md @@ -14,8 +14,8 @@ We test the whole matrix of possible boundness and declaredness states. The curr summarized in the following table, while the tests below demonstrate each case. Note that some of this behavior is questionable and might change in the future. See the TODOs in `symbol_by_id` (`types.rs`) and [this issue](https://github.com/astral-sh/ruff/issues/14297) for more information. -In particular, we should raise errors in the "possibly-undeclared-and-unbound" as well as the -"undeclared-and-possibly-unbound" cases (marked with a "?"). +In particular, we should raise an error in the "possibly-undeclared-and-unbound" case (marked with a +"?"). | **Public type** | declared | possibly-undeclared | undeclared | | ---------------- | ------------ | -------------------------- | ----------------------- | @@ -23,11 +23,11 @@ In particular, we should raise errors in the "possibly-undeclared-and-unbound" a | possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` | | unbound | `T_declared` | `T_declared` | `Unknown` | -| **Diagnostic** | declared | possibly-undeclared | undeclared | -| ---------------- | -------- | ------------------------- | ------------------- | -| bound | | | | -| possibly-unbound | | `possibly-missing-import` | ? | -| unbound | | ? | `unresolved-import` | +| **Diagnostic** | declared | possibly-undeclared | undeclared | +| ---------------- | -------- | ---------------------------- | ---------------------------- | +| bound | | | | +| possibly-unbound | | `possibly-missing-attribute` | `possibly-missing-attribute` | +| unbound | | ? | `unresolved-attribute` | When the declared and inferred types are mutually assignable, we use `T_declared` directly instead of unioning it with `T_inferred`. @@ -73,10 +73,7 @@ class Public: c: Any d: int - # `flag` is a function, so this is always truthy — the test means `flag()`, but fixing it - # changes what the section asserts about possibly-missing attributes - # error: [redundant-condition] - if flag: + if flag(): a = 1 b = 2 # error: [invalid-assignment] c = 3 @@ -214,27 +211,27 @@ Public.a = None ### Undeclared and possibly unbound -If a symbol is undeclared and *possibly* unbound, we currently do not raise an error. This seems -inconsistent when compared to the "possibly-undeclared-and-possibly-unbound" case. +If a symbol is undeclared and *possibly* unbound, we raise a `possibly-missing-attribute` error +because the attribute might not exist. ```py def flag() -> bool: return True class Public: - # as above: `if flag:` is always truthy, and `if flag()` would satisfy the TODO below - # error: [redundant-condition] - if flag: + if flag(): a = 1 b: SomeUnknownName = 1 # error: [unresolved-reference] -# TODO: these should raise an error. Once we fix this, update the section description and the table -# on top of this document. +# error: [possibly-missing-attribute] reveal_type(Public.a) # revealed: int -reveal_type(Public.b) # revealed: Unknown +# error: [possibly-missing-attribute] +reveal_type(Public.b) # revealed: Literal[1] | Unknown # External modifications of `a` are checked against the inferred type: +# # error: [invalid-assignment] +# error: [possibly-missing-attribute] Public.a = None ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 45062b7996..223783929b 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -387,10 +387,11 @@ def accepts_truthy_constrained_typevar(x: T_constrained_a_b) -> bool: if isinstance(x, (A, B)): return True -RecursiveA = TypeAliasType("RecursiveA", Union[A, "RecursiveB"]) -RecursiveB = TypeAliasType("RecursiveB", Union[B, "RecursiveA"]) -RecursivePartialA = TypeAliasType("RecursivePartialA", Union[A, "RecursivePartialB"]) -RecursivePartialB = TypeAliasType("RecursivePartialB", Union[bytes, "RecursivePartialA"]) +# Invalid alias cycles still recover the non-recursive members for narrowing. +RecursiveA = TypeAliasType("RecursiveA", Union[A, "RecursiveB"]) # error: [cyclic-type-alias-definition] +RecursiveB = TypeAliasType("RecursiveB", Union[B, "RecursiveA"]) # error: [cyclic-type-alias-definition] +RecursivePartialA = TypeAliasType("RecursivePartialA", Union[A, "RecursivePartialB"]) # error: [cyclic-type-alias-definition] +RecursivePartialB = TypeAliasType("RecursivePartialB", Union[bytes, "RecursivePartialA"]) # error: [cyclic-type-alias-definition] def accepts_mutually_recursive_alias(x: RecursiveA) -> bool: reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] @@ -466,6 +467,14 @@ error[call-non-callable]: `NotImplemented` is not callable | --------------^^ | | | Did you mean `NotImplementedError`? +help: Use `NotImplementedError` instead + | +2 | # snapshot: call-non-callable + - raise NotImplemented() +3 + raise NotImplementedError() +4 | def _(): + | +note: This is an unsafe fix and may change runtime behavior ``` ```py @@ -482,6 +491,33 @@ error[call-non-callable]: `NotImplemented` is not callable | --------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | | | Did you mean `NotImplementedError`? +help: Use `NotImplementedError` instead + | +5 | # snapshot: call-non-callable + - raise NotImplemented("this module is not implemented yet!!!") +6 + raise NotImplementedError("this module is not implemented yet!!!") +7 | def _(NotImplementedError: object): + | +note: This is an unsafe fix and may change runtime behavior +``` + +When a local binding shadows `NotImplementedError`, replacing `NotImplemented` with that name would +not necessarily produce an exception, so we omit the fix. + +```py +def _(NotImplementedError: object): + # snapshot: call-non-callable + raise NotImplemented() +``` + +```snapshot +error[call-non-callable]: `NotImplemented` is not callable + --> src/mdtest_snippet.py:9:11 + | +9 | raise NotImplemented() + | --------------^^ + | | + | Did you mean `NotImplementedError`? ``` ## `map` with generic callbacks @@ -559,6 +595,83 @@ def clean(value: dict[str, int] | str | None) -> None: value[key] = item ``` +## `dict` keyword arguments with a shadowed `typing` module + +An empty first-party `typing` module hides the definitions that make `dict` generic. Calls with one +or more named keyword arguments still check their values and recover with `Unknown`, just like +dictionary literals. + +`typing.py`: + +```py +``` + +`main.py`: + +```py +reveal_type(dict(a=1)) # revealed: dict[str, int] +reveal_type(dict(a=1, b=2)) # revealed: dict[str, int] +reveal_type({"a": 1}) # revealed: dict[str, int] + +# error: [unresolved-reference] +dict(a=1, b=missing) +``` + +## Empty `dict` calls with a non-generic stub + +An empty call to a non-generic dictionary class is rejected if its constructor requires an argument. + +```toml +[environment] +typeshed = "/typeshed" +``` + +`/typeshed/stdlib/builtins.pyi`: + +```pyi +class object: ... +class int: ... + +class dict: + def __init__(self, value: int) -> None: ... +``` + +```py +dict(1) + +dict() # error: [missing-argument] "No argument provided for required parameter `value`" +``` + +## `dict` keyword arguments that violate a type variable bound + +A custom typeshed can constrain dictionary values. A value that violates the bound is rejected, and +later keyword values are still checked. + +```toml +[environment] +python-version = "3.12" +typeshed = "/typeshed" +``` + +`/typeshed/stdlib/builtins.pyi`: + +```pyi +class object: ... +class str: ... +class int: ... + +class dict[K, V: int]: + def __init__(self, **kwargs: V) -> None: ... +``` + +```py +dict(a=1) + +# error: [invalid-argument-type] "does not satisfy upper bound `int`" +# error: [unresolved-reference] +dict(a="oops", b=missing) +``` + ## Failed inner `OrderedDict` calls do not invalidate outer constructors Constructing an `OrderedDict` from a list containing both strings and floats is already rejected. diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index 68dce9a5f0..552520e5e7 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -240,6 +240,7 @@ except* Exception: unknown_decorator: Any +# error: [dynamic-function-decorator-return] @unknown_decorator # error: [unresolved-reference] def decorated(argument: lambda: decorated, /): # error: [invalid-type-form] pass @@ -518,4 +519,126 @@ Parent().method(1) Child().method(1) ``` +## Recursive receiver protocols across overloads + +Each overload introduces its own type variables, even when two overloads have identical signatures. +Binding the receiver preserves these variables when its protocol refers back to the same method. + +```py +from typing import Protocol, overload + +class P[T](Protocol): + def method(self) -> T: ... + +@overload +def method[T](obj: P[T]) -> T: ... +@overload +def method[U](obj: P[U]) -> U: ... +@overload +def method(obj: object) -> int: ... +def method(obj): + raise NotImplementedError + +class C: + method = method + +reveal_type(C().method) # revealed: Overload[[T]() -> T, [U]() -> U, () -> int] +``` + +## Concrete overload alternatives with a recursive receiver + +A recursive protocol-typed receiver also works with concrete overloads that accept different +argument types. Binding the receiver preserves both concrete alternatives. + +```py +from typing import Protocol, overload + +class P[T](Protocol): + def method(self, arg: T) -> None: ... + +@overload +def method[T](obj: P[T], arg: T) -> None: ... +@overload +def method(obj: object, arg: int) -> None: ... +@overload +def method(obj: object, arg: str) -> None: ... +def method(obj, arg): + raise NotImplementedError + +class C: + method = method + +reveal_type(C().method) # revealed: Overload[[T](arg: T) -> None, (arg: int) -> None, (arg: str) -> None] +``` + +## Recursive receiver protocols with tuple parameters + +The receiver's type variables can occur together in a tuple parameter and separately in the return +type. Binding the receiver preserves both variables and the concrete overload alternatives. + +```py +from typing import Protocol, overload + +class P[A, R](Protocol): + def method(self, arg: tuple[A, R]) -> R: ... + +@overload +def method[A, R](obj: P[A, R], arg: tuple[A, R]) -> R: ... +@overload +def method(obj: object, arg: tuple[int, str]) -> str: ... +@overload +def method(obj: object, arg: tuple[str, int]) -> int: ... +def method(obj, arg): + raise NotImplementedError + +class C: + method = method + +# revealed: Overload[[A, R](arg: tuple[A, R]) -> R, (arg: tuple[int, str]) -> str, (arg: tuple[str, int]) -> int] +reveal_type(C().method) +C().method((1, "x")) +C().method(("x", 1)) +C().method((1,)) # error: [no-matching-overload] +``` + +## Recursive receiver protocols with invariant containers + +Type variables in invariant containers also survive receiver binding. Calls still enforce the +parameter's container shape after the recursive receiver constraints have been resolved. + +```py +from typing import Protocol, overload + +class P[A, R](Protocol): + def method(self, arg: list[A]) -> list[R]: ... + +@overload +def method[A, R](obj: P[A, R], arg: list[A]) -> list[R]: ... +@overload +def method(obj: object, arg: list[int]) -> list[int]: ... +@overload +def method(obj: object, arg: list[str]) -> list[str]: ... +def method(obj, arg): + raise NotImplementedError + +class C: + method = method + +# revealed: Overload[[A, R](arg: list[A]) -> list[R], (arg: list[int]) -> list[int], (arg: list[str]) -> list[str]] +reveal_type(C().method) +C().method([1]) +C().method(["x"]) +C().method(1) # error: [no-matching-overload] +``` + +## Builtin functions with recursive receiver protocols + +Assigning `pow` to a class's `__pow__` attribute is valid. Its overloads accept protocols describing +that same method, so receiver checking is recursive. + +```py +class C: + __pow__ = pow +``` + [`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092 diff --git a/crates/ty_python_semantic/resources/mdtest/call/constructor.md b/crates/ty_python_semantic/resources/mdtest/call/constructor.md index 40eae772ba..0419465669 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/constructor.md +++ b/crates/ty_python_semantic/resources/mdtest/call/constructor.md @@ -68,7 +68,7 @@ class Foo: reveal_type(Foo(1)) # revealed: Foo -# error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["x"]`" +# error: [invalid-argument-type] "Argument to constructor `Foo.__new__` is incorrect: Expected `int`, found `Literal["x"]`" reveal_type(Foo("x")) # revealed: Foo # error: [missing-argument] "No argument provided for required parameter `x` of class `Foo`" reveal_type(Foo()) # revealed: Foo @@ -76,6 +76,17 @@ reveal_type(Foo()) # revealed: Foo reveal_type(Foo(1, 2)) # revealed: Foo ``` +When `__new__` is a lambda with an unknown return type, constructor calls still infer the class +instance type: + +```py +class LambdaNew: + __new__ = lambda cls: object.__new__(cls) + +reveal_type(LambdaNew()) # revealed: LambdaNew +LambdaNew().missing_attribute # error: [unresolved-attribute] +``` + ## `__new__` with an invalid decorator and unresolved return annotation Regression test for . @@ -149,8 +160,8 @@ def _(flag: bool) -> None: def __new__(cls, x: int, y: int = 1): ... reveal_type(Foo(1)) # revealed: Foo - # error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["1"]`" - # error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["1"]`" + # error: [invalid-argument-type] "Argument to constructor `Foo.__new__` is incorrect: Expected `int`, found `Literal["1"]`" + # error: [invalid-argument-type] "Argument to constructor `Foo.__new__` is incorrect: Expected `int`, found `Literal["1"]`" reveal_type(Foo("1")) # revealed: Foo # error: [missing-argument] "No argument provided for required parameter `x` of class `Foo`" # error: [missing-argument] "No argument provided for required parameter `x` of class `Foo`" @@ -266,6 +277,224 @@ class ReturnsFactory: ReturnsFactory() ``` +## Recursive constructor signatures + +When a constructor refers back to the same receiver type without reaching a callable signature, we +fall back to an unknown signature with the nominal instance return type. + +```toml +[environment] +python-version = "3.12" +``` + +### A decorator returning the enclosing class + +A decorator can replace `__new__` with the type of its implicit `cls` parameter, making the +constructor refer back to itself. Regression test for . + +```py +from collections.abc import Callable + +def decorate[T](callback: Callable[[T], None]) -> T: + raise NotImplementedError + +class C: + @decorate + def __new__(cls): + pass + +reveal_type(C.__new__) # revealed: type[C] +reveal_type(C()) # revealed: C +``` + +### Mutually recursive class objects + +The same fallback applies when two classes use each other as their `__new__` method: + +```py +class A: + __new__: type["B"] + +class B: + __new__: type[A] + +reveal_type(A()) # revealed: A +``` + +### Recursive generic constructors + +A constructor can also return to the same generic class with the same specialization: + +```py +class C[T]: + __new__: type["C[T]"] + +# TODO: Default unsolved class type variables in gradual constructor signatures to `Unknown`. +reveal_type(C()) # revealed: C[T@C] +reveal_type(C[int]()) # revealed: C[int] +``` + +### Shared constructors in union branches + +A constructor that occurs in separate union branches is not recursive. Both branches reach the same +finite signature: + +```py +class End: + def __new__(cls, *args: object) -> int: + return 1 + +class Left: + __new__ = End + +class Right: + __new__ = End + +class C: + __new__: type[Left] | type[Right] + +reveal_type(C()) # revealed: int +``` + +### Finite generic constructor chains + +Returning to the same generic class is safe when subsequent constructors reach a callable signature. +Here, each step consumes a nested specialization until it reaches `End.__new__`: + +```py +class End[T]: + def __new__(cls, *args: object) -> int: + return 1 + +class C[T]: + __new__: type[T] + +reveal_type(C[C[End[int]]]()) # revealed: int + +type Inner = C[End[int]] + +reveal_type(C[Inner]()) # revealed: int +``` + +A finite chain can even grow its arguments. `Factory` returns to `C` with a larger argument, but +`End` does not expand the nested `Factory` type: + +```py +class Factory[T]: + __new__: type[C[End["Factory[T]"]]] + +reveal_type(C[Factory[int]]()) # revealed: int +``` + +### Descriptor-sensitive constructor receivers + +An exact class object and a value of `type[C]` can select different descriptor overloads. This +constructor returns to `C` with a different receiver type before reaching a finite signature: + +```py +from collections.abc import Callable +from typing import overload +from ty_extensions._internal import TypeOf + +class Descriptor: + @overload + def __get__(self, obj: None, owner: "TypeOf[C]") -> "type[C]": ... + @overload + def __get__(self, obj: None, owner: "type[C]") -> Callable[..., int]: ... + def __get__(self, obj, owner): + raise NotImplementedError + +class C: + __new__: Descriptor + +reveal_type(C()) # revealed: int +``` + +### Argument checks after recursive `__new__` + +The recursive `__new__` fallback accepts arbitrary arguments, but an ordinary `__init__` still +validates them against the class's specialization: + +```py +class C[T]: + __new__: type["C[T]"] + + def __init__(self, x: T): + pass + +reveal_type(C[int](1)) # revealed: C[int] +# error: [invalid-argument-type] +C[int]("wrong") +``` + +### A recursive class object in place of `__init__` + +Class-valued `__init__` methods can also lead back to the constructor being expanded: + +```py +class C: + __init__: type["C"] + +reveal_type(C()) # revealed: C +``` + +The fallback does not override a known non-instance return from `__new__`, which bypasses +`__init__`: + +```py +class ReturnsInt: + def __new__(cls) -> int: + return 1 + + __init__: type["ReturnsInt"] + +reveal_type(ReturnsInt()) # revealed: int +``` + +### A recursive class object in place of metaclass `__call__` + +A metaclass's `__call__` can refer back to the class being constructed: + +```py +class Meta(type): + __call__: type["C"] + +class C(metaclass=Meta): ... + +reveal_type(C()) # revealed: C +``` + +### A cycle through a callable instance + +Constructor expansion can pass through an instance's `__call__` before returning to the original +class: + +```py +class C: + __new__: "Factory" + +class Factory: + __call__: type[C] + +reveal_type(C()) # revealed: C +``` + +### A metaclass bypassing a recursive `__new__` + +A metaclass `__call__` that returns an unrelated type bypasses `__new__`. A recursive signature in +the unused `__new__` does not prevent us from inferring the metaclass method's return type: + +```py +class Meta(type): + def __call__(cls) -> int: + return 1 + +class C(metaclass=Meta): + __new__: type["C"] + +reveal_type(C()) # revealed: int +``` + ## `__new__` is implicitly a static method, but explicitly marking it as one is harmless ```py @@ -386,7 +615,7 @@ class Foo: def __new__(cls, x: int): return object.__new__(cls) -# error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found ``" +# error: [invalid-argument-type] "Argument to bound method `Foo.__new__` is incorrect: Expected `int`, found ``" # error: [too-many-positional-arguments] "Too many positional arguments to class `Foo`: expected 0, got 1" Foo(1) @@ -1748,8 +1977,8 @@ def _(flag: bool) -> None: def __init__(self, x: int, y: int = 1): ... reveal_type(Foo(1)) # revealed: Foo - # error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["1"]`" - # error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["1"]`" + # error: [invalid-argument-type] "Argument to `Foo.__init__` is incorrect: Expected `int`, found `Literal["1"]`" + # error: [invalid-argument-type] "Argument to `Foo.__init__` is incorrect: Expected `int`, found `Literal["1"]`" reveal_type(Foo("1")) # revealed: Foo # error: [missing-argument] "No argument provided for required parameter `x` of class `Foo`" # error: [missing-argument] "No argument provided for required parameter `x` of class `Foo`" @@ -1851,10 +2080,10 @@ class Foo: def __init__(self, x: str) -> None: self.x = x -# error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `str`, found `Literal[1]`" +# error: [invalid-argument-type] "Argument to `Foo.__init__` is incorrect: Expected `str`, found `Literal[1]`" Foo(1) -# error: [invalid-argument-type] "Argument to class `Foo` is incorrect: Expected `int`, found `Literal["x"]`" +# error: [invalid-argument-type] "Argument to constructor `Foo.__new__` is incorrect: Expected `int`, found `Literal["x"]`" Foo("x") ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index c8580eec52..be72a221d2 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -12,15 +12,21 @@ reveal_type(get_int()) # revealed: int ## Gradual variadic parameters ```py -from typing import Any +from typing import Any, TypeVar + +T = TypeVar("T") def accepts_anything(first: int, *args: Any, **kwargs: Any) -> None: ... def accepts_only_gradual(*args: Any, **kwargs: Any) -> None: ... +def preserves_first(first: T, *args: Any, **kwargs: Any) -> T: + return first accepts_anything(1, "one", object(), keyword=object()) accepts_anything("not an int") # error: [invalid-argument-type] accepts_only_gradual(1, "one", keyword=object()) accepts_only_gradual(**{1: "one"}) # error: [invalid-argument-type] + +reveal_type(preserves_first(1, "other", keyword=object())) # revealed: Literal[1] ``` ## Object variadic parameters @@ -89,6 +95,41 @@ def get_int[T]() -> int: reveal_type(get_int()) # revealed: int ``` +## Generic call with independent and dependent arguments + +Concrete arguments must not erase a type variable inferred from another argument. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +class Invariant[T]: + item: T + +class Bounded[T: int]: + item: T + +def combine[A, B, U](first: Invariant[A], second: Invariant[B], dependent: Invariant[U]) -> tuple[A, B, U]: + raise NotImplementedError + +class Constructed[T]: + def __new__[A, B, U](cls, first: Invariant[A], second: Invariant[B], dependent: Invariant[U]) -> Constructed[tuple[A, B, U]]: + raise NotImplementedError + +def infer[T](dependent: Invariant[T]) -> None: + concrete = Invariant[int]() + reveal_type(combine(concrete, concrete, dependent)) # revealed: tuple[int, int, T@infer] + reveal_type(Constructed(concrete, concrete, dependent)) # revealed: Constructed[tuple[int, int, T@infer]] + + bounded = Invariant[Bounded[int]]() + reveal_type(combine(bounded, bounded, dependent)) # revealed: tuple[Bounded[int], Bounded[int], T@infer] + reveal_type(Constructed(bounded, bounded, dependent)) # revealed: Constructed[tuple[Bounded[int], Bounded[int], T@infer]] +``` + ## Generic callable chains Inferring a chain of generic callable parameters should discard internal typevar artifacts from @@ -140,6 +181,30 @@ dynamic: Any = [] reveal_type(map(operator.add, ints, dynamic)) # revealed: map[Unknown] ``` +## Generic overloaded callable constraints in constructors + +An overloaded callback can have a type variable of its own. An overload rejected by the constructor +must not leave a mapping for that variable that causes the accepted overload to be rejected. + +```py +from typing import Generic, TypeVar, overload + +T = TypeVar("T", str, bytes) + +class Result(Generic[T]): ... + +@overload +def convert(value: T) -> Result[T]: ... +@overload +def convert(value: Result[T]) -> Result[T]: ... +def convert(value: T | Result[T]) -> Result[T]: + raise NotImplementedError + +# TODO: Preserve correlated overloaded-callback solutions (astral-sh/ty#2799) to infer +# `map[Result[str]]`. +reveal_type(map(convert, ["a"])) # revealed: map[Unknown] +``` + ## Decorated ```py @@ -1335,8 +1400,9 @@ with_default(1, 2) ### Unpacked variadic elements preserve generic bounds -Ordinary type variables are inferred from individual unpacked elements, even beside an unresolved -type-variable tuple. Their upper bounds remain enforced. +Ordinary type variables are inferred from individual unpacked elements in homogeneous or +heterogeneous tuples, even beside an unresolved type-variable tuple. Their upper bounds remain +enforced. ```toml [environment] @@ -1355,6 +1421,19 @@ fixed(1) # error: [invalid-argument-type] reveal_type(suffix("prefix", "valid")) # revealed: Literal["valid"] suffix("prefix", 1) # error: [invalid-argument-type] + +def homogeneous[T: str](*args: *tuple[T, ...]) -> T: + return args[0] + +reveal_type(homogeneous("first", "second")) # revealed: Literal["first", "second"] +homogeneous("valid", 1) # error: [invalid-argument-type] + +def heterogeneous[T: str](*args: *tuple[int, T]) -> T: + return args[1] + +def _(valid: tuple[int, str], invalid: tuple[int, int]) -> None: + reveal_type(heterogeneous(*valid)) # revealed: str + heterogeneous(*invalid) # error: [invalid-argument-type] ``` ### Callable protocols enforce unpacked variadic requirements @@ -1558,6 +1637,29 @@ f(**dict(a=1, b=2)) f(**Foo(a=1, b=2)) ``` +### Unpacked keyword values retain their individual generic types + +Each named value in an unpacked `TypedDict` must be related to its own generic parameter instead of +using the type of the complete mapping. + +```py +from typing_extensions import TypedDict, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + +class Values(TypedDict, closed=True): + first: int + second: str + +def combine(*, first: T, second: U) -> tuple[T, U]: + return first, second + +values: Values = {"first": 1, "second": "value"} + +reveal_type(combine(**values)) # revealed: tuple[int, str] +``` + ### Keyword-only parameters ```py diff --git a/crates/ty_python_semantic/resources/mdtest/call/functools_partial.md b/crates/ty_python_semantic/resources/mdtest/call/functools_partial.md index 7aa518e66f..5f76a45aca 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/functools_partial.md +++ b/crates/ty_python_semantic/resources/mdtest/call/functools_partial.md @@ -393,6 +393,90 @@ reveal_type(p(2)) # revealed: tuple[int, int] reveal_type(p(2)[1]) # revealed: int ``` +### Variadic generic functions with no bound arguments + +A partial with no bound arguments preserves its variadic type parameter until the resulting callable +is invoked. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from functools import partial + +def collect[*Ts](*values: *Ts) -> tuple[*Ts]: + return values + +bound = partial(collect) +reveal_type(bound) # revealed: partial[[*Ts](*values: *Ts) -> tuple[*Ts]] +reveal_type(bound()) # revealed: tuple[()] +reveal_type(bound("x", 1)) # revealed: tuple[Literal["x"], Literal[1]] +``` + +### Variadic generic functions with a bound leading parameter + +Binding a fixed leading parameter leaves the variadic type parameter available for later arguments. +A completed call with no variadic arguments still infers an empty tuple. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from functools import partial + +def collect[*Ts](prefix: int, *values: *Ts) -> tuple[*Ts]: + return values + +bound = partial(collect, 1) +reveal_type(bound) # revealed: partial[[*Ts](*values: *Ts) -> tuple[*Ts]] +reveal_type(bound()) # revealed: tuple[()] +reveal_type(bound("x", 2)) # revealed: tuple[Literal["x"], Literal[2]] +reveal_type(collect(1)) # revealed: tuple[()] +``` + +### Variadic generic functions with a bound generic leading parameter + +A bound ordinary type parameter is specialized while an untouched variadic type parameter remains +generic. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from functools import partial + +def collect[T, *Ts](prefix: T, *values: *Ts) -> tuple[T, *Ts]: + return (prefix, *values) + +bound = partial(collect, 1) +reveal_type(bound) # revealed: partial[[*Ts](*values: *Ts) -> tuple[Literal[1], *Ts]] +reveal_type(bound()) # revealed: tuple[Literal[1]] +reveal_type(bound("x", True)) # revealed: tuple[Literal[1], Literal["x"], Literal[True]] +``` + +### Partially bound asyncio executor callback + +Binding the executor must not consume the callback's variadic arguments before it is called. + +```toml +[environment] +python-version = "3.14" +``` + +```py +import asyncio +from functools import partial + +callback = partial(asyncio.get_running_loop().run_in_executor, None) +asyncio.run(callback(print, "")) +``` + ### Generic functions preserve defaults for no-longer-inferable type params ```py diff --git a/crates/ty_python_semantic/resources/mdtest/call/methods.md b/crates/ty_python_semantic/resources/mdtest/call/methods.md index 742a07462f..1c1c7c8e07 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/methods.md +++ b/crates/ty_python_semantic/resources/mdtest/call/methods.md @@ -829,6 +829,100 @@ class Valid(Base[int], arg=1): ... class InvalidType(Base[int], arg="x"): ... # error: [invalid-argument-type] ``` +#### Generic subclasses + +```toml +[environment] +python-version = "3.13" +``` + +##### Type parameter defaults + +When checking `cls` for an inherited `__init_subclass__` hook, the generic subclass retains its type +parameters rather than replacing them with their defaults. `Child[T]` therefore satisfies the +receiver bound `Base[T]`. + +```py +class Base[T]: + def __init_subclass__(cls, *, flag: bool = False) -> None: ... + +class NoDefault[T](Base[T]): ... +class Child[T = int](Base[T]): ... +class PartialDefault[U, T = int](Base[T]): ... +``` + +Class keyword arguments are still checked against the hook's signature. + +```py +class WithKeyword[T = int](Base[T], flag=True): ... + +# error: [invalid-argument-type] "Expected `bool`, found" +class InvalidKeyword[T = int](Base[T], flag="bad"): ... +``` + +##### Explicit receiver annotations + +The implicit call also accepts an explicit `cls: type[Base[T]]` annotation. + +```py +class Base[T]: + def __init_subclass__(cls: type["Base[T]"]) -> None: ... + +class Child[T = int](Base[T]): ... +``` + +A hook restricted to `RestrictedBase[int]` rejects a subclass that remains generic, even when `int` +is its default. + +```py +class RestrictedBase[T]: + def __init_subclass__(cls: type["RestrictedBase[int]"]) -> None: ... + +class Concrete(RestrictedBase[int]): ... + +# error: [invalid-argument-type] "Expected `type[RestrictedBase[int]]`" +class Invalid[T = int](RestrictedBase[T]): ... +``` + +##### Legacy type parameter defaults + +The same receiver relationship holds for subclasses using legacy type variables with defaults. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U", default=int) + +class Base(Generic[T]): + def __init_subclass__(cls) -> None: ... + +class Child(Base[U]): ... +``` + +##### Keyword arguments using subclass type parameters + +Keyword arguments can refer to the subclass's type parameters. The argument must be compatible with +that type parameter, not just its default: `T` can be specialized to a type other than `int`. + +```py +from typing import cast + +class Base[T]: + def __init_subclass__(cls, *, value: T) -> None: ... + +class Valid[T = int](Base[T], value=cast(T, 1)): ... + +# error: [invalid-argument-type] "Expected `T@Invalid`, found `Literal[1]`" +class Invalid[T = int](Base[T], value=1): ... +``` + +An explicitly specialized base accepts the concrete argument. + +```py +class AlsoValid(Base[int], value=1): ... +``` + ## `@staticmethod` ### Basic @@ -1076,6 +1170,28 @@ def narrowed_bound_method_attribute(): reveal_type(method.__globals__) # revealed: dict[str, Any] ``` +## Receiver rebinding does not shadow methods + +Assigning to `self` does not assign to its method attributes. An empty loop targeting `self` +therefore leaves method calls bound, both before the loop and outside the method containing it. + +This guards against a regression in implicit attribute type inference: synthetic loop-header +definitions for attribute places such as `self.method` are not attribute assignments and must not be +considered when inferring instance attribute types. + +```py +class C: + def method(self) -> None: + pass + + def rebind(self) -> None: + self.method() + for self in []: + pass + +C().method() +``` + ## Builtin functions and methods Some builtin functions and methods are heavily special-cased by ty. This mdtest checks that various diff --git a/crates/ty_python_semantic/resources/mdtest/call/new_class.md b/crates/ty_python_semantic/resources/mdtest/call/new_class.md index 941606be2c..f0a4ed9799 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/new_class.md +++ b/crates/ty_python_semantic/resources/mdtest/call/new_class.md @@ -192,6 +192,19 @@ MyEnum = types.new_class("MyEnum", (Enum,)) reveal_type(MyEnum) # revealed: ``` +### Protocol bases + +`types.new_class()` also preserves the `_ProtocolMeta` metaclass of source-defined protocols. + +```py +import types +from typing import Protocol + +class Interface(Protocol): ... + +reveal_type(type(types.new_class("Dynamic", (Interface,)))) # revealed: +``` + ### Generic and TypedDict bases Even though `types.new_class()` handles `__mro_entries__` at runtime, ty does not yet model the full diff --git a/crates/ty_python_semantic/resources/mdtest/call/overloads.md b/crates/ty_python_semantic/resources/mdtest/call/overloads.md index 1050121c28..bbb8a81766 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/call/overloads.md @@ -264,6 +264,109 @@ def _(a: A, bc: B | C, cd: C | D): reveal_type(f(*(a, cd))) # revealed: Unknown ``` +### Expanding a keyword argument after unpacking into a variadic parameter + +Valid unpacked positional arguments must not prevent expansion of an unrelated union-typed keyword +argument. The positional arguments may come from an empty tuple, a fixed-length tuple, a +variable-length tuple, or a list. + +`overloaded.pyi`: + +```pyi +from typing import overload + +@overload +def f(*values: str, kind: int) -> int: ... +@overload +def f(*values: str, kind: None) -> str: ... +``` + +Expanding `kind` matches one overload when its value is an `int` and the other when its value is +`None`, independently of how the positional arguments are provided. An incompatible positional +argument must still fail to match either overload. + +```py +from overloaded import f + +def _(one: tuple[str], many: tuple[str, ...], items: list[str], kind: int | None) -> None: + reveal_type(f("a", kind=kind)) # revealed: int | str + reveal_type(f(*(), kind=kind)) # revealed: int | str + reveal_type(f(*one, kind=kind)) # revealed: int | str + reveal_type(f(*many, kind=kind)) # revealed: int | str + reveal_type(f(*items, kind=kind)) # revealed: int | str + +def _(invalid: tuple[int], kind: int | None) -> None: + # error: [no-matching-overload] + reveal_type(f(*invalid, kind=kind)) # revealed: Unknown +``` + +### Expanding a keyword argument with an unpacked variadic annotation + +An unpacked variadic annotation can specify a different expected type for each positional argument. +Unpacked arguments must be checked against their corresponding element types while an unrelated +union-typed keyword argument is expanded. + +```toml +[environment] +python-version = "3.13" +``` + +`overloaded.pyi`: + +```pyi +from typing import overload + +@overload +def f(*values: *tuple[str, int], kind: int) -> int: ... +@overload +def f(*values: *tuple[str, int], kind: None) -> str: ... +@overload +def suffix[T: str, *Parts](*values: *tuple[*Parts, T], kind: int) -> int: ... +@overload +def suffix[T: str, *Parts](*values: *tuple[*Parts, T], kind: None) -> str: ... +``` + +Both directly supplied arguments and an unpacked tuple satisfy the heterogeneous annotation. A +generic variadic prefix also permits expansion when the fixed suffix has a compatible type. + +```py +from overloaded import f, suffix + +def _(values: tuple[str, int], kind: int | None) -> None: + reveal_type(f("a", 1, kind=kind)) # revealed: int | str + reveal_type(f(*values, kind=kind)) # revealed: int | str + +def _(pair: tuple[int, str], kind: int | None) -> None: + reveal_type(suffix(*pair, kind=kind)) # revealed: int | str +``` + +### Expanding a keyword argument after unpacking into positional parameters + +Expanding a union-typed keyword argument must also work when a fixed-length tuple supplies ordinary +positional parameters with different types instead of a variadic parameter. + +`overloaded.pyi`: + +```pyi +from typing import overload + +@overload +def f(value: str, count: int, *, kind: int) -> int: ... +@overload +def f(value: str, count: int, *, kind: None) -> str: ... +``` + +Both the direct positional arguments and the unpacked tuple select the same overloads after `kind` +is expanded. + +```py +from overloaded import f + +def _(values: tuple[str, int], kind: int | None) -> None: + reveal_type(f("a", 1, kind=kind)) # revealed: int | str + reveal_type(f(*values, kind=kind)) # revealed: int | str +``` + ### Generics (legacy) `overloaded.pyi`: @@ -755,7 +858,7 @@ class Foo: from overloaded import A, B, C, Foo, f from typing_extensions import Any, reveal_type -def _(ab: A | B, a: int | Any): +def _(ab: A | B, a: int | Any, invalid: tuple[C]): reveal_type(f(a1=a, a2=a, a3=a)) # revealed: C reveal_type(f(A(), a1=a, a2=a, a3=a)) # revealed: A reveal_type(f(B(), a1=a, a2=a, a3=a)) # revealed: B @@ -803,6 +906,25 @@ def _(ab: A | B, a: int | Any): ) ) + # An incompatible element in a definitely nonempty splat must also prevent expansion of the + # nine union-typed keyword arguments. + reveal_type( + # error: [no-matching-overload] + # revealed: Unknown + f( + *invalid, + a1=a, + a2=a, + a3=a, + a4=a, + a5=a, + a6=a, + a7=a, + a8=a, + a9=a, + ) + ) + # Here, the heuristics won't come into play because all arguments can be expanded but expanding # the first argument results in a successful evaluation of the call, so there's no exponential # growth of the number of argument lists. diff --git a/crates/ty_python_semantic/resources/mdtest/call/subclass_of.md b/crates/ty_python_semantic/resources/mdtest/call/subclass_of.md index f0f217c22e..527aea3ed3 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/subclass_of.md +++ b/crates/ty_python_semantic/resources/mdtest/call/subclass_of.md @@ -20,7 +20,7 @@ class C: def _(subclass_of_c: type[C]): reveal_type(subclass_of_c(1)) # revealed: C - # error: [invalid-argument-type] "Argument to class `C` is incorrect: Expected `int`, found `Literal["a"]`" + # error: [invalid-argument-type] "Argument to `C.__init__` is incorrect: Expected `int`, found `Literal["a"]`" reveal_type(subclass_of_c("a")) # revealed: C # error: [missing-argument] "No argument provided for required parameter `x` of class `C`" reveal_type(subclass_of_c()) # revealed: C diff --git a/crates/ty_python_semantic/resources/mdtest/call/type.md b/crates/ty_python_semantic/resources/mdtest/call/type.md index 90ab1c3f81..64298bd726 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/type.md +++ b/crates/ty_python_semantic/resources/mdtest/call/type.md @@ -2,14 +2,186 @@ ## Single-argument form -A single-argument call to `type()` returns an object that has the argument's meta-type. (This is -tested more extensively in `crates/ty_python_semantic/resources/mdtest/attributes.md`, alongside the -tests for the `__class__` attribute.) +A single-argument call to `type()` returns an object that has the argument's meta-type. + +### Basic + +For an integer literal, the result is the exact class object `int`. ```py reveal_type(type(1)) # revealed: ``` +### Classes of recursive intersections + +These aliases are invalid because expanding either one includes itself as a union member. During +error recovery, computing the class of their intersection preserves every non-recursive member, +regardless of expansion order. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from ty_extensions import Intersection + +type First = Second | int # error: [cyclic-type-alias-definition] +type Second = First | str # error: [cyclic-type-alias-definition] + +def recursive(value: Intersection[First, Second]): + reveal_type(type(value)) # revealed: type[int | str] +``` + +### Classes of recursive aliases with repeating specializations + +This invalid cyclic alias rotates its arguments through a finite set of specializations. During +error recovery, class inference retains the union of those arguments instead of widening to `type`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Rotate[T, U] = T | Rotate[U, T] # error: [cyclic-type-alias-definition] + +def rotating(value: Rotate[int, str]): + reveal_type(type(value)) # revealed: type[int | str] +``` + +### Classes of recursive aliases with growing specializations + +This invalid cyclic alias introduces classes beyond the initial type argument. During error +recovery, class inference terminates even when the type arguments keep growing, conservatively +returning `type`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Growing[T] = T | Growing[list[T]] # error: [cyclic-type-alias-definition] + +def growing(value: Growing[int]): + reveal_type(type(value)) # revealed: type +``` + +### Classes of recursive class aliases with growing specializations + +The arguments of a recursive alias can grow while nested `type` specializations are resolved. +Computing the class still terminates and retains the possible metaclasses. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Meta[T] = type[T] +type Growing[T] = T | Meta[Growing[list[T]]] + +def growing_class(value: Growing[int]): + reveal_type(type(value)) # revealed: type[int | type] +``` + +### Classes of recursive class aliases with nested specializations + +An alias can forward a class type to another alias. Class inference also terminates when that class +type contains a growing recursive specialization, retaining the possible metaclasses. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Meta[T] = type[T] +type NestedMeta[T] = Meta[type[T]] +type Growing[T] = T | NestedMeta[Growing[list[T]]] + +def nested_specialization(value: Growing[int]): + reveal_type(type(value)) # revealed: type[int | type] +``` + +### Classes of materialized recursive aliases + +Upper and lower materializations retain their different class types after recursive alias expansion. +Interleaving queries with the original alias preserves all three results. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Any +from ty_extensions import Bottom, Top + +type Meta[T] = type[T] +type Gradual = list[Any] | Meta[Gradual] + +def materialized_classes(top: Top[Gradual], bottom: Bottom[Gradual], plain: Gradual): + reveal_type(type(top)) # revealed: type[Top[list[Any]] | type] + reveal_type(type(bottom)) # revealed: type[Bottom[list[Any]] | type] + reveal_type(type(plain)) # revealed: type[list[Any] | type] + reveal_type(top.__class__) # revealed: type[Top[list[Any]] | type] + reveal_type(bottom.__class__) # revealed: type[Bottom[list[Any]] | type] + reveal_type(type(top)) # revealed: type[Top[list[Any]] | type] +``` + +### Classes with an aliased recursive type-variable bound + +A type variable cannot appear in its own bound, but this is not yet diagnosed when an alias hides +the type variable. Computing a parameter's class still terminates in this case. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Meta[T] = type[T] + +def recursive_bound[T: Meta[T]](value: type[T]): + type(value) +``` + +### Classes with an identity alias in a recursive type-variable bound + +An identity alias can also hide an invalid bound that refers back to the same type variable. +Computing a parameter's class terminates after the alias has been expanded. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Identity[T] = T + +def recursive_bound[T: Identity[T]](value: type[T]): + type(value) +``` + +### Classes with aliased recursive type-variable constraints + +An alias for `type[T]` can hide an invalid recursive constraint. Although this is not yet diagnosed, +computing a `type[T]` parameter's class still terminates. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Meta[T] = type[T] + +def recursive_constraints[T: (Meta[T], int)](value: type[T]): + type(value) +``` + ## Three-argument form (dynamic class creation) A three-argument call to `type()` creates a new class. We synthesize a class type using the name @@ -81,6 +253,14 @@ takes_foo1(foo2) takes_foo2(foo1) ``` +The classes also remain distinct when both calls occur in the same string annotation, even though +the surrounding type expression is invalid: + +```py +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +distinct: "static_assert(type('Foo', (), {}) is not type('Foo', (), {}))[int]" +``` + ## Instances and attribute access Instances of dynamic classes are typed with the synthesized class name. Attributes from all base @@ -975,6 +1155,31 @@ bases: tuple[type[C], type[D]] = (C, D) Y = type("Y", bases, {}) ``` +When a class is created in the metadata of a string annotation, the diagnostic still highlights the +class-creation call, not the whole string: + +```py +from typing import Annotated + +# snapshot: instance-layout-conflict +bad: "Annotated[int, type('Bad', (A, B), {})]" +``` + +```snapshot +error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases + --> src/mdtest_snippet.py:21:22 + | +21 | bad: "Annotated[int, type('Bad', (A, B), {})]" + | ^^^^^^^^^^^^^^^^^^^^^^^ Bases `A` and `B` cannot be combined in multiple inheritance +info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts + --> src/mdtest_snippet.py:21:35 + | +21 | bad: "Annotated[int, type('Bad', (A, B), {})]" + | - - `B` instances have a distinct memory layout because `B` defines non-empty `__slots__` + | | + | `A` instances have a distinct memory layout because `A` defines non-empty `__slots__` +``` + ## Cyclic functional class definitions ### Self-referential @@ -1180,6 +1385,44 @@ class Unrelated: ... Bad: type[Unrelated] = type("Bad", (Base,), {}) ``` +## Dynamic class calls in string annotations + +Dynamic class constructors can appear as `Annotated` metadata inside valid string annotations: + +```py +from collections import namedtuple +from enum import Enum +from types import new_class +from typing import Annotated, NamedTuple, TypedDict + +def f( + builtin: "Annotated[int, type('X', (), {})]", + new: "Annotated[int, new_class('X', ())]", + enum: "Annotated[int, Enum('X', {'VALUE': 1})]", + named_tuple: "Annotated[int, NamedTuple('X', [('value', int)])]", + collections_named_tuple: "Annotated[int, namedtuple('X', ['value'])]", + typed_dict: "Annotated[int, TypedDict('X', {'value': int})]", +): + reveal_type(builtin) # revealed: int + reveal_type(new) # revealed: int + reveal_type(enum) # revealed: int + reveal_type(named_tuple) # revealed: int + reveal_type(collections_named_tuple) # revealed: int + reveal_type(typed_dict) # revealed: int +``` + +An invalid subscript of a `type()` call should produce the usual diagnostic, including when the call +appears in a nested string annotation: + +```py +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +plain: "type('X', (), {})[int]" + +name = "Nested" +# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type expressions" +nested: "'type(name, (), {})[int]'" +``` + ## Dynamic class reassignment in a loop A dynamic class can capture the previous value of a loop-carried variable in its namespace. Type @@ -1283,7 +1526,8 @@ NT = type("NT", (NamedTuple,), {}) ### Protocol bases -Inheriting from a class that is itself a protocol is valid: +When a dynamic class inherits from a source-defined protocol, it also inherits the protocol's +`_ProtocolMeta` metaclass: ```py from typing import Protocol @@ -1294,12 +1538,22 @@ class MyProtocol(Protocol): ProtoImpl = type("ProtoImpl", (MyProtocol,), {"method": lambda self: 42}) reveal_type(ProtoImpl) # revealed: +reveal_type(type(ProtoImpl)) # revealed: reveal_mro(ProtoImpl) # revealed: (, , typing.Protocol, typing.Generic, ) instance = ProtoImpl() reveal_type(instance) # revealed: ProtoImpl ``` +A subclass of the dynamic class cannot choose a metaclass unrelated to `_ProtocolMeta`. + +```py +class Meta(type): ... + +# error: [conflicting-metaclass] +class Invalid(ProtoImpl, metaclass=Meta): ... +``` + ### TypedDict bases Inheriting from a class that is itself a TypedDict is valid: diff --git a/crates/ty_python_semantic/resources/mdtest/call/union.md b/crates/ty_python_semantic/resources/mdtest/call/union.md index a6af3d6293..2610c8d4b2 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/union.md +++ b/crates/ty_python_semantic/resources/mdtest/call/union.md @@ -132,7 +132,7 @@ class B: def _(flag: bool): cls = A if flag else B - # error: [invalid-argument-type] "Argument to class `B` is incorrect: Expected `str`, found `Literal[1]`" + # error: [invalid-argument-type] "Argument to `B.__init__` is incorrect: Expected `str`, found `Literal[1]`" reveal_type(cls(1)) # revealed: A | B ``` @@ -148,8 +148,8 @@ class B: def __init__(self, x: int) -> None: ... def _(factory: type[A] | type[B]): - # error: [invalid-argument-type] "Argument to class `A` is incorrect: Expected `int`, found `Literal["hello"]`" - # error: [invalid-argument-type] "Argument to class `B` is incorrect: Expected `int`, found `Literal["hello"]`" + # error: [invalid-argument-type] "Argument to `B.__init__` is incorrect: Expected `int`, found `Literal["hello"]`" + # error: [invalid-argument-type] "Argument to `A.__init__` is incorrect: Expected `int`, found `Literal["hello"]`" factory("hello") ``` @@ -174,8 +174,8 @@ class IntDiag(DeferredDiagBase[int]): ... class StrDiag(DeferredDiagBase[str]): ... def _(factory: type[IntDiag] | type[StrDiag]): - # error: [invalid-argument-type] "Argument to class `IntDiag` is incorrect: Expected `int`, found `float`" - # error: [invalid-argument-type] "Argument to class `StrDiag` is incorrect: Expected `str`, found `float`" + # error: [invalid-argument-type] "Argument to `DeferredDiagBase.__init__` is incorrect: Expected `str`, found `float`" + # error: [invalid-argument-type] "Argument to `DeferredDiagBase.__init__` is incorrect: Expected `int`, found `float`" factory(1.2) ``` @@ -1027,14 +1027,14 @@ def _(cls: type[UsesInit], other: type[UsesBytes], condition: bool) -> None: if issubclass(cls, UsesNew): constructor = cls if condition else other reveal_type(constructor) # revealed: (type[UsesInit] & type[UsesNew]) | type[UsesBytes] - # error: [invalid-argument-type] "class `UsesInit`" - # error: [invalid-argument-type] "class `UsesNew`" + # error: [invalid-argument-type] "Argument to `UsesInit.__init__` is incorrect: Expected `int`, found `None`" + # error: [invalid-argument-type] "Argument to constructor `UsesNew.__new__` is incorrect: Expected `str`, found `None`" # snapshot: invalid-argument-type constructor(None) ``` ```snapshot -error[invalid-argument-type]: Argument to class `UsesBytes` is incorrect +error[invalid-argument-type]: Argument to `UsesBytes.__init__` is incorrect --> src/mdtest_snippet.py:20:21 | 20 | constructor(None) diff --git a/crates/ty_python_semantic/resources/mdtest/class/slots.md b/crates/ty_python_semantic/resources/mdtest/class/slots.md new file mode 100644 index 0000000000..b6979d07ee --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/class/slots.md @@ -0,0 +1,1070 @@ +# Instance slots + +Classes can declare instance attributes and restrict their instance layout with `__slots__`. + +## Slot names declare instance attributes + +A slot is a valid instance attribute even when no method assigns to it. It can be read and assigned +without a type error, even though its type may be unknown. + +```py +class Slotted: + __slots__ = ("value",) + +reveal_type(Slotted().value) # revealed: Unknown +Slotted().value = 1 +``` + +## Slots create class descriptors + +Accessing a slot on the class returns a `MemberDescriptorType` descriptor. This descriptor is not a +`property` and does not expose property attributes. + +```py +class Slotted: + __slots__ = ("value",) + +reveal_type(Slotted.value) # revealed: MemberDescriptorType + +def accepts_property(descriptor: property) -> None: ... + +accepts_property(Slotted.value) # error: [invalid-argument-type] +Slotted.value.fget # error: [unresolved-attribute] +``` + +## Slot names appear on classes and instances + +The name of a slot is included when looking up the available attributes of its class or an instance. + +```py +from ty_extensions import static_assert +from ty_extensions._internal import has_member + +class Slotted: + __slots__ = ("value",) + +static_assert(has_member(Slotted, "value")) +static_assert(has_member(Slotted(), "value")) +``` + +## Slot descriptors can be called directly + +A slot descriptor can be assigned to its public `MemberDescriptorType` annotation. Calling its +`__get__` method directly then uses the return type declared in typeshed, even when the slot has a +more precise annotation. + +```py +from types import MemberDescriptorType + +class Slotted: + value: int + __slots__ = ("value",) + +descriptor: MemberDescriptorType = Slotted.value +reveal_type(descriptor.__get__(Slotted(), Slotted)) # revealed: Any + +# TODO: Preserve the slot annotation when its descriptor is called directly. +inferred_descriptor = Slotted.value +reveal_type(inferred_descriptor.__get__(Slotted(), Slotted)) # revealed: Any +``` + +## Class dictionaries are separate from instance dictionary slots + +An instance dictionary slot must not replace the existing namespace exposed by its class. + +```py +class WithDictionary: + __slots__ = ("value", "__dict__") + +reveal_type(WithDictionary.__dict__) # revealed: dict[str, Any] +``` + +A subclass continues to expose its own class namespace. + +```py +class SlottedChild(WithDictionary): + __slots__ = () + +reveal_type(SlottedChild.__dict__) # revealed: dict[str, Any] +``` + +The same rule applies when a class is accessed through a `type` annotation. + +```py +def inspect_class(cls: type[WithDictionary]) -> None: + reveal_type(cls.__dict__) # revealed: dict[str, Any] +``` + +## Slot assignments preserve inferred types + +Assignments to slotted attributes continue to determine their inferred types. + +```py +class Slotted: + __slots__ = ("value",) + + def __init__(self, value: int) -> None: + self.value = value + +reveal_type(Slotted(1).value) # revealed: int +``` + +## Assignments narrow slotted attributes + +Writing to an annotated slot narrows later reads, just as it does for an ordinary instance +attribute. + +```py +class Slotted: + __slots__ = ("value",) + + def __init__(self) -> None: + self.value: int | None = None + +def assign(instance: Slotted) -> int: + instance.value = 1 + reveal_type(instance.value) # revealed: Literal[1] + return instance.value +``` + +A conditional assignment also removes `None` from later reads. + +```py +def initialize(instance: Slotted) -> int: + if instance.value is None: + instance.value = 1 + return instance.value +``` + +Narrowing does not change which values can be assigned to the declared attribute. + +```py +def reject(instance: Slotted) -> None: + instance.value = "wrong" # error: [invalid-assignment] +``` + +## Assignments narrow slots annotated in class bodies + +An annotation in the class body follows the same narrowing rules as an annotation in an initializer. + +```py +class Slotted: + __slots__ = ("value",) + value: int | None + +def assign(instance: Slotted) -> int: + instance.value = 1 + return instance.value +``` + +## Assignments do not narrow arbitrary descriptors + +Unlike a slot, an arbitrary data descriptor can transform an assigned value in its setter. Later +reads therefore retain the return type of the descriptor's `__get__` method. + +```py +class TransformingDescriptor: + def __get__(self, instance: object, owner: type | None = None) -> int | None: ... + def __set__(self, instance: object, value: int) -> None: ... + +class DescriptorOwner: + __slots__ = () + value = TransformingDescriptor() + +def inspect_descriptor(owner: DescriptorOwner) -> None: + owner.value = 1 + reveal_type(owner.value) # revealed: int | None +``` + +## Annotated slots enforce their declared types + +An annotation on a slot controls both attribute reads and assignments. + +```py +class Slotted: + __slots__ = ("value",) + value: int + +reveal_type(Slotted.value) # revealed: MemberDescriptorType +reveal_type(Slotted().value) # revealed: int +Slotted().value = 1 +Slotted().value = "wrong" # error: [invalid-assignment] +``` + +## Slots declared in stub files + +A bare stub annotation describes the value stored in a runtime slot without creating a conflicting +class variable. + +```pyi +class BareAnnotation: + __slots__ = ("value",) + value: int + +reveal_type(BareAnnotation.value) # revealed: MemberDescriptorType +reveal_type(BareAnnotation().value) # revealed: int +BareAnnotation().value = 1 +BareAnnotation().value = "wrong" # error: [invalid-assignment] +``` + +An ellipsis placeholder in a stub has the same meaning and does not conflict with its slot. + +```pyi +class EllipsisAnnotation: + __slots__ = ("value",) + value: str = ... + +reveal_type(EllipsisAnnotation().value) # revealed: str +EllipsisAnnotation().value = "valid" +EllipsisAnnotation().value = 1 # error: [invalid-assignment] +``` + +## Generic slots use the instance's type arguments + +A slot in a generic class uses the type arguments of the instance. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Box(Generic[T]): + __slots__ = ("value",) + value: T + + def __init__(self, value: T) -> None: + self.value = value + +reveal_type(Box(1).value) # revealed: int +Box(1).value = "wrong" # error: [invalid-assignment] +``` + +## Slot attributes can be deleted + +Slot descriptors support deleting their stored values as well as reading and writing them. + +```py +class Slotted: + __slots__ = ("value",) + +instance = Slotted() +instance.value = 1 +del instance.value +``` + +## Supported ways to declare slots + +A single string declares one slot. + +```py +class StringSlots: + __slots__ = "value" + +reveal_type(StringSlots().value) # revealed: Unknown +``` + +A tuple can declare multiple slots. + +```py +class TupleSlots: + __slots__ = ("first", "second") + +reveal_type(TupleSlots().first) # revealed: Unknown +reveal_type(TupleSlots().second) # revealed: Unknown +``` + +A list can also provide the slot names. + +```py +class ListSlots: + __slots__ = ["value"] + +reveal_type(ListSlots().value) # revealed: Unknown +``` + +A set can provide the slot names as its elements. + +```py +class SetSlots: + __slots__ = {"value"} + +reveal_type(SetSlots().value) # revealed: Unknown +``` + +When `__slots__` is a dictionary, its keys are the slot names. + +```py +class DictionarySlots: + __slots__ = {"value": "Documentation for the slot."} + +reveal_type(DictionarySlots().value) # revealed: Unknown +``` + +## Annotated and indirect slot declarations + +An annotation on `__slots__` does not hide its runtime value. + +```py +class AnnotatedSlots: + __slots__: tuple[str, ...] = ("value",) + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] + +reveal_type(AnnotatedSlots().value) # revealed: Unknown +AnnotatedSlots().missing # error: [unresolved-attribute] +``` + +A statically known tuple can also be supplied through another variable. + +```py +slot_names = ("value",) + +class IndirectSlots: + __slots__ = slot_names + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] + +reveal_type(IndirectSlots().value) # revealed: Unknown +IndirectSlots().missing # error: [unresolved-attribute] +``` + +The elements of mutable slot declarations can also refer to statically known string values. + +```py +slot_name = "value" + +class IndirectListSlots: + __slots__ = [slot_name] + +reveal_type(IndirectListSlots().value) # revealed: Unknown +IndirectListSlots().missing # error: [unresolved-attribute] +``` + +The same inference applies to set elements. + +```py +class IndirectSetSlots: + __slots__ = {slot_name} + +reveal_type(IndirectSetSlots().value) # revealed: Unknown +IndirectSetSlots().missing # error: [unresolved-attribute] +``` + +Dictionary keys are evaluated in the same way. + +```py +class IndirectDictionarySlots: + __slots__ = {slot_name: "Documentation for the slot."} + +reveal_type(IndirectDictionarySlots().value) # revealed: Unknown +IndirectDictionarySlots().missing # error: [unresolved-attribute] +``` + +## Mutated slot declarations + +Slot names are taken from the original literal. Later changes to that literal are not evaluated, so +an appended name is not treated as an available slot. + +```py +class MutatedSlots: + __slots__ = ["value"] + # TODO: Warn that mutating the slot declaration is not supported. + __slots__.append("extra") + + def __init__(self) -> None: + self.value = 1 + self.extra = 2 # error: [unresolved-attribute] +``` + +## Dynamic slot declarations + +When the slot names cannot be determined statically, attribute writes remain permissive. + +```py +def choose_slots() -> tuple[str, ...]: + return ("value",) + +class DynamicSlots: + __slots__ = choose_slots() + + def __init__(self) -> None: + # No error on either assignment because the slot names are unknown. + self.value = 1 + self.extra = 2 + +reveal_type(DynamicSlots().extra) # revealed: int +``` + +## Inherited slots + +A slotted subclass can use slots declared by any of its base classes. + +```py +class Base: + __slots__ = ("base_value",) + +class Child(Base): + __slots__ = ("child_value",) + + def __init__(self) -> None: + self.base_value = 1 + self.child_value = 2 + +reveal_type(Child.base_value) # revealed: MemberDescriptorType +reveal_type(Child().base_value) # revealed: int +``` + +## Slots use annotations inherited from base classes + +A class with empty `__slots__` can declare an instance attribute without providing a slot for it. +The annotation alone does not make the attribute writable. + +```py +class Base: + __slots__ = () + value: int + +Base().value = 1 # error: [missing-slot] +``` + +A subclass can create the missing slot. Its inherited annotation controls both reads and writes. + +```py +class Child(Base): + __slots__ = ("value",) + +item = Child() +reveal_type(item.value) # revealed: int +item.value = 1 +item.value = "wrong" # error: [invalid-assignment] +``` + +A generic base class supplies the type chosen by its subclass. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class GenericBase(Generic[T]): + __slots__ = () + value: T + +class IntegerChild(GenericBase[int]): + __slots__ = ("value",) + +reveal_type(IntegerChild().value) # revealed: int +IntegerChild().value = "wrong" # error: [invalid-assignment] +``` + +## Subclass annotations override inherited slot types + +A subclass can narrow an inherited attribute declaration even when its storage remains in a base +class's slot. Reads and writes use the subclass's declared type, as they do without slots. + +```py +class Base: + __slots__ = ("value",) + value: int | None + +class Child(Base): + __slots__ = () + # TODO: Reject this unsafe override when mutable attribute overrides are checked. + value: int + +reveal_type(Child().value) # revealed: int +Child().value = 2 +Child().value = None # error: [invalid-assignment] +``` + +An annotation on an assignment in the subclass's initializer establishes the same narrower type. + +```py +class InitializedChild(Base): + def __init__(self) -> None: + # TODO: Reject this unsafe override when mutable attribute overrides are checked. + self.value: int = 1 + + def get(self) -> int: + return self.value + +reveal_type(InitializedChild().value) # revealed: int +InitializedChild().value = None # error: [invalid-assignment] +``` + +As with an ordinary instance attribute, an overriding annotation replaces the inherited type. + +```py +class StringChild(Base): + def __init__(self) -> None: + # TODO: Reject this unsafe override when mutable attribute overrides are checked. + self.value: str = "valid" + +reveal_type(StringChild().value) # revealed: str +StringChild().value = 1 # error: [invalid-assignment] +``` + +## Extra instance attributes require an instance dictionary + +An instance without an instance dictionary cannot create attributes outside its declared slots. + +```py +from typing import ClassVar + +class Slotted: + __slots__ = ("value",) + shared = 1 + explicit_classvar: ClassVar[int] = 2 + + def __init__(self) -> None: + self.value = 1 + self.extra = 2 # error: [unresolved-attribute] + +Slotted().other = 3 # error: [unresolved-attribute] +Slotted().shared = 3 # error: [missing-slot] +reveal_type(Slotted.value) # revealed: MemberDescriptorType +reveal_type(Slotted.shared) # revealed: int +reveal_type(Slotted.explicit_classvar) # revealed: int +Slotted.explicit_classvar = 4 +``` + +An explicit `__dict__` slot restores support for additional instance attributes. + +```py +class WithDictionary: + __slots__ = ("value", "__dict__") + + def __init__(self) -> None: + self.extra = 1 + +reveal_type(WithDictionary().value) # revealed: Unknown +reveal_type(WithDictionary().extra) # revealed: int +``` + +An ordinary base class can also supply an inherited instance dictionary. + +```py +class OrdinaryBase: + pass + +class InheritedDictionary(OrdinaryBase): + # TODO: Warn that these slots do not remove OrdinaryBase's instance dictionary. + __slots__ = ("value",) + + def __init__(self) -> None: + # No error because OrdinaryBase provides an instance dictionary. + self.extra = 1 + +reveal_type(InheritedDictionary().extra) # revealed: int +``` + +A subclass without its own `__slots__` regains an instance dictionary. + +```py +class SlottedBase: + __slots__ = ("value",) + +class OrdinaryChild(SlottedBase): + def __init__(self) -> None: + self.extra = 1 + +reveal_type(OrdinaryChild().extra) # revealed: int +``` + +## Slotted subclasses of named tuples + +A named tuple synthesizes empty `__slots__`, so a subclass with its own empty slots does not gain an +instance dictionary. An annotation alone cannot provide storage for a new attribute. + +```py +from typing import NamedTuple + +class Point(NamedTuple): + value: int + +class SlottedPoint(Point): + __slots__ = () + extra: int + +SlottedPoint(1).extra = 2 # error: [missing-slot] +``` + +Named tuples created with the functional syntax have the same empty-slot layout. + +```py +FunctionalPoint = NamedTuple("FunctionalPoint", [("value", int)]) + +class SlottedFunctionalPoint(FunctionalPoint): + __slots__ = () + extra: int + +SlottedFunctionalPoint(1).extra = 2 # error: [missing-slot] +``` + +The `collections.namedtuple` factory also creates a class without an instance dictionary. + +```py +from collections import namedtuple + +LegacyPoint = namedtuple("LegacyPoint", ["value"]) + +class SlottedLegacyPoint(LegacyPoint): + __slots__ = () + extra: int + +SlottedLegacyPoint(1).extra = 2 # error: [missing-slot] +``` + +## Dataclass-generated slots + +A dataclass with `slots=True` does not give its instances a dictionary. + +```py +from dataclasses import dataclass + +@dataclass(slots=True) +class SlottedDataclass: + value: int + +SlottedDataclass(1).extra = 1 # error: [unresolved-attribute] +``` + +Its subclasses inherit that restricted instance layout unless they introduce a dictionary. + +```py +class SlottedChild(SlottedDataclass): + __slots__ = ("other",) + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] +``` + +## Dataclass-generated slots exclude inherited slots + +A slotted dataclass creates descriptors only for fields that do not already have an inherited slot. + +```py +from dataclasses import dataclass + +@dataclass(slots=True) +class Parent: + value: int + +@dataclass(slots=True) +class Child(Parent): + other: int + +reveal_type(Child.__slots__) # revealed: tuple[Literal["other"]] +``` + +Redeclaring an inherited field does not create a second slot for that field. + +```py +@dataclass(slots=True) +class Redefined(Parent): + value: int + other: int + +reveal_type(Redefined.__slots__) # revealed: tuple[Literal["other"]] +``` + +An inherited field still needs a new slot when its original class stored the field in an instance +dictionary. + +```py +@dataclass +class UnslottedParent: + value: int + +# TODO: Warn that slots=True cannot remove the inherited instance dictionary. +@dataclass(slots=True) +class SlottedChild(UnslottedParent): + other: int + + def initialize(self) -> None: + self.extra = 1 + +reveal_type(SlottedChild.__slots__) # revealed: tuple[Literal["value"], Literal["other"]] +``` + +An ordinary slotted base also supplies storage for any matching dataclass field. + +```py +class SlottedBase: + __slots__ = ("value",) + +@dataclass(slots=True) +class SlottedChild(SlottedBase): + value: int + other: int + +reveal_type(SlottedChild.__slots__) # revealed: tuple[Literal["other"]] +``` + +## Dataclass-generated slots on Python 3.10 + +Python 3.10 includes inherited fields in generated dataclass slots. ty does not currently model this +version-specific runtime behavior and instead uses the Python 3.11-and-later behavior. + +```toml +[environment] +python-version = "3.10" +``` + +```py +from dataclasses import dataclass + +@dataclass(slots=True) +class Parent: + value: int + +@dataclass(slots=True) +class Child(Parent): + other: int + +reveal_type(Child.__slots__) # revealed: tuple[Literal["other"]] +``` + +## Slots generated by dataclass transforms + +A dataclass-like decorator can also generate slots. The resulting class has the same restricted +instance layout as an ordinary slotted dataclass. + +```py +from typing import Callable, TypeVar +from typing_extensions import dataclass_transform + +T = TypeVar("T", bound=type) + +@dataclass_transform() +def model(*, slots: bool = False) -> Callable[[T], T]: + raise NotImplementedError + +@model(slots=True) +class SlottedModel: + value: int + + def initialize(self) -> None: + self.other = 1 # error: [unresolved-attribute] +``` + +## Slotted subclasses of built-in types without instance dictionaries + +A slotted subclass of a built-in type without an instance dictionary cannot create extra attributes. + +```py +class SlottedString(str): + __slots__ = ("value",) + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] +``` + +The structural mapping bases used to describe `dict` in typeshed do not add an instance dictionary. + +```py +class SlottedDictionary(dict[str, int]): + __slots__ = () + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] +``` + +## Built-in bases with instance dictionaries + +`staticmethod` instances have instance dictionaries, so a slotted subclass can still create +additional attributes. + +```py +from typing import Any + +class SlottedStaticMethod(staticmethod[..., Any]): + __slots__ = ("value",) + + def __init__(self) -> None: + super().__init__(lambda: 1) + self.extra = 1 +``` + +`classmethod` instances also have instance dictionaries. + +```py +class SlottedClassMethod(classmethod[Any, ..., Any]): + __slots__ = ("value",) + + def __init__(self) -> None: + super().__init__(lambda cls: 1) + self.extra = 1 +``` + +## Instance dictionaries inherited from standard-library bases + +A standard-library class with slots declared in typeshed does not supply an instance dictionary. A +slotted subclass inherits that restricted layout. + +```py +from pathlib import Path + +class SlottedPath(Path): + __slots__ = () + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] +``` + +An ordinary standard-library class without a slot declaration supplies an instance dictionary even +when its subclass declares slots. + +```py +from collections import Counter + +class SlottedCounter(Counter[str]): + __slots__ = () + + def initialize(self) -> None: + self.extra = 1 +``` + +## Interpreter-managed classes without instance dictionaries + +Some classes implemented by the interpreter have restricted instance layouts that are not expressed +through `__slots__` in typeshed. + +```py +from types import GenericAlias + +class SlottedAlias(GenericAlias): + __slots__ = () + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] +``` + +## Descriptor setters do not require instance dictionaries + +A data descriptor can accept assignments even when its owning instance has no instance dictionary. + +```py +from typing import Any + +class Descriptor: + def __set__(self, instance: object, value: int) -> None: ... + +class SlottedDescriptor: + __slots__ = () + value = Descriptor() + +SlottedDescriptor().value = 1 +SlottedDescriptor().value = "wrong" # error: [invalid-assignment] +``` + +Annotating a descriptor as `Any` does not hide the setter defined by the actual descriptor. + +```py +class AnnotatedDescriptor: + __slots__ = () + value: Any = Descriptor() + +AnnotatedDescriptor().value = 1 +``` + +## Custom attribute setters do not require instance dictionaries + +A custom `__setattr__` method can decide how assignments are handled even when its instances have no +instance dictionaries. + +```py +class CustomSetter: + __slots__ = () + shared = 1 + + def __setattr__(self, name: str, value: int) -> None: ... + +CustomSetter().shared = 1 +``` + +## Instance dictionaries and inherited annotations + +Typeshed declares `__dict__` on `object`. As an intentional limitation, the attribute therefore +remains available through ordinary attribute lookup even when accessing it would raise an +`AttributeError` at runtime. + +```py +class Slotted: + __slots__ = ("value",) + +reveal_type(Slotted().__dict__) # revealed: dict[str, Any] +reveal_type(Slotted.__dict__) # revealed: dict[str, Any] +``` + +An unslotted subclass can introduce an instance dictionary, so methods on a slotted base may access +the dictionary after checking whether it exists. + +```py +from typing import Any + +class SlottedBase: + __slots__ = () + + def attributes(self) -> dict[str, Any]: + if hasattr(self, "__dict__"): + return self.__dict__ + return {} + +class OrdinaryChild(SlottedBase): + pass + +reveal_type(OrdinaryChild().__dict__) # revealed: dict[str, Any] +``` + +## Weak-reference slots create descriptors + +A slotted instance does not expose `__weakref__` unless the slot is explicitly declared. + +```py +class Slotted: + __slots__ = ("value",) + +Slotted().__weakref__ # error: [unresolved-attribute] +``` + +An explicit `__weakref__` slot permits reads on the class and its instances. The typeshed descriptor +returns `Any` for both forms of access. + +```py +class WithWeakReference: + __slots__ = ("value", "__weakref__") + +reveal_type(WithWeakReference.__weakref__) # revealed: Any +reveal_type(WithWeakReference().__weakref__) # revealed: Any +``` + +The typeshed descriptor permits writing and deleting, so the runtime restriction on weak-reference +storage is not modeled. + +```py +# Both operations fail at runtime but are not currently rejected. +WithWeakReference().__weakref__ = None +del WithWeakReference().__weakref__ +``` + +## A property named `__dict__` does not provide instance storage + +A slotted class can expose a property named `__dict__` without acquiring ordinary instance +dictionary storage. + +```py +class VirtualDictionary: + __slots__ = () + + @property + def __dict__(self) -> dict[str, int]: + return {"virtual": 1} + + def initialize(self) -> None: + self.extra = 1 # error: [unresolved-attribute] + +reveal_type(VirtualDictionary().__dict__) # revealed: dict[str, int] +``` + +## Weak-reference storage inherited from ordinary bases + +Ordinary classes provide weak-reference storage at runtime, but their implicit `__weakref__` +attributes are not currently modeled. The same limitation applies to slotted subclasses. + +```toml +[environment] +python-version = "3.11" +``` + +```py +class OrdinaryBase: + pass + +class SlottedChild(OrdinaryBase): + __slots__ = ("value",) + +OrdinaryBase().__weakref__ # error: [unresolved-attribute] +SlottedChild().__weakref__ # error: [unresolved-attribute] +``` + +Without modeling that inherited storage, a slotted dataclass also includes a requested +weak-reference slot even though the ordinary base already provides it at runtime. + +```py +from dataclasses import dataclass + +@dataclass(slots=True, weakref_slot=True) +class SlottedDataclass(OrdinaryBase): + value: int + +reveal_type(SlottedDataclass.__slots__) # revealed: tuple[Literal["value"], Literal["__weakref__"]] +``` + +## Class-body annotations do not require instance storage + +A bare annotation does not require an instance slot because a subclass may supply the storage. The +annotation does not make the attribute writable without a slot. + +```py +class Slotted: + __slots__ = ("value",) + value: int + missing: int + +Slotted().missing = 1 # error: [missing-slot] +``` + +A subclass can provide the missing slot and use the inherited annotation. + +```py +class Child(Slotted): + __slots__ = ("missing",) + +reveal_type(Child().missing) # revealed: int +Child().missing = 1 +``` + +## Class attributes cannot have the same name as a slot + +Assigning to a slot name in the class body prevents Python from creating the class. + +```py +class Conflicting: + __slots__ = ("value",) + value = 1 # error: [invalid-assignment] +``` + +A method with the same name also occupies the final class namespace and conflicts with the slot. + +```py +class ConflictingMethod: + __slots__ = ("value",) + + def value(self) -> None: # error: [invalid-assignment] + pass +``` + +A temporary class variable that is deleted before the class is created does not conflict. + +```py +class DeletedDefault: + __slots__ = ("value",) + value = 1 + del value +``` + +Class assignments inside `TYPE_CHECKING` blocks do not execute and therefore cannot conflict with +runtime slot descriptors. Pydantic uses this pattern for slotted attributes. + +```py +from typing import TYPE_CHECKING, ClassVar + +class TypeCheckingOnly: + __slots__ = ("value",) + + if TYPE_CHECKING: + value: ClassVar[int] = 1 +``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/byte_literals.md b/crates/ty_python_semantic/resources/mdtest/comparison/byte_literals.md index 2a76e5b227..808f8ba034 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/byte_literals.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/byte_literals.md @@ -1,5 +1,7 @@ # Comparison: Byte literals +## Literal comparisons + These tests assert that we infer precise `Literal` types for comparisons between objects inferred as having `Literal` bytes types: @@ -41,3 +43,18 @@ reveal_type(b"abc" is b"ab") # revealed: Literal[False] reveal_type(b"abc" is not b"abc") # revealed: bool reveal_type(b"abc" is not b"ab") # revealed: Literal[True] ``` + +## Equality with sequences + +A `Sequence[int]` can be a `bytes` object, including an empty one, so comparing it with a bytes +literal has an unknown result: + +```py +from collections.abc import Sequence + +def _(value: Sequence[int]): + reveal_type(value == b"") # revealed: bool + reveal_type(b"" == value) # revealed: bool + reveal_type(value != b"") # revealed: bool + reveal_type(b"" != value) # revealed: bool +``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/strings.md b/crates/ty_python_semantic/resources/mdtest/comparison/strings.md index ed543de401..deaf322b20 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/strings.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/strings.md @@ -37,3 +37,30 @@ def _(value: LiteralString): reveal_type(value == "") # revealed: bool reveal_type(value != "") # revealed: bool ``` + +## Equality with sequences + +A `Sequence[str]` can be a string, including the empty string, so comparing it with a string literal +has an unknown result: + +```py +from collections.abc import Sequence + +def _(value: Sequence[str]): + reveal_type(value == "") # revealed: bool + reveal_type("" == value) # revealed: bool + reveal_type(value != "") # revealed: bool + reveal_type("" != value) # revealed: bool +``` + +The result is also unknown when the string's precise literal value is not known: + +```py +from typing_extensions import LiteralString + +def _(value: Sequence[str], other: LiteralString): + reveal_type(value == other) # revealed: bool + reveal_type(other == value) # revealed: bool + reveal_type(value != other) # revealed: bool + reveal_type(other != value) # revealed: bool +``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md b/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md index 5196cbfba9..48f658273f 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md @@ -543,6 +543,69 @@ def _( reveal_type(prefix_int_var_int < prefix_int_var_bool) # revealed: bool ``` +## Equality with sequences + +A `Sequence[object]` can be an empty or nonempty tuple. Both equality and inequality comparisons +therefore have unknown results: + +```py +from collections.abc import Sequence + +def _(value: Sequence[object]): + reveal_type(value == ()) # revealed: bool + reveal_type(() == value) # revealed: bool + reveal_type(value != ()) # revealed: bool + reveal_type(() != value) # revealed: bool + + reveal_type(value == (1,)) # revealed: bool +``` + +The operand types can overlap without either being a subtype of the other. For example, `(1,)` +inhabits both `Sequence[int]` and `tuple[int | str]`, so these comparisons also have unknown +results: + +```py +def _(value: Sequence[int], other: tuple[int | str]): + reveal_type(value == other) # revealed: bool + reveal_type(other == value) # revealed: bool +``` + +## Equality with tuple subclasses + +A `Base` value can be a `Child` instance, so comparing them has an unknown result despite their +different inherited equality methods: + +```py +class Base: ... +class Child(tuple[int, ...], Base): ... + +def _(value: Base, child: Child): + reveal_type(value == child) # revealed: bool + reveal_type(child == value) # revealed: bool +``` + +For unrelated operand classes, the default equality semantics still ignore possible subclasses with +different equality methods: + +```py +def _(value: Base, other: tuple[int, ...]): + reveal_type(value == other) # revealed: Literal[False] +``` + +## Comparisons with truthy strings + +A nonempty string cannot compare equal to an integer. Truthiness narrowing preserves this result +when equality is used for tuple comparisons or membership tests: + +```py +def _(text: str): + if text: + reveal_type((1,) == (text,)) # revealed: Literal[False] + reveal_type((text,) == (1,)) # revealed: Literal[False] + reveal_type(text in (1,)) # revealed: Literal[False] + reveal_type(1 in (text,)) # revealed: Literal[False] +``` + ## Chained comparisons with elements that incorrectly implement `__bool__` diff --git a/crates/ty_python_semantic/resources/mdtest/conditional/if_expression.md b/crates/ty_python_semantic/resources/mdtest/conditional/if_expression.md index 082e0d43db..fba82cbd9e 100644 --- a/crates/ty_python_semantic/resources/mdtest/conditional/if_expression.md +++ b/crates/ty_python_semantic/resources/mdtest/conditional/if_expression.md @@ -36,6 +36,74 @@ def _(flag: bool): reveal_type(x) # revealed: Literal[1] | None ``` +## Statically known compound conditions + +Short-circuit conditions can select a single branch even when an operand has mutable truthiness. +Saving the condition's value and testing it again does not provide the same guarantee. + +```py +def _(value: object): + reveal_type(1 if value and False else 2) # revealed: Literal[2] + reveal_type(1 if value or True else 2) # revealed: Literal[1] + + saved = value and False + reveal_type(1 if saved else 2) # revealed: Literal[1, 2] +``` + +A comparison chain can select a single branch even when an individual comparison returns an +arbitrary object. Saving the chain's result allows that object's truthiness to be tested again. + +```py +class Comparable: + def __lt__(self, other: int) -> object: + return object() + +def _(value: Comparable): + reveal_type(1 if value < 1 < 0 else 2) # revealed: Literal[2] + + saved = value < 1 < 0 + reveal_type(1 if saved else 2) # revealed: Literal[1, 2] +``` + +An operand narrowed to `Never` cannot produce a result. Nested conditions preserve the remaining +short-circuit outcome when selecting a branch. + +```py +def _(other: object, value: bool): + reveal_type(1 if other and (isinstance(value, str) and value) else 2) # revealed: Literal[2] + reveal_type(1 if other or (not isinstance(value, str) or value) else 2) # revealed: Literal[1] +``` + +## Conditions with operands equivalent to `Never` + +A call whose return type is an alias of `Never` cannot produce a result. The preceding short-circuit +outcome alone selects the conditional expression's branch. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Never + +type Bottom = Never + +def stop() -> Bottom: + raise RuntimeError + +def _(flag: bool): + reveal_type(1 if flag and stop() else 2) # revealed: Literal[2] + reveal_type(1 if flag or stop() else 2) # revealed: Literal[1] +``` + +A type variable bounded by `Never` also cannot produce a result. + +```py +def _[T: Never](flag: bool, value: T): + reveal_type(1 if flag and value else 2) # revealed: Literal[2] +``` + ## Condition with object that implements `__bool__` incorrectly ```py diff --git a/crates/ty_python_semantic/resources/mdtest/conditional/match.md b/crates/ty_python_semantic/resources/mdtest/conditional/match.md index 0d49cee0ff..5f55067f30 100644 --- a/crates/ty_python_semantic/resources/mdtest/conditional/match.md +++ b/crates/ty_python_semantic/resources/mdtest/conditional/match.md @@ -567,8 +567,8 @@ the subject after that pattern succeeds. Value patterns use `==`, and `as` binds the original subject rather than the value written in the pattern. Broad builtin types are treated as if they use builtin equality, so matching `1` narrows -`x` to the integer and boolean literals that compare equal to it. After that pattern fails, matching -`"foo"` narrows `x` to that string literal. +`x` to that integer literal without adding the boolean literal that compares equal to it. After that +pattern fails, matching `"foo"` narrows `x` to that string literal. ```py def _(target: int | str): @@ -577,7 +577,7 @@ def _(target: int | str): match target: case 1 as x: y = 2 - reveal_type(x) # revealed: Literal[1, True] + reveal_type(x) # revealed: Literal[1] case "foo" as x: y = 3 reveal_type(x) # revealed: Literal["foo"] diff --git a/crates/ty_python_semantic/resources/mdtest/cycle.md b/crates/ty_python_semantic/resources/mdtest/cycle/basic.md similarity index 85% rename from crates/ty_python_semantic/resources/mdtest/cycle.md rename to crates/ty_python_semantic/resources/mdtest/cycle/basic.md index 65e2bc8d36..9555f76883 100644 --- a/crates/ty_python_semantic/resources/mdtest/cycle.md +++ b/crates/ty_python_semantic/resources/mdtest/cycle/basic.md @@ -1,35 +1,39 @@ # Cycles -## Function signature +## Recursive lambda in a loop condition -Deferred annotations can result in cycles in resolving a function signature: +A lambda is always truthy. Determining whether the final assignment is reachable must not require +inferring the lambda's return type, which depends on that same assignment. ```py -from __future__ import annotations - -# error: [invalid-type-form] -def f(x: f): +(f := lambda: f) +while lambda: f: pass +f = 0 +``` -reveal_type(f) # revealed: def f(x: Unknown) +## Recursive lambda in a conditional + +The same cycle can arise when a conditional filters the bindings visible to a recursive lambda. + +```py +f = lambda: f +if not (lambda: f): + f = 0 ``` -## Unpacking +## Function signature -See: +Deferred annotations can result in cycles in resolving a function signature: ```py -class Point: - def __init__(self, x: int = 0, y: int = 0) -> None: - self.x = x - self.y = y +from __future__ import annotations - def replace_with(self, other: "Point") -> None: - self.x, self.y = other.x, other.y +# error: [invalid-type-form] +def f(x: f): + pass -p = Point() -reveal_type(p.x) # revealed: int -reveal_type(p.y) # revealed: int +reveal_type(f) # revealed: def f(x: Unknown) ``` ## Unpacking a recursively growing tuple @@ -230,10 +234,10 @@ class D: ### Lambdas -all four show the default one layer deeper than the parameter it is the default of. the two -positional ones used to stop a layer earlier, because the expected type carried into the lambda -folded them back onto the marker — a context holding the cycle's own marker no longer earns a query -key of its own, so they now read the same way the keyword-only two always have: +all four stop at the same depth: the parameter is a divergent marker, and the default it stands for +is that marker again rather than another layer of the lambda. the two positional ones used to go a +layer deeper than the keyword-only two, which said nothing about the program and only recorded which +of them had earned a query key of its own: ```py class C: @@ -243,59 +247,140 @@ class C: self.c = lambda positional_only=self.c, /: positional_only self.d = lambda *, kw_only=self.d: kw_only - # revealed: (positional: (positional: Divergent = ...) -> Divergent = ...) -> Divergent + # revealed: (positional: Divergent = ...) -> Divergent reveal_type(self.a) - # revealed: (*, kw_only: (*, kw_only: Divergent = ...) -> Divergent = ...) -> Divergent + # revealed: (*, kw_only: Divergent = ...) -> Divergent reveal_type(self.b) - # revealed: (positional_only: (positional_only: Divergent = ..., /) -> Divergent = ..., /) -> Divergent + # revealed: (positional_only: Divergent = ..., /) -> Divergent reveal_type(self.c) - # revealed: (*, kw_only: (*, kw_only: Divergent = ...) -> Divergent = ...) -> Divergent + # revealed: (*, kw_only: Divergent = ...) -> Divergent reveal_type(self.d) ``` -## Self-referential implicit attributes +### Self-referential decorated functions + +Resolving a decorated function's callable signature must not eagerly infer its default values. +Otherwise, a default that refers back to the decorated name can re-enter the reachability check for +an earlier assertion and prevent inference from converging. This is a regression test for +. ```py -class Cyclic: - def __init__(self, data: str | dict): # error: [missing-type-argument] - self.data = data +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" - def update(self): - if isinstance(self.data, str): - self.data = {"url": self.data} +@property +def f(x=lambda: f): ... +``` -# revealed: str | dict[Unknown, Unknown] | dict[str, str] -reveal_type(Cyclic("").data) +The same cycle must converge when the parameter and return type are annotated: + +```py +g = lambda: g +assert g # error: [redundant-condition] "This condition is always true" + +@property +def g(x: object = lambda: g) -> None: ... ``` -## Cycle normalization preserves non-gradual variadic parameters +### Diagnostics for self-referential decorated functions -Normalizing a recursive implicit-attribute type does not reinterpret specialized variadic parameters -as gradual: +We reject a decorator that expects an integer instead of a function. Displaying the function's +signature in that diagnostic can infer its self-referential default value. We report the error after +function inference finishes, so diagnostic formatting does not create a cycle through the +reachability check for the earlier assertion. This is a regression test for +. ```py -from typing import Any, Callable, Generic, TypeVar -from ty_extensions import static_assert -from ty_extensions._internal import TypeOf, is_subtype_of +def decorator(value: int) -> int: + return value -T = TypeVar("T") -flag: bool +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" + +# error: [invalid-argument-type] "Expected `int`, found `def f(x: some () -> int = ...)`" +@decorator +def f(x=lambda: f): ... +``` + +### Self-referential property construction + +Constructing a property explicitly has the same behavior as decorator syntax: + +```py +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" + +def getter(x=lambda: f): ... + +f = property(getter) +``` + +### Self-referential callable decorators + +The cycle is not specific to properties. A decorator that returns a callable with a fixed signature +must also terminate: + +```py +from collections.abc import Callable +from typing import Any + +def decorator(fn: Callable[[Any], Any]) -> Callable[[Any], Any]: + return fn + +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" + +@decorator +def f(x=lambda: f): ... +``` + +### Self-referential ParamSpec decorators + +A decorator can capture a function's parameters and return a callable with a different signature. +Capturing those parameters must not evaluate a self-referential default. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from collections.abc import Callable -class C(Generic[T]): - def method(self, *args: T, **kwargs: T) -> None: ... +def decorator[**P](fn: Callable[P, None]) -> Callable[[], None]: + return lambda: None -c = C[Any]() +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" -class Recursive: - def __init__(self, other: "Recursive"): - self.callback = c.method if flag else other.callback +@decorator +def f(x=lambda: f) -> None: ... + +reveal_type(f) # revealed: () -> None +``` -def check(value: Recursive): - reveal_type(value.callback) # revealed: bound method C[Any].method(*args: Any, **kwargs: Any) - static_assert(is_subtype_of(TypeOf[value.callback], Callable[[], None])) +### Self-referential generic properties + +A generic getter's annotations are inferred in its type-parameter scope. Constructing the property +must not pull its self-referential default into that inference. + +```toml +[environment] +python-version = "3.12" +``` + +```py +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" + +@property +def f[T](value: T, callback=lambda: f) -> T: + return value + +reveal_type(f) # revealed: property ``` ## Decorated methods with implicit class attributes @@ -372,24 +457,6 @@ min(Y) # error: [invalid-argument-type] T = f() ``` -## Lazy cached property behind `hasattr` - -This pattern used to panic with "too many cycle iterations". - -```py -class Cached: - def get(self) -> int: - return 0 - - @property - def metadata(self) -> int: - if not hasattr(self, "_metadata"): - self._metadata = self.get() - return self._metadata - -reveal_type(Cached().metadata) # revealed: int -``` - ## Decorator defined on a base class with constrained typevars, accessed from a subclass with decorated generic parameters This example was minimized from @@ -713,6 +780,11 @@ against `list[Divergent]` learned `Never ≤ T` and the marker was gone before a reading the same parameter off the argument's own bases keeps it, which is why the identical constructor declared `list[T]` always settled. +that structural read only covers a protocol the argument's own bases name. `iter` and `next` are +declared over `SupportsIter` and `SupportsNext`, which `set` satisfies without naming, so the hop +out of the container goes back through the constraint set and loses the marker again. the display +form settles because it never takes that hop. + ```py def through_set(n: int): if n: @@ -720,7 +792,7 @@ def through_set(n: int): t = set([through_set(n)]) return "b" + next(iter(t)) -reveal_type(through_set(1)) # revealed: str +reveal_type(through_set(1)) # revealed: str | Unknown def through_frozenset(n: int): if n: @@ -728,7 +800,7 @@ def through_frozenset(n: int): t = frozenset([through_frozenset(n)]) return "b" + next(iter(t)) -reveal_type(through_frozenset(1)) # revealed: str +reveal_type(through_frozenset(1)) # revealed: str | Unknown def through_list(n: int): if n: @@ -736,7 +808,7 @@ def through_list(n: int): t = list([through_list(n)]) return "b" + next(iter(t)) -reveal_type(through_list(1)) # revealed: str +reveal_type(through_list(1)) # revealed: str | Unknown ``` nothing about this is particular to the containers typeshed ships. a class of one's own taking an @@ -782,6 +854,9 @@ literal the binding was built from — a query of its own, which the return type first rounds never revisits — so every later round reads it back and the recursion settles on `str | Unknown`. +reading the marker back off the container is the same hop as above, and loses it for the same +reason: `__iter__` is reached through `SupportsIter`, which `list` satisfies without naming. + ```py def through_dunder_iter(n: int): if n: @@ -789,7 +864,7 @@ def through_dunder_iter(n: int): t = list([through_dunder_iter(n)]) return "b" + next(t.__iter__()) -reveal_type(through_dunder_iter(1)) # revealed: str +reveal_type(through_dunder_iter(1)) # revealed: str | Unknown def through_second_container(n: int): if n: @@ -797,7 +872,7 @@ def through_second_container(n: int): t = list([through_second_container(n)]) return "b" + list(t)[0] -reveal_type(through_second_container(1)) # revealed: str +reveal_type(through_second_container(1)) # revealed: str | Unknown ``` a marker is what the answer is *not yet*, so a call on one answers with the marker. a genuinely @@ -1264,3 +1339,52 @@ recovered from. The class the existing type is already carrying answers it witho reveal_type("a".upper()) # revealed: LiteralString reveal_type([1, 2, 3]) # revealed: list[int] ``` + +## Known class instances with a shadowed typing module + +String members retain their types when a local `typing.py` introduces an inference cycle. Resolving +`str`'s bases looks up `Sequence` in that module. Determining whether the assignment is reachable +requires inferring `trigger`'s return annotation. Resolving `C.attribute` requires determining `C`'s +metaclass. Checking a call to that unknown metaclass constructs a class namespace with `str` keys, +completing the cycle. The consumer is checked before the shadowing module. + +This is a regression test for . + +`m.py`: + +```py +reveal_type("a".encode()) # revealed: bytes +``` + +`typing.py`: + +```py +class C(metaclass=missing): ... # error: [unresolved-reference] + +def trigger() -> C.attribute: ... + +trigger() +Sequence = object +``` + +## Known class instances after checking the shadowing module + +String members retain their types when the shadowing module is checked first, as they do when the +consumer is checked first. + +`typing.py`: + +```py +class C(metaclass=missing): ... # error: [unresolved-reference] + +def trigger() -> C.attribute: ... + +trigger() +Sequence = object +``` + +`m.py`: + +```py +reveal_type("a".encode()) # revealed: bytes +``` diff --git a/crates/ty_python_semantic/resources/mdtest/cycle/implicit_instance_attributes.md b/crates/ty_python_semantic/resources/mdtest/cycle/implicit_instance_attributes.md new file mode 100644 index 0000000000..2935b1679a --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/cycle/implicit_instance_attributes.md @@ -0,0 +1,532 @@ +# Cycles in implicit instance attributes + +## Unpacking + +See: + +```py +class Point: + def __init__(self, x: int = 0, y: int = 0) -> None: + self.x = x + self.y = y + + def replace_with(self, other: "Point") -> None: + self.x, self.y = other.x, other.y + +p = Point() +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +``` + +## Self-referential implicit attributes + +```py +class Cyclic: + def __init__(self, data: str | dict): # error: [missing-type-argument] + self.data = data + + def update(self): + if isinstance(self.data, str): + self.data = {"url": self.data} + +# revealed: str | dict[Unknown, Unknown] | dict[str, str] +reveal_type(Cyclic("").data) +``` + +## Cycle normalization preserves non-gradual variadic parameters + +Normalizing a recursive implicit-attribute type does not reinterpret specialized variadic parameters +as gradual: + +```py +from typing import Any, Callable, Generic, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_subtype_of + +T = TypeVar("T") +flag: bool + +class C(Generic[T]): + def method(self, *args: T, **kwargs: T) -> None: ... + +c = C[Any]() + +class Recursive: + def __init__(self, other: "Recursive"): + self.callback = c.method if flag else other.callback + +def check(value: Recursive): + reveal_type(value.callback) # revealed: bound method C[Any].method(*args: Any, **kwargs: Any) + static_assert(is_subtype_of(TypeOf[value.callback], Callable[[], None])) +``` + +## Guarded instance attributes when the base is checked first + +A guarded bound-method initializer remains valid, including when its receiver is explicitly +annotated, while another initializer still reports an attribute that is missing from the base class. +Calling the initialized method returns `str`. This reproduces +. + +`base.py`: + +```py +class Base: + def __init__(self): + if not hasattr(self, "x"): + self.x = self.__str__ + if not hasattr(self, "z"): + self.z = self.y # error: [unresolved-attribute] + +reveal_type(Base().x()) # revealed: str + +class Annotated: + def __init__(self: "Annotated"): + if not hasattr(self, "value"): + self.value = self.__str__ + self.missing # error: [unresolved-attribute] +``` + +`child.py`: + +```py +from base import Annotated, Base + +class Child(Base): + x = Base.__str__ + + def z(self): ... + def y(self): ... + +class AnnotatedChild(Annotated): + value = Annotated.__str__ +``` + +## Guarded instance attributes when the subclass is checked first + +Checking the subclass first preserves the valid initializer, its inferred return type, and the +missing-attribute diagnostic. + +`child.py`: + +```py +from base import Annotated, Base + +class Child(Base): + x = Base.__str__ + + def z(self): ... + def y(self): ... + +class AnnotatedChild(Annotated): + value = Annotated.__str__ +``` + +`base.py`: + +```py +class Base: + def __init__(self): + if not hasattr(self, "x"): + self.x = self.__str__ + if not hasattr(self, "z"): + self.z = self.y # error: [unresolved-attribute] + +reveal_type(Base().x()) # revealed: str + +class Annotated: + def __init__(self: "Annotated"): + if not hasattr(self, "value"): + self.value = self.__str__ + self.missing # error: [unresolved-attribute] +``` + +## Named protocol guards when the base is checked first + +Here, a runtime-checkable protocol with a read-only `x` property checks the same member presence as +`hasattr(self, "x")`. Whether the protocol is named does not change the initializer's reachability. +The initialized instance also satisfies the protocol outside the initializer. + +`base.py`: + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class HasX(Protocol): + @property + def x(self) -> object: ... + +class Base: + def __init__(self): + if not isinstance(self, HasX): + self.x = self.__str__ + self.missing # error: [unresolved-attribute] + +def accepts_x(value: HasX) -> None: ... + +accepts_x(Base()) +reveal_type(Base().x()) # revealed: str +``` + +`child.py`: + +```py +from base import Base + +class Child(Base): + x = Base.__str__ +``` + +## Named protocol guards when the subclass is checked first + +Checking the subclass first preserves the reachable initializer and its missing-attribute error. + +`child.py`: + +```py +from base import Base + +class Child(Base): + x = Base.__str__ +``` + +`base.py`: + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class HasX(Protocol): + @property + def x(self) -> object: ... + +class Base: + def __init__(self): + if not isinstance(self, HasX): + self.x = self.__str__ + self.missing # error: [unresolved-attribute] + +def accepts_x(value: HasX) -> None: ... + +accepts_x(Base()) +reveal_type(Base().x()) # revealed: str +``` + +## Nested and compound attribute guards when the base is checked first + +An unrelated condition can appear outside an attribute guard, inside it, or on either side of a +compound condition without making a guarded initializer invalid. Previous narrowing of the receiver +must also preserve real diagnostics in the guarded branch. + +`base.py`: + +```py +class Marker: ... + +class Base: + def __init__(self, enabled: bool): + if enabled: + if not hasattr(self, "outer"): + self.outer = self.__str__ + if not hasattr(self, "inner"): + if enabled: + self.inner = self.__str__ + if enabled and not hasattr(self, "leading"): + self.leading = self.__str__ + if not hasattr(self, "trailing") and enabled: + self.trailing = self.__str__ + if self is not None: + if not hasattr(self, "nonnull"): + self.nonnull = self.__str__ + self.nonnull_missing # error: [unresolved-attribute] + if not hasattr(self, "other"): + if not hasattr(self, "unrelated"): + self.unrelated = self.__str__ + self.unrelated_missing # error: [unresolved-attribute] + if isinstance(self, Marker): + if not hasattr(self, "narrowed"): + self.narrowed = self.__str__ + self.narrowed_missing # error: [unresolved-attribute] +``` + +`child.py`: + +```py +from base import Base, Marker + +class Child(Base, Marker): + outer = Base.__str__ + inner = Base.__str__ + leading = Base.__str__ + trailing = Base.__str__ + nonnull = Base.__str__ + unrelated = Base.__str__ + narrowed = Base.__str__ +``` + +## Nested and compound attribute guards when the subclass is checked first + +Checking the subclass first must preserve the same nested and compound guarded initializers. + +`child.py`: + +```py +from base import Base, Marker + +class Child(Base, Marker): + outer = Base.__str__ + inner = Base.__str__ + leading = Base.__str__ + trailing = Base.__str__ + nonnull = Base.__str__ + unrelated = Base.__str__ + narrowed = Base.__str__ +``` + +`base.py`: + +```py +class Marker: ... + +class Base: + def __init__(self, enabled: bool): + if enabled: + if not hasattr(self, "outer"): + self.outer = self.__str__ + if not hasattr(self, "inner"): + if enabled: + self.inner = self.__str__ + if enabled and not hasattr(self, "leading"): + self.leading = self.__str__ + if not hasattr(self, "trailing") and enabled: + self.trailing = self.__str__ + if self is not None: + if not hasattr(self, "nonnull"): + self.nonnull = self.__str__ + self.nonnull_missing # error: [unresolved-attribute] + if not hasattr(self, "other"): + if not hasattr(self, "unrelated"): + self.unrelated = self.__str__ + self.unrelated_missing # error: [unresolved-attribute] + if isinstance(self, Marker): + if not hasattr(self, "narrowed"): + self.narrowed = self.__str__ + self.narrowed_missing # error: [unresolved-attribute] +``` + +## Class attributes independently establish presence + +A class attribute is present before an initializer runs, including when it is inherited. Assigning +to the same name inside a negative guard does not make that branch reachable. This applies to both +`hasattr` and named protocols with a read-only `object` property. + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class HasX(Protocol): + @property + def x(self) -> object: ... + +class Base: + x = 1 + + def __init__(self): + if not hasattr(self, "x"): + self.x = self.__str__ + self.missing + if not isinstance(self, HasX): + self.x = self.__str__ + self.missing + +class Child(Base): + def initialize(self): + if not hasattr(self, "x"): + self.x = self.__str__ + self.missing +``` + +## Class attributes establish presence through aliased protocol members + +A read-only protocol property typed as an alias of `object` imposes the same presence requirement as +`object` itself. The class attribute makes the negative guard unreachable, even when that branch +assigns to the same attribute. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, runtime_checkable + +type Top = object + +@runtime_checkable +class HasX(Protocol): + @property + def x(self) -> Top: ... + +class C: + x = 1 + + def __init__(self): + if not isinstance(self, HasX): + self.x = self.__str__ + self.missing +``` + +## Unreachable and deleted class attributes do not prevent guarded initialization + +A class attribute that was never assigned or was deleted cannot make a later instance initializer +unreachable. + +`base.py`: + +```py +class Base: + if False: + unreachable = 1 + + deleted = 1 + del deleted + + def __init__(self): + if not hasattr(self, "unreachable"): + self.unreachable = self.__str__ + if not hasattr(self, "deleted"): + self.deleted = self.__str__ +``` + +`child.py`: + +```py +from base import Base + +class Child(Base): + unreachable = Base.__str__ + deleted = Base.__str__ +``` + +## Guarded instance attributes after a call when the base is checked first + +A call before a guarded initializer must not make its validity or later diagnostics depend on file +order. + +`base.py`: + +```py +def prepare() -> None: ... + +class Base: + def __init__(self): + if not hasattr(self, "x"): + prepare() + self.x = self.__str__ + self.missing # error: [unresolved-attribute] +``` + +`child.py`: + +```py +from base import Base + +class Child(Base): + x = Base.__str__ +``` + +## Guarded instance attributes after a call when the subclass is checked first + +Checking the subclass first must preserve the same guarded assignment and genuine missing-attribute +diagnostic after the intervening call. + +`child.py`: + +```py +from base import Base + +class Child(Base): + x = Base.__str__ +``` + +`base.py`: + +```py +def prepare() -> None: ... + +class Base: + def __init__(self): + if not hasattr(self, "x"): + prepare() + self.x = self.__str__ + self.missing # error: [unresolved-attribute] +``` + +## Non-returning initializers do not define instance attributes + +An assignment whose initializer never returns cannot make its target attribute present. + +```py +from typing import NoReturn + +def fail() -> NoReturn: + raise RuntimeError + +class C: + def initialize(self): + if not hasattr(self, "x"): + self.x = fail() # error: [invalid-assignment] + +C().x # error: [unresolved-attribute] +``` + +## Assignments in the opposite guard branch do not initialize an attribute + +Assigning an existing attribute when `hasattr` succeeds does not initialize it in the opposite +branch. That branch remains unreachable and cannot create another instance attribute. + +```py +class C: + def __init__(self): + self.x = 1 + + def update(self): + if hasattr(self, "x"): + self.x = 2 + else: + self.y = self.missing + +C().y # error: [unresolved-attribute] +``` + +## Contradictory attribute guards do not initialize an attribute + +An impossible inner `hasattr` branch cannot create an instance attribute. + +```py +class C: + def initialize(self): + if hasattr(self, "x"): + if not hasattr(self, "x"): + self.x = self.missing + +C().x # error: [unresolved-attribute] +``` + +## Lazy cached property behind `hasattr` + +This pattern used to panic with "too many cycle iterations". + +```py +class Cached: + def get(self) -> int: + return 0 + + @property + def metadata(self) -> int: + if not hasattr(self, "_metadata"): + self._metadata = self.get() + return self._metadata + +reveal_type(Cached().metadata) # revealed: int +``` diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index 0434c3bbe5..c6431f0b1c 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -714,6 +714,28 @@ class InvalidNonFrozenChild(FrozenParent, frozen=False): y: int ``` +#### Repeated explicit metaclasses + +A class that explicitly repeats its base's dataclass-transform metaclass is neither frozen nor +non-frozen. The base can be defined by a class statement or created dynamically. + +```py +from typing import dataclass_transform + +@dataclass_transform(frozen_default=True) +class FrozenMeta(type): + def __new__(cls, name, bases, namespace, *, frozen: bool = True): ... + +class Root(metaclass=FrozenMeta): ... +class StaticRoot(Root, metaclass=FrozenMeta): ... +class StaticMutable(StaticRoot, frozen=False): ... + +Dynamic = type("Dynamic", (Root,), {}) + +class DynamicRoot(Dynamic, metaclass=FrozenMeta): ... +class DynamicMutable(DynamicRoot, frozen=False): ... +``` + #### Using base-class-based transformers Similarly, for base-class-based transformers, the class that is decorated with @@ -1411,6 +1433,45 @@ class Outer: Field ordering checks apply to classes created via `dataclass_transform`, just like normal `dataclass`es. +### Required fields inherited from stub models + +An annotation-only field in a stub remains required when its model is generated by a +`dataclass_transform` base class. + +`models.pyi`: + +```pyi +from typing_extensions import dataclass_transform + +@dataclass_transform() +class ModelBase: ... + +class RequiredModel(ModelBase): + required: int +``` + +Adding another required field does not cause an ordering violation, and both constructors reject +calls that omit their required parameters. + +```py +from models import RequiredModel + +class Child(RequiredModel): + added: str + +reveal_type(RequiredModel.__init__) # revealed: (self: RequiredModel, required: int) -> None +reveal_type(Child.__init__) # revealed: (self: Child, required: int, added: str) -> None + +RequiredModel(1) +Child(1, "value") + +# error: [missing-argument] "No argument provided for required parameter `required`" +RequiredModel() + +# error: [missing-argument] "No argument provided for required parameter `added`" +Child(1) +``` + ### For function-based transformers ```py @@ -1468,6 +1529,48 @@ class InvalidKWOnlyDefaultModel: z: bytes = field(kw_only=False) # error: [dataclass-field-order] ``` +### Keyword-only field specifiers before Python 3.10 + +Although `dataclasses.field` does not support `kw_only` before Python 3.10, third-party field +specifiers can support it on earlier Python versions. Inherited keyword-only fields must retain that +setting so they do not participate in positional field ordering. + +```toml +[environment] +python-version = "3.9" +``` + +```py +from typing import Any, TypeVar +from typing_extensions import dataclass_transform + +T = TypeVar("T") + +def custom_field(*, default: Any = ..., kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(custom_field,)) +def custom_dataclass(cls: type[T]) -> type[T]: + return cls + +@custom_dataclass +class Base: + optional: float = custom_field(default=1.0, kw_only=True) + +@custom_dataclass +class Child(Base): + required: str + +reveal_type(Child.__init__) # revealed: (self: Child, required: str, *, optional: int | float = ...) -> None + +Child("value") +Child("value", optional=2.0) +Child("value", 2.0) # error: [too-many-positional-arguments] + +@custom_dataclass +class InvalidChild(Base): + positional_default: str = custom_field(default="default", kw_only=False) + required: int # error: [dataclass-field-order] +``` + ### For metaclass-based transformers ```py diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index b44d6744f1..fc506b5e27 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -1494,6 +1494,26 @@ class A: y: int ``` +The field-level argument is also ignored, so fields remain positional and still participate in +constructor ordering: + +```py +from dataclasses import dataclass, field + +@dataclass +class PositionalField: + value: int = field(default=1, kw_only=True) + +reveal_type(PositionalField.__init__) # revealed: (self: PositionalField, value: int = 1) -> None + +PositionalField(1) + +@dataclass +class InvalidFieldOrder: + optional: int = field(default=1, kw_only=True) + required: str # error: [dataclass-field-order] +``` + ### `kw_only` - Python 3.13 ```toml @@ -1674,10 +1694,68 @@ class B: reveal_type(B.__slots__) # revealed: tuple[Literal["x"], Literal["y"]] ``` +A dataclass cannot generate slots when its class body already defines `__slots__`. The invalid +dataclass should produce only the dataclass error, not additional errors for its fields. + +```py +@dataclass(slots=True) +class ExistingSlots: # error: [invalid-dataclass] "Dataclass `ExistingSlots` cannot combine `slots=True` with manually assigned `__slots__`" + value: int + __slots__ = () +``` + +An explicit `__slots__` declaration remains valid when slot generation is disabled. + +```py +@dataclass(slots=False) +class ValidExistingSlots: + __slots__ = ("value",) + value: int +``` + +An explicit `__slots__` declaration also conflicts when its names are not statically known. + +```py +def choose_slots() -> tuple[str, ...]: + return ("value",) + +@dataclass(slots=True) +class DynamicExistingSlots: # error: [invalid-dataclass] "Dataclass `DynamicExistingSlots` cannot combine `slots=True` with manually assigned `__slots__`" + value: int + __slots__ = choose_slots() +``` + +A bare annotation does not bind `__slots__` in the runtime class namespace, so it does not prevent +the dataclass from generating slots. + +```py +from typing import ClassVar + +@dataclass(slots=True) +class AnnotatedSlots: + __slots__: ClassVar[tuple[str, ...]] + value: int + +reveal_type(AnnotatedSlots.__slots__) # revealed: tuple[Literal["value"]] +``` + +Like other class attributes, `__slots__` assignments inside `TYPE_CHECKING` blocks are visible to +static analysis. + +```py +from typing import TYPE_CHECKING + +@dataclass(slots=True) +class TypeCheckingSlots: # error: [invalid-dataclass] "Dataclass `TypeCheckingSlots` cannot combine `slots=True` with manually assigned `__slots__`" + value: int + + if TYPE_CHECKING: + __slots__ = ("other",) +``` + ### `weakref_slot` -When a dataclass is defined with `weakref_slot=True` on Python >=3.11, the `__weakref__` attribute -is generated. For now, we do not attempt to infer a more precise type for it. +On Python 3.11 and later, `weakref_slot=True` creates a `__weakref__` descriptor on the dataclass. ```toml [environment] @@ -1691,7 +1769,19 @@ from dataclasses import dataclass class C: x: int -reveal_type(C.__weakref__) # revealed: Any | None +reveal_type(C.__weakref__) # revealed: Any +reveal_type(C(1).__weakref__) # revealed: Any +reveal_type(C.__slots__) # revealed: tuple[Literal["x"], Literal["__weakref__"]] +``` + +A slotted subclass uses the inherited weak-reference slot instead of creating another one. + +```py +@dataclass(slots=True, weakref_slot=True) +class Child(C): + y: int + +reveal_type(Child.__slots__) # revealed: tuple[Literal["y"]] ``` `weakref_slot=True` requires `slots=True`: @@ -1868,6 +1958,75 @@ Derived(1, "a") Derived(True) ``` +### Required fields inherited from stub dataclasses + +An annotation without an assigned value in a stub does not give a dataclass field a default. + +`base.pyi`: + +```pyi +from dataclasses import dataclass + +@dataclass +class Base: + required: int +``` + +The inherited field remains required in both constructors, and adding another required field does +not introduce a field-ordering violation. + +```py +from dataclasses import dataclass +from base import Base + +@dataclass +class Child(Base): + added: str + +reveal_type(Base.__init__) # revealed: (self: Base, required: int) -> None +reveal_type(Child.__init__) # revealed: (self: Child, required: int, added: str) -> None + +Base(1) +Child(1, "value") + +# error: [missing-argument] "No argument provided for required parameter `required`" +Base() + +# error: [missing-argument] "No argument provided for required parameter `added`" +Child(1) +``` + +### Defaulted fields inherited from stub dataclasses + +An ellipsis assigned to a stub field indicates an actual default. + +`base.pyi`: + +```pyi +from dataclasses import dataclass + +@dataclass +class EllipsisDefault: + required: int + optional: int = ... +``` + +The field produces an optional constructor parameter, so adding a required field in a subclass is +invalid. + +```py +from dataclasses import dataclass +from base import EllipsisDefault + +reveal_type(EllipsisDefault.__init__) # revealed: (self: EllipsisDefault, required: int, optional: int = ...) -> None + +EllipsisDefault(1) + +@dataclass +class InvalidEllipsisChild(EllipsisDefault): + added: str # error: [dataclass-field-order] +``` + ### Required fields after inherited defaults A required positional field cannot follow a positional field with a default inherited from a @@ -2261,8 +2420,7 @@ class ChildOfParentDataclass[T](ParentDataclass[T]): ... def uses_dataclass[T](x: T) -> ChildOfParentDataclass[T]: return ChildOfParentDataclass(x) -# TODO: ParentDataclass.__init__ should show generic types, not Unknown -# revealed: (self: ParentDataclass[Unknown], value: Unknown) -> None +# revealed: [T](self: ParentDataclass[T], value: T) -> None reveal_type(ParentDataclass.__init__) # revealed: [T](self: ParentDataclass[T], value: T) -> None diff --git a/crates/ty_python_semantic/resources/mdtest/decorators.md b/crates/ty_python_semantic/resources/mdtest/decorators.md index e783acfaa3..c0646f679e 100644 --- a/crates/ty_python_semantic/resources/mdtest/decorators.md +++ b/crates/ty_python_semantic/resources/mdtest/decorators.md @@ -62,8 +62,12 @@ Decorator expressions can also introduce bindings that remain visible after the definition: ```py -def decorator_factory(flag: bool): - def decorator(func): +from typing import TypeVar + +T = TypeVar("T") + +def decorator_factory(flag: bool) -> Callable[[T], T]: + def decorator(func: T) -> T: return func return decorator @@ -227,6 +231,8 @@ reveal_type(Box[int]().values) # revealed: list[int] | None ## Lambdas as decorators ```py +# TODO: infer the `lambda` as a generic function and avoid the false-positive diagnostic here: +# error: [dynamic-function-decorator-return] @lambda f: f def g(x: int) -> str: return "a" @@ -241,6 +247,7 @@ reveal_type(g) # revealed: Unknown ```py # error: [unresolved-reference] "Name `unknown_decorator` used when not defined" +# error: [dynamic-function-decorator-return] @unknown_decorator def f(x): ... @@ -251,6 +258,7 @@ reveal_type(f) # revealed: Unknown ```py # error: [unsupported-operator] +# error: [dynamic-function-decorator-return] @(1 + "a") def f(x): ... @@ -287,6 +295,26 @@ def f(x): ... reveal_type(f) # revealed: str ``` +Each error in a decorator stack describes the value received at that step. An inner decorator can +replace the function before a failing call, and an outer decorator can replace the result again. + +```py +def returns_bytes(value: object) -> bytes: + return b"" + +def requires_bytes(value: bytes) -> bool: + return bool(value) + +# error: [invalid-argument-type] "Expected `bytes`, found `str`" +@requires_bytes +# error: [invalid-argument-type] "Expected `int`, found `bytes`" +@wrong_signature +@returns_bytes +def stacked(): ... + +reveal_type(stacked) # revealed: bool +``` + #### Wrong number of arguments Decorators need to be callable with a single argument. If they are not, we emit a diagnostic: @@ -309,6 +337,25 @@ def takes_no_argument() -> str: def g(x): ... ``` +#### No matching overload + +An overloaded decorator is rejected when none of its signatures accepts the function being +decorated. + +```py +from typing import overload + +@overload +def scalar(value: int) -> None: ... +@overload +def scalar(value: str) -> None: ... +def scalar(value: int | str) -> None: ... + +# error: [no-matching-overload] +@scalar +def f() -> None: ... +``` + ### Class, with wrong signature, used as a decorator When a class is used as a decorator, its constructor (`__init__` or `__new__`) must accept the @@ -401,6 +448,8 @@ def takes_int(x: int) -> int: # error: [invalid-argument-type] @takes_int class Foo: ... + +reveal_type(Foo) # revealed: int ``` Using `None` as a decorator is an error: @@ -409,6 +458,8 @@ Using `None` as a decorator is an error: # error: [call-non-callable] @None class Bar: ... + +reveal_type(Bar) # revealed: ``` A decorator can enforce type constraints on the class being decorated: @@ -470,7 +521,7 @@ reveal_type(DataclassThenWrapped) # revealed: WrapBackend class WrappedThenDataclass: value: int -reveal_type(WrappedThenDataclass) # revealed: Unknown +reveal_type(WrappedThenDataclass) # revealed: WrapBackend def int_decorator_factory() -> Callable[[type[object]], int]: def decorator(cls: type[object]) -> int: @@ -483,7 +534,7 @@ def int_decorator_factory() -> Callable[[type[object]], int]: class IntThenDataclass: value: int -reveal_type(IntThenDataclass) # revealed: Unknown +reveal_type(IntThenDataclass) # revealed: int @WrapBackend class InvalidWrappedBase(1): ... # error: [invalid-base] @@ -562,7 +613,13 @@ callable_decorator = CallableDecorator() class CallableInstanceDecorated: ... reveal_type(CallableInstanceDecorated) # revealed: +``` + +An explicit return annotation can also produce `Unknown`, for example when a type variable is not +specialized. We preserve the class binding in these cases too, but use the return type when it is +known: +```py class ExplicitReturnDecorator(Generic[T]): def __call__(self, cls) -> T: raise NotImplementedError @@ -572,7 +629,7 @@ explicit_return_decorator = ExplicitReturnDecorator() @explicit_return_decorator class ExplicitReturnCallableInstanceDecorated: ... -reveal_type(ExplicitReturnCallableInstanceDecorated) # revealed: Unknown +reveal_type(ExplicitReturnCallableInstanceDecorated) # revealed: specialized_explicit_return_decorator = ExplicitReturnDecorator[int]() @@ -591,7 +648,7 @@ def explicit_return_callable_decorator(cls) -> T: @explicit_return_callable_decorator class ExplicitReturnCallableDecorated: ... -reveal_type(ExplicitReturnCallableDecorated) # revealed: Unknown +reveal_type(ExplicitReturnCallableDecorated) # revealed: def regular_callable_replacement_factory() -> Callable[[type[object]], T]: raise NotImplementedError @@ -602,17 +659,25 @@ class RegularCallableReplacementDecorated: ... reveal_type(RegularCallableReplacementDecorated) # revealed: Never ``` -An unknown class decorator still makes the class binding unknown: +An unknown class decorator preserves the class binding while still reporting the unresolved +reference: ```py # error: [unresolved-reference] "Name `unknown_class_decorator` used when not defined" @unknown_class_decorator -class UnknownDecorated: ... - -reveal_type(UnknownDecorated) # revealed: Unknown +class UnknownDecorated: + def method(self, value: int) -> str: + return str(value) + +reveal_type(UnknownDecorated) # revealed: +reveal_type(UnknownDecorated()) # revealed: UnknownDecorated +reveal_type(UnknownDecorated().method(1)) # revealed: str +UnknownDecorated().method("a") # error: [invalid-argument-type] ``` -An unannotated class decorator preserves the result of earlier decorators: +If an earlier decorator replaces the class with an instance, an `Unknown` return type preserves that +instance's type. This applies both to unannotated decorators and to decorators whose return +annotations evaluate to `Unknown`: ```py def unannotated_identity(cls): @@ -623,6 +688,12 @@ def unannotated_identity(cls): class WrappedThenUnannotated: ... reveal_type(WrappedThenUnannotated) # revealed: WrapBackend + +@explicit_return_decorator +@WrapBackend +class WrappedThenUnknown: ... + +reveal_type(WrappedThenUnknown) # revealed: WrapBackend ``` Metadata decorators still apply above an unannotated class-preserving decorator: @@ -640,6 +711,55 @@ class DeprecatedThenUnannotated: ... DeprecatedThenUnannotated() # error: [deprecated] "use OtherClass" ``` +## Unknown return annotations on class decorators + +An unresolved return annotation produces `Unknown`. This preserves the class binding and allows +outer metadata decorators to apply to the class: + +```py +from dataclasses import dataclass + +def decorator(cls: type) -> Missing: # error: [unresolved-reference] + return cls + +@dataclass +@decorator +class C: + value: int + +reveal_type(C) # revealed: +reveal_type(C(1).value) # revealed: int +C("a") # error: [invalid-argument-type] +``` + +## Explicitly dynamic class decorators + +Unlike `Unknown`, an explicit `Any` return type replaces the class binding: + +```py +from typing import Any + +def decorator(cls: type) -> Any: + return cls + +@decorator +class C: ... + +reveal_type(C) # revealed: Any +``` + +An explicit `type[Any]` return type also replaces the binding: + +```py +def class_decorator(cls: type) -> type[Any]: + return cls + +@class_decorator +class D: ... + +reveal_type(D) # revealed: type[Any] +``` + ## Preserving the original class object If a class decorator returns the original class object, we preserve the class binding so it can @@ -789,3 +909,556 @@ class RegisteredIdentity: reveal_type(RegisteredIdentity.resource.fetch()) # revealed: str ``` + +## Dynamic function decorator returns + +### Basics + +A decorator that returns `Any` erases the original function's signature. Unannotated decorators have +the same effect because their `Unknown` return type is equivalent to `Any`. Our opt-in diagnostic +`dynamic-function-decorator-return` identifies and flags these cases, which will be undesirable for +users who want strict typing enforced on their codebases: + +```py +from typing import Any, Callable + +def returns_any(function: Callable[..., object]) -> Any: + return function + +# snapshot: dynamic-function-decorator-return +@returns_any +def fully_typed(value: int) -> str: + return str(value) + +reveal_type(fully_typed) # revealed: Any +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:7:1 + | +7 | @returns_any + | ^^^^^^^^^^^^ +8 | def fully_typed(value: int) -> str: + | ----------- Signature of `fully_typed` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:3:5 + | +3 | def returns_any(function: Callable[..., object]) -> Any: + | --------------------------------------------------- `returns_any` defined here +``` + +### Self-referential defaults + +Reporting a dynamic return must not eagerly infer a self-referential default. Even after an earlier +assertion on the same name, the diagnostic identifies the function's signature. + +```py +from typing import Any + +def dynamic(function: object) -> Any: + return function + +f = lambda: f +assert f # error: [redundant-condition] "This condition is always true" + +# snapshot: dynamic-function-decorator-return +@dynamic +def f(x=lambda: f): ... +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:10:1 + | +10 | @dynamic + | ^^^^^^^^ +11 | def f(x=lambda: f): ... + | - Signature of `f` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:3:5 + | + 3 | def dynamic(function: object) -> Any: + | -------------------------------- `dynamic` defined here +``` + +### Dynamic decorators implemented by callable instances + +A callable-instance decorator points to its `__call__` method and suggests adding a return +annotation when that method is unannotated. + +```py +from typing import Any + +class CallableDecorator: + def __call__(self, function: object) -> Any: + return function + +# snapshot: dynamic-function-decorator-return +@CallableDecorator() +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:8:1 + | +8 | @CallableDecorator() + | ^^^^^^^^^^^^^^^^^^^^ +9 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:4:9 + | +4 | def __call__(self, function: object) -> Any: + | --------------------------------------- `CallableDecorator.__call__` defined here +``` + +### Dynamic decorators on overloaded function implementations + +A dynamic decorator on an overload implementation does not obscure the externally visible overload +signatures, so it does not trigger `dynamic-function-decorator-return`: + +```py +from collections.abc import Callable +from typing import Any, overload + +def dynamic(function: Callable[..., object]) -> Any: + return function + +@overload +def decorated(value: int) -> int: ... +@overload +def decorated(value: str) -> str: ... +@dynamic +def decorated(value: int | str) -> int | str: + return value + +reveal_type(decorated) # revealed: Overload[(value: int) -> int, (value: str) -> str] +reveal_type(decorated(1)) # revealed: int +reveal_type(decorated("hello")) # revealed: str +``` + +### Subdiagnostics suggest adding annotations, where appropriate + +Decorators imported from another first-party module point to their definition. If the diagnostic was +triggered due to a missing return-type annotation, we suggest adding one: + +`decorator.py`: + +```py +from typing import Any + +def dynamic(value: object) -> Any: + return value +``` + +`main.py`: + +```py +from decorator import dynamic + +# snapshot: dynamic-function-decorator-return +@dynamic +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/main.py:4:1 + | +4 | @dynamic + | ^^^^^^^^ +5 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: src/decorator.py:3:5 + | +3 | def dynamic(value: object) -> Any: + | ----------------------------- `dynamic` defined here +``` + +But we refrain from suggesting the user add a return annotation to the implementation of an +overloaded decorator function: the return annotation of the implementation is irrelevant to the +diagnostic in the following example: + +```py +from typing import overload, Callable, Any + +@overload +def overloaded_dynamic(function: Callable[..., object]) -> Any: ... +@overload +def overloaded_dynamic(function: None) -> None: ... +def overloaded_dynamic(function): + return function + +# snapshot: dynamic-function-decorator-return +@overloaded_dynamic +def decorated2(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:11:1 + | +11 | @overloaded_dynamic + | ^^^^^^^^^^^^^^^^^^^ +12 | def decorated2(value: int) -> str: + | ---------- Signature of `decorated2` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:4:5 + | + 4 | def overloaded_dynamic(function: Callable[..., object]) -> Any: ... + | ---------------------------------------------------------- Matching overload defined here +``` + +### Dynamic decorators use the matched overload definition(s) + +The definition annotation points to the overload selected by the implicit decorator call, even when +that overload is not the first declaration. + +```py +from typing import overload, Any + +@overload +def dynamic(function, extra) -> Any: ... +@overload +def dynamic(function) -> Any: ... +def dynamic(function, extra=None): + return function + +# snapshot: dynamic-function-decorator-return +@dynamic +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:11:1 + | +11 | @dynamic + | ^^^^^^^^ +12 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:6:5 + | + 6 | def dynamic(function) -> Any: ... + | ------------------------ Matching overload defined here +``` + +When multiple overloads match, the definition annotation spans every overload: + +```py +from collections.abc import Callable +from typing import Any, overload + +@overload +def dynamic(function: Callable[[int], object]) -> Any: ... +@overload +def dynamic(function: None) -> None: ... +@overload +def dynamic(function: Callable[[str], object]): ... +def dynamic(function): + return function + +# snapshot: dynamic-function-decorator-return +@dynamic +def decorated(value: Any) -> object: + return value +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Unknown` + --> src/mdtest_snippet.py:27:1 + | +27 | @dynamic + | ^^^^^^^^ +28 | def decorated(value: Any) -> object: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:17:1 + | +17 | / @overload +18 | | def dynamic(function: Callable[[int], object]) -> Any: ... +19 | | @overload +20 | | def dynamic(function: None) -> None: ... +21 | | @overload +22 | | def dynamic(function: Callable[[str], object]): ... + | |______________________________________________- Overloads of `dynamic` defined here +help: Ensure all `dynamic` overloads have a return annotation +``` + +### Fully annotated decorators with multiple matching overloads + +Several overloads matching at once does not make the result dynamic here: basedpython answers with +the union of what they return, as an `UnsafeUnion` — the caller may rely on any one member, and only +one of them is actually true. So there is nothing for this rule to report, and the decorated +function keeps a type precise enough to check calls against. + +```py +from collections.abc import Callable +from typing import Any, overload + +@overload +def dynamic(function: Callable[[int], object]) -> int: ... +@overload +def dynamic(function: None) -> None: ... +@overload +def dynamic(function: Callable[[str], object]) -> str: ... +def dynamic(function: Any) -> Any: + return function + +@dynamic +def decorated(value: Any) -> object: + return value + +reveal_type(decorated) # revealed: UnsafeUnion[int, str] +``` + +### Dynamic decorators defined in third-party packages + +A decorator from a dependency still points to its definition, but ty does not suggest editing code +outside the current project. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//dependency.py`: + +```py +from typing import Any + +def dynamic(value: object) -> Any: + return value +``` + +`main.py`: + +```py +from dependency import dynamic + +# snapshot: dynamic-function-decorator-return +@dynamic +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/main.py:4:1 + | +4 | @dynamic + | ^^^^^^^^ +5 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: .venv//dependency.py:3:5 + | +3 | def dynamic(value: object) -> Any: + | ----------------------------- `dynamic` defined here +``` + +### Edge case: dynamic decorators defined in non-module scripts + +Decorators defined in the checked file receive a suggestion to add a return-type annotation even +when the filename is not a valid Python module name. (`typed-script.py` cannot be resolved to a +valid Python module by our module resolver, so cannot be recognised as having a "first-party search +path", but we nonetheless recognise it as a first-party file and offer the suggestion.) + +`typed-script.py`: + +```py +from typing import Any + +def dynamic(function: object) -> Any: + return function + +# snapshot: dynamic-function-decorator-return +@dynamic +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/typed-script.py:7:1 + | +7 | @dynamic + | ^^^^^^^^ +8 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator + | + ::: src/typed-script.py:3:5 + | +3 | def dynamic(function: object) -> Any: + | -------------------------------- `dynamic` defined here +``` + +### Edge case: the decorator has a union type + +This comes up very rarely, so for simplicity's sake we just don't have any secondary annotations +here: + +```py +from collections.abc import Callable +from typing import Any + +def annotated_dynamic(function: Callable[..., object]) -> Any: + return function + +def unannotated_dynamic(function: Callable[..., object]) -> Any: + return function + +def condition() -> bool: + return True + +decorator = annotated_dynamic if condition() else unannotated_dynamic + +# snapshot: dynamic-function-decorator-return +@decorator +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:16:1 + | +16 | @decorator + | ^^^^^^^^^^ +17 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator +``` + +### Edge case: the decorator is a union of an overloaded function and `Callable` + +When one union member is an overloaded function and another is a `Callable`, the latter's bindings +must not be attributed to the overloaded function. + +```py +from typing import Any, Callable, overload + +@overload +def overloaded_dynamic(value: None): ... +@overload +def overloaded_dynamic(value: Callable[[int], str]) -> Any: ... +def overloaded_dynamic(value: object) -> Any: + return value + +def apply_decorator(flag: bool, other: Callable[[Callable[..., object]], Any]) -> None: + decorator = overloaded_dynamic if flag else other + + # snapshot: dynamic-function-decorator-return + @decorator + def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:14:5 + | +14 | @decorator + | ^^^^^^^^^^ +15 | def decorated(value: int) -> str: + | --------- Signature of `decorated` will be obscured by the decorator +``` + +### Multiple dynamic decorators + +Only the first decorator that replaces a precise type with a dynamic type is reported. Outer +decorators receive an already-dynamic type, so they do not lose any additional information. + +```py +from typing import Any + +def dynamic(value: Any) -> Any: + return value + +# no error for this outer decorator (the received type was already `Any`) +@dynamic +# error: [dynamic-function-decorator-return] +@dynamic +def decorated_function(value: int) -> str: + return str(value) +``` + +### Dynamic decorators applied to replacement values + +When an inner decorator replaces a function with a non-callable value, an outer dynamic decorator +obscures that replacement value's type rather than the original function signature. + +```py +from collections.abc import Callable +from typing import Any + +def replace_with_int(function: Callable[..., object]) -> int: + return 1 + +def dynamic(value: int) -> Any: + return value + +# snapshot: dynamic-function-decorator-return +@dynamic +@replace_with_int +def decorated(value: int) -> str: + return str(value) +``` + +```snapshot +info[dynamic-function-decorator-return]: Decorator returns `Any` + --> src/mdtest_snippet.py:11:1 + | +11 | @dynamic + | ^^^^^^^^ +12 | @replace_with_int +13 | def decorated(value: int) -> str: + | --------- Previous type of `decorated` will be obscured by the decorator + | + ::: src/mdtest_snippet.py:7:5 + | + 7 | def dynamic(value: int) -> Any: + | -------------------------- `dynamic` defined here +``` + +### Decorator return types equivalent to `Any` + +Aliases of `Any` erase the decorated type in the same way as a direct `Any` annotation. + +```py +from typing import Any, TypeAlias + +DynamicAlias: TypeAlias = Any + +def returns_alias(value: object) -> DynamicAlias: + return value + +# error: [dynamic-function-decorator-return] +@returns_alias +def decorated_function(value: int) -> str: + return str(value) +``` + +### Partially dynamic decorator returns + +A decorator does not erase all information when its return type merely contains `Any`. Such a type +is not equivalent to `Any`, so the decorator is not reported. + +```py +from collections.abc import Callable +from typing import Any + +def returns_callable(function: Callable[..., object]) -> Callable[..., Any]: + return function + +@returns_callable +def decorated_function(value: int) -> str: + return str(value) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/del.md b/crates/ty_python_semantic/resources/mdtest/del.md index cdd01e254e..b098ece8e1 100644 --- a/crates/ty_python_semantic/resources/mdtest/del.md +++ b/crates/ty_python_semantic/resources/mdtest/del.md @@ -373,7 +373,7 @@ item deletion. The `__delitem__` method is independent of `__getitem__`. ```py from typing import Protocol, TypeVar -KT = TypeVar("KT") +KT = TypeVar("KT", contravariant=True) class CanDelItem(Protocol[KT]): def __delitem__(self, k: KT, /) -> None: ... diff --git a/crates/ty_python_semantic/resources/mdtest/deprecated.md b/crates/ty_python_semantic/resources/mdtest/deprecated.md index 55e9d41237..b8d7c7a17c 100644 --- a/crates/ty_python_semantic/resources/mdtest/deprecated.md +++ b/crates/ty_python_semantic/resources/mdtest/deprecated.md @@ -91,10 +91,147 @@ class StaticMethodReplacement: StaticMethodReplacement.old() # error: [deprecated] "use replacement directly" ``` +## Callable replacements + +```toml +[environment] +python-version = "3.12" +``` + +An outer `@deprecated` decorator also applies when an inner decorator returns a `Callable` type. +Both references and calls report the deprecation, and the decorated signature is preserved. + +```py +from collections.abc import Callable +from typing_extensions import deprecated + +def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]: + return function + +@deprecated("use current instead") +@passthrough +def old(value: int) -> str: + return str(value) + +old # error: [deprecated] "use current instead" +# error: [deprecated] "use current instead" +reveal_type(old(1)) # revealed: str +# error: [invalid-argument-type] +old("wrong") # error: [deprecated] "use current instead" +``` + +Replacing the callable with a different function discards the original deprecation. If an outer +`@deprecated` wraps the replacement, its message is used instead. + +```py +def replace(function: Callable[[int], str]) -> Callable[[int], str]: + return lambda value: str(value) + +@replace +@deprecated("discarded deprecation") +@passthrough +def replaced(value: int) -> str: + return str(value) + +replaced(1) + +@deprecated("outer deprecation") +@replace +@deprecated("inner deprecation") +@passthrough +def replaced_again(value: int) -> str: + return str(value) + +replaced_again(1) # error: [deprecated] "outer deprecation" +``` + +## Callable method replacements + +```toml +[environment] +python-version = "3.12" +``` + +Changing a method's descriptor kind or specializing its class preserves the deprecation. + +```py +from collections.abc import Callable +from typing_extensions import deprecated + +def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]: + return function + +class C[T]: + @deprecated("old initializer") + @passthrough + def __init__(self) -> None: ... + @deprecated("old method") + @passthrough + def method(self, value: T) -> T: + return value + + @staticmethod + @deprecated("old static method") + @passthrough + def static(value: int) -> int: + return value + + @classmethod + @deprecated("old class method") + @passthrough + def class_method(cls, value: int) -> int: + return value + +c = C[int]() # error: [deprecated] "old initializer" +c.__init__ # error: [deprecated] "old initializer" +# error: [deprecated] "old method" +reveal_type(c.method(1)) # revealed: int +# error: [invalid-argument-type] +c.method("wrong") # error: [deprecated] "old method" +C.static(1) # error: [deprecated] "old static method" +c.static(1) # error: [deprecated] "old static method" +C.class_method(1) # error: [deprecated] "old class method" +c.class_method(1) # error: [deprecated] "old class method" +``` + +## Overloaded callable replacements + +```toml +[environment] +python-version = "3.12" +``` + +Deprecating a callable replacement for an overload implementation deprecates the whole function, +while calls still use the declared overload signatures. + +```py +from collections.abc import Callable +from typing import overload +from typing_extensions import deprecated + +def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]: + return function + +@overload +def overloaded(value: int) -> int: ... +@overload +def overloaded(value: str) -> str: ... +@deprecated("old implementation") +@passthrough +def overloaded(value: int | str) -> int | str: + return value + +overloaded # error: [deprecated] "old implementation" +# error: [deprecated] "old implementation" +reveal_type(overloaded(1)) # revealed: int +# error: [deprecated] "old implementation" +reveal_type(overloaded("one")) # revealed: str +``` + ## Callable-object replacements -`@deprecated` can also wrap other callable objects at runtime, but we currently only preserve the -deprecation when an inner decorator returns a function literal. +`@deprecated` can also wrap callable instances at runtime, but we do not yet preserve the +deprecation when an inner decorator returns an instance type. ```py from collections.abc import Callable @@ -393,59 +530,1295 @@ AliasClass() # error: [deprecated] "Use OtherType instead" ## Dunders -If a dunder like `__add__` is deprecated, then the equivalent syntactic sugar like `+` should fire a +### Binary operators + +Using `+` invokes `__add__`, so it reports that method's deprecation. + +```py +from typing_extensions import deprecated + +class Number: + @deprecated("old addition") + def __add__(self, other: object) -> "Number": + return self + +number = Number() +number + 1 # error: [deprecated] "old addition" +``` + +Without an `__iadd__` method, `+=` falls back to `__add__` and reports the same deprecation. + +```py +number += 1 # error: [deprecated] "old addition" +``` + +### Reflected operators + +When the left operand accepts the operation, a deprecated `__radd__` on the right operand is not +called and does not produce a warning. + +```py +from typing_extensions import deprecated + +class Left: + def __add__(self, other: object) -> int: + return 0 + +class Right: + @deprecated("reflected addition") + def __radd__(self, other: object) -> int: + return 0 + +Left() + Right() +``` + +Here, `int.__add__` does not accept a `Right` instance, so `+` calls the deprecated +`Right.__radd__`. + +```py +1 + Right() # error: [deprecated] "reflected addition" +``` + +A deprecated method whose parameter does not accept the other operand does not trigger a warning +when a compatible reflected method is available. + +```py +class RestrictedLeft: + @deprecated("integer addition") + def __add__(self, other: int) -> int: + return 0 + +class ActiveRight: + def __radd__(self, other: object) -> int: + return 0 + +RestrictedLeft() + ActiveRight() +``` + +### In-place operators + +When `__iadd__` accepts the operand, `+=` uses it without calling a deprecated `__add__`. + +```py +from typing_extensions import deprecated + +class Number: + @deprecated("binary addition") + def __add__(self, other: int) -> "Number": + return self + + def __iadd__(self, other: int) -> "Number": + return self + +number = Number() +number += 1 +``` + +A deprecated `__iadd__` produces a warning at the augmented assignment. + +```py +class OldNumber: + @deprecated("in-place addition") + def __iadd__(self, other: int) -> "OldNumber": + return self + +old = OldNumber() +old += 1 # error: [deprecated] "in-place addition" +``` + +### Callable instances + +Calling an instance invokes its `__call__` method and reports that method's deprecation. + +```py +from typing_extensions import deprecated + +class Invocable: + @deprecated("do not call") + def __call__(self) -> int: + return 0 + +invocable = Invocable() +invocable() # error: [deprecated] "do not call" +``` + +An explicit reference to `__call__` is also deprecated. Calling that reference still produces only +one warning. + +```py +invocable.__call__ # error: [deprecated] "do not call" +invocable.__call__() # error: [deprecated] "do not call" +``` + +### Overloaded callable instances + +For overloaded `__call__` methods, only calls that select a deprecated overload trigger a warning. + +```py +from typing import overload +from typing_extensions import deprecated + +class Overloaded: + @overload + @deprecated("integer call") + def __call__(self, value: int) -> int: ... + @overload + def __call__(self, value: str) -> str: ... + def __call__(self, value: int | str) -> int | str: + return value + +overloaded = Overloaded() +overloaded(1) # error: [deprecated] "integer call" +overloaded("one") +``` + +### Unary operators + +If a dunder like `__invert__` is deprecated, then the equivalent `~` operator should fire a diagnostic. +#### Custom operator + +```py +from typing_extensions import deprecated + +class MyBits: + @deprecated("MyBits `~` support is broken") + def __invert__(self): + return self + +x = MyBits() +~x # error: [deprecated] "MyBits `~` support is broken" +``` + +#### Possibly unbound operator + +If the operand's type is a union and the dunder is missing on some members, it's possibly unbound. +This should still report the deprecation on the members where it is found and is deprecated, +alongside `unsupported-operator` diagnostic. + ```py from typing_extensions import deprecated -class MyInt: - def __init__(self, val): - self.val = val +class MyBits: + @deprecated("MyBits `~` support is broken") + def __invert__(self): + return self - @deprecated("MyInt `+` support is broken") - def __add__(self, other): - return MyInt(self.val + other.val) +class NoBits: ... -x = MyInt(1) -y = MyInt(2) -z = x + y # TODO error: [deprecated] "MyInt `+` support is broken" +def f(x: MyBits | NoBits): + # error: [unsupported-operator] + # error: [deprecated] + ~x ``` -## Overloads +#### Unions and intersections -Overloads can be deprecated, but only trigger warnings when invoked. +A unary operation on a union reports a deprecation if any member's operator is deprecated. ```py from typing_extensions import deprecated -from typing_extensions import overload -@overload -@deprecated("strings are no longer supported") -def f(x: str): ... -@overload -def f(x: int): ... -def f(x): - print(x) +class Deprecated: + @deprecated("old inversion") + def __invert__(self) -> int: + return 1 -f(1) -f("hello") # TODO: error: [deprecated] "strings are no longer supported" +class Ordinary: + def __invert__(self) -> int: + return 3 + +def mixed_union(value: Deprecated | Ordinary) -> None: + ~value # error: [deprecated] "old inversion" +``` + +An intersection can use a non-deprecated implementation instead, so it does not warn when one is +available. + +```py +def mixed_intersection(value: Deprecated) -> None: + if isinstance(value, Ordinary): + ~value +``` + +When every applicable implementation is deprecated, one warning includes both messages. + +```py +class AlsoDeprecated: + @deprecated("another old inversion") + def __invert__(self) -> int: + return 2 + +def deprecated_intersection(value: Deprecated) -> None: + if isinstance(value, AlsoDeprecated): + # error: [deprecated] "`Deprecated.__invert__`, `AlsoDeprecated.__invert__`" + ~value +``` + +A gradually typed comparison can produce an intersection of `bool` and `Any`. The unknown +alternative might provide a non-deprecated operator, so inverting it should not warn. + +```py +from typing import Any + +def gradual_intersection(value: Any) -> None: + if value is None: + return + + mask = value == 0 + ~mask +``` + +#### Bool literals + +`bool.__invert__` is one such case in typeshed. This applies both to `bool` literals and to +arbitrary values of type `bool`. + +```py +~True # error: [deprecated] + +def f(x: bool): + ~x # error: [deprecated] ``` -If the actual impl is deprecated, the deprecation always fires. +#### Constrained TypeVars + +A unary operation on a constrained type variable can invoke the method from any of its constraints. +If several methods are deprecated, their messages appear in one diagnostic. ```py +from typing import TypeVar from typing_extensions import deprecated -from typing_extensions import overload -@overload -def f(x: str): ... -@overload -def f(x: int): ... -@deprecated("unusable") -def f(x): - print(x) +class First: + @deprecated("first") + def __invert__(self) -> int: + return 42 + +class Second: + @deprecated("second") + def __invert__(self) -> int: + return 42 + +T = TypeVar("T", First, Second) + +def f(value: T) -> None: + # error: [deprecated] "`First.__invert__`, `Second.__invert__`" + ~value +``` + +Deprecation reporting for one constraint does not depend on whether another constraint supports the +operator or on the order of the constraints. + +```py +class Third: ... + +U = TypeVar("U", Third, First) +V = TypeVar("V", First, Third) + +def g(value: U) -> None: + # error: [unsupported-operator] + # error: [deprecated] + ~value + +def h(value: V) -> None: + # error: [unsupported-operator] + # error: [deprecated] + ~value +``` + +A constraint that is itself a union may contain a deprecated operator even when that operator is +missing from another union member. + +```py +W = TypeVar("W", First | Third, Second) + +def nested_union(value: W) -> None: + # error: [unsupported-operator] + # error: [deprecated] "`First.__invert__`, `Second.__invert__`" + ~value +``` + +A deprecated operator should also be reported when its signature cannot accept the implicit unary +call. + +```py +class Invalid: + @deprecated("invalid inversion") + def __invert__(self, required: int) -> int: + return required + +X = TypeVar("X", Invalid, Second) + +def invalid_operator(value: X) -> None: + # error: [unsupported-operator] + # error: [deprecated] "`Invalid.__invert__`, `Second.__invert__`" + ~value +``` + +## Property accessors + +Reading a property invokes its getter. A deprecated getter produces a warning on a read, but not on +an assignment or deletion. + +```py +from typing_extensions import deprecated + +class OldGetter: + @property + @deprecated("old getter") + def value(self) -> int: + return 0 + + @value.setter + def value(self, value: int) -> None: ... + @value.deleter + def value(self) -> None: ... + +old_getter = OldGetter() +old_getter.value # error: [deprecated] "old getter" +old_getter.value = 1 +del old_getter.value +``` + +Assignments and deletions invoke the setter and deleter, respectively. Neither accessor is called +when reading the property. + +```py +class OldSetter: + @property + def value(self) -> int: + return 0 + + @value.setter + @deprecated("old setter") + def value(self, value: int) -> None: ... + @value.deleter + @deprecated("old deleter") + def value(self) -> None: ... + +old_setter = OldSetter() +old_setter.value +old_setter.value = 1 # error: [deprecated] "old setter" +del old_setter.value # error: [deprecated] "old deleter" +``` + +Access through the class returns the property object without invoking its deprecated getter. + +```py +OldGetter.value +``` + +## Augmented property assignments + +Augmented assignment reads and then writes the property. When both the getter and setter are +deprecated, it reports each accessor's deprecation. + +```py +from typing_extensions import deprecated + +class OldBoth: + @property + @deprecated("both getter") + def value(self) -> int: + return 0 + + @value.setter + @deprecated("both setter") + def value(self, value: int) -> None: ... + +old_both = OldBoth() +# error: [deprecated] "both getter" +# error: [deprecated] "both setter" +old_both.value += 1 +``` + +## Inherited properties + +Reading or deleting the property on a subclass instance reports the inherited accessor's +deprecation. + +```py +from typing_extensions import deprecated + +class Parent: + @property + @deprecated("parent getter") + def value(self) -> int: + return 0 + + @value.deleter + @deprecated("parent deleter") + def value(self) -> None: ... + +class Child(Parent): ... + +Child().value # error: [deprecated] "parent getter" +del Child().value # error: [deprecated] "parent deleter" +``` + +Overriding the getter with a non-deprecated method removes the deprecation on reads. + +```py +class ActiveChild(Parent): + @property + def value(self) -> int: + return 0 + +ActiveChild().value +``` + +## Properties accessed through `super()` + +Reading a property through `super()` invokes the parent getter with the instance as its receiver. + +```py +from typing_extensions import deprecated + +class Parent: + @property + @deprecated("parent getter") + def value(self) -> int: + return 0 + + @value.deleter + @deprecated("parent deleter") + def value(self) -> None: ... + +class Child(Parent): + def read(self) -> int: + return super().value # error: [deprecated] "parent getter" +``` + +Binding `super()` to a class instead returns the property object without invoking its getter. + +```py +super(Child, Child).value +``` + +Deleting an attribute on a `super()` object does not invoke the parent's property deleter. + +```py +del super(Child, Child()).value +``` + +The same holds when the receiver may be either a `super()` object or an ordinary instance. + +```py +class Ordinary: + value: int + +def delete_union(flag: bool): + target = super(Child, Child()) if flag else Ordinary() + del target.value +``` + +## Metaclass properties + +A class is an instance of its metaclass, so reading or writing a metaclass property invokes its +accessors. + +```py +from typing_extensions import deprecated + +class Meta(type): + @property + @deprecated("metaclass getter") + def value(cls) -> int: + return 0 + + @value.setter + @deprecated("metaclass setter") + def value(cls, value: int) -> None: ... + +class C(metaclass=Meta): ... + +C.value # error: [deprecated] "metaclass getter" +C.value = 1 # error: [deprecated] "metaclass setter" +``` + +Access through the metaclass itself returns the property object without invoking its getter. + +```py +Meta.value +``` + +## Properties on unions + +An attribute access through a union warns if any member uses a deprecated property. + +```py +from typing_extensions import deprecated + +class Old: + @property + @deprecated("union getter") + def value(self) -> int: + return 0 + + @value.setter + @deprecated("union setter") + def value(self, value: int) -> None: ... + +class Active: + value: int + +def check(value: Old | Active): + value.value # error: [deprecated] "union getter" + value.value = 1 # error: [deprecated] "union setter" +``` + +An invalid assignment on one union member does not hide a deprecated setter on another member. + +```py +class Wrong: + value: str + +def check_invalid(value: Wrong | Old): + # error: [invalid-assignment] + # error: [deprecated] "union setter" + value.value = 1 +``` + +An invalid assignment still reports deprecations after an `isinstance` check narrows the union's +members to intersections. + +```py +class Marker: ... + +def check_invalid_intersections(value: Wrong | Old): + if isinstance(value, Marker): + # error: [invalid-assignment] + # error: [deprecated] "union setter" + value.value = 1 +``` + +## Properties on intersections + +An intersection can use a non-deprecated member's attribute instead of a deprecated property. We do +not warn when that alternative is available. + +```py +from typing_extensions import deprecated + +class Old: + @property + @deprecated("old getter") + def value(self) -> int: + return 0 + + @value.setter + @deprecated("old setter") + def value(self, value: int) -> None: ... + @value.deleter + @deprecated("old deleter") + def value(self) -> None: ... + +class Active: + value: int + +def check(value: Old): + if isinstance(value, Active): + value.value + value.value = 1 +``` + +A member without the attribute does not provide an alternative accessor, so the deprecated property +still applies. + +```py +class Marker: ... + +def check_marker(value: Old): + if isinstance(value, Marker): + value.value # error: [deprecated] "old getter" + value.value = 1 # error: [deprecated] "old setter" +``` + +When both members define their own deprecated property, reading, assigning, and deleting the +attribute report the getter, setter, and deleter deprecations, respectively. Each warning names both +declarations. + +```py +class AlsoOld: + @property + @deprecated("old getter") + def value(self) -> int: + return 0 + + @value.setter + @deprecated("old setter") + def value(self, value: int) -> None: ... + @value.deleter + @deprecated("old deleter") + def value(self) -> None: ... + +def check_both(value: Old): + if isinstance(value, AlsoOld): + value.value # error: [deprecated] "`Old.value`, `AlsoOld.value`: old getter" + value.value = 1 # error: [deprecated] "`Old.value`, `AlsoOld.value`: old setter" + del value.value # error: [deprecated] "`Old.value`, `AlsoOld.value`: old deleter" +``` + +A non-deprecated getter suppresses the warning on reads. Assignments still warn when both setters +are deprecated. + +```py +class ActiveGetter: + @property + def value(self) -> int: + return 0 + + @value.setter + @deprecated("old setter") + def value(self, value: int) -> None: ... + +def check_active_getter(value: Old): + if isinstance(value, ActiveGetter): + value.value + value.value = 1 # error: [deprecated] "`Old.value`, `ActiveGetter.value`: old setter" +``` + +## Invalid property getter calls + +The getter below accepts an `int`, but Python passes a `C` instance. Reading the property reports +both the invalid call and the deprecation. + +```py +from typing_extensions import deprecated + +class C: + @property + @deprecated("invalid getter") + def value(self: int) -> int: + return self + +# error: [invalid-attribute-access] +# error: [deprecated] "invalid getter" +C().value +``` + +## Setter deprecations and contextual inference + +The ordinary attribute supplies `int` as the lambda's parameter type. Checking the other member's +deprecated setter does not change that inferred type or warn about the non-deprecated assignment. + +```py +from collections.abc import Callable +from typing_extensions import deprecated + +class Ordinary: + callback: Callable[[int], object] + +class Deprecated: + @property + def callback(self) -> object: ... + @callback.setter + @deprecated("old setter") + def callback(self, value: Callable[[str], object]) -> None: ... + +def check(value: Ordinary): + if isinstance(value, Deprecated): + value.callback = lambda argument: reveal_type(argument) # revealed: int +``` + +Deprecation checks also preserve the `str` parameter type inferred from a protocol setter. + +```py +from typing import Protocol + +class Callback(Protocol): + @property + def callback(self) -> Callable[[str], object]: ... + @callback.setter + @deprecated("callback setter") + def callback(self, value: Callable[[str], object]) -> None: ... + +def check_protocol(value: Callback): + if isinstance(value, Ordinary): + value.callback = lambda argument: reveal_type(argument) # revealed: str +``` + +## Overloads + +### Deprecated overloads + +A call reports the deprecation of the overload selected by its arguments. + +```py +from typing_extensions import deprecated, overload + +@overload +@deprecated("strings are no longer supported") +def f(x: str): ... +@overload +def f(x: int): ... +def f(x): + print(x) + +f(1) +f("hello") # error: [deprecated] "strings are no longer supported" +``` + +Referring to the function without calling it does not select an overload and does not warn. + +```py +f +``` + +### Deprecated implementations + +A deprecated implementation makes every call deprecated. Its message takes precedence over an +individual overload's deprecation, and a call produces only one warning. + +```py +from typing_extensions import deprecated, overload + +@overload +@deprecated("string overload") +def f(x: str): ... +@overload +def f(x: int): ... +@deprecated("entire function") +def f(x): + print(x) + +f(1) # error: [deprecated] "entire function" +f("hello") # error: [deprecated] "entire function" +``` + +### Equivalent return types + +An `Any` argument matches both overloads below. Their return types are equivalent, so overload +resolution selects the first. The deprecated second overload does not produce a warning. + +```py +from typing import Any, overload +from typing_extensions import deprecated + +@overload +def convert(value: int) -> str: ... +@overload +@deprecated("strings are no longer supported") +def convert(value: str) -> str: ... +def convert(value: int | str) -> str: + return str(value) + +def check(value: Any): + convert(value) +``` + +### Ambiguous overloads + +All three overloads remain possible for an `Any` argument. Their return types differ, so no single +overload wins. The call reports the deprecated overload that remains a possible target. + +```py +from typing import Any, overload +from typing_extensions import deprecated + +@overload +def convert(value: list[int]) -> int: ... +@overload +@deprecated("string lists are no longer supported") +def convert(value: list[str]) -> str: ... +@overload +def convert(value: bytes) -> bytes: ... +def convert(value): ... +def check(value: Any): + convert(value) # error: [deprecated] "string lists are no longer supported" +``` + +The same ambiguity can occur within one member of a union. The `list[Any]` member can select the +deprecated overload, even though the `bytes` member selects a non-deprecated overload. + +```py +def check_union(value: list[Any] | bytes): + convert(value) # error: [deprecated] "string lists are no longer supported" +``` + +### Union arguments + +A union argument can select different overloads for different members. The call reports a +deprecation if any selected overload is deprecated. + +```py +from typing import overload +from typing_extensions import deprecated + +@overload +def convert(value: int) -> str: ... +@overload +@deprecated("use an integer") +def convert(value: str) -> str: ... +def convert(value: int | str) -> str: + return str(value) + +def check(value: int | str): + convert(value) # error: [deprecated] "use an integer" +``` + +If no overload accepts the argument, there is no selected overload to report as deprecated. + +```py +convert(None) # error: [no-matching-overload] +``` + +### Equivalent return types within a union + +Each member of a union resolves its overloads separately. The `list[Any]` member matches the first +two overloads, whose equivalent return types select the first. The `bytes` member selects the third +overload. Neither selected overload is deprecated. + +```py +from typing import Any, overload +from typing_extensions import deprecated + +@overload +def convert(value: list[int]) -> str: ... +@overload +@deprecated("string lists are no longer supported") +def convert(value: list[str]) -> str: ... +@overload +def convert(value: bytes) -> str: ... +def convert(value) -> str: + return "" + +def check(value: list[Any] | bytes): + convert(value) +``` + +### Ambiguity in one union member + +Ambiguity in one union member does not change how another member selects its overload. The +`list[Any]` member can select the deprecated overload for lists of strings. The `set[Any]` member's +two overloads have equivalent return types, so only its non-deprecated first overload is selected. + +```py +from typing import Any, overload +from typing_extensions import deprecated + +@overload +def convert(value: list[int]) -> int: ... +@overload +@deprecated("string lists are no longer supported") +def convert(value: list[str]) -> str: ... +@overload +def convert(value: set[int]) -> int: ... +@overload +@deprecated("string sets are no longer supported") +def convert(value: set[str]) -> int: ... +def convert(value): ... +def check(value: list[Any] | set[Any]): + # error: [deprecated] "The overload of `convert` is deprecated: string lists are no longer supported" + convert(value) +``` + +### Calls that select several deprecated overloads + +An `int | str` argument selects a different overload for each member of the union. When both +overloads are deprecated, the warning names the function once. + +```py +from typing import overload +from typing_extensions import deprecated + +@overload +@deprecated("integer overload") +def convert(value: int) -> str: ... +@overload +@deprecated("string overload") +def convert(value: str) -> str: ... +def convert(value: int | str) -> str: + return str(value) + +def check(value: int | str): + # error: [deprecated] "Possible use of deprecated function: `convert`" + convert(value) +``` + +### Shared deprecation messages across overloads + +When selected overloads share a deprecation message, the warning includes that message once in both +full and concise output. The full diagnostic points to each deprecated overload. + +```py +from typing import overload +from typing_extensions import deprecated + +@overload +@deprecated("Use `parse` instead. Support ends in version 2.") +def convert(value: int) -> str: ... +@overload +@deprecated("Use `parse` instead. Support ends in version 2.") +def convert(value: str) -> str: ... +def convert(value: int | str) -> str: + return str(value) + +def check(value: int | str): + # snapshot: deprecated + convert(value) +``` + +```snapshot +warning[deprecated]: Possible use of deprecated function: `convert` + --> src/mdtest_snippet.py:15:5 + | +15 | convert(value) + | ^^^^^^^ Use `parse` instead. Support ends in version 2. + | + ::: src/mdtest_snippet.py:6:5 + | + 6 | def convert(value: int) -> str: ... + | ------- + 7 | @overload + 8 | @deprecated("Use `parse` instead. Support ends in version 2.") + 9 | def convert(value: str) -> str: ... + | ------- +``` + +### Overloads for different receivers + +The `self` annotations restrict which overloads each instance can call. A call on `C[str]` reports +the deprecated string overload, even though binding the method discards the integer overload. + +```py +from typing import Generic, TypeVar, overload +from typing_extensions import deprecated + +T = TypeVar("T") + +class C(Generic[T]): + @overload + def method(self: "C[int]", value: int) -> int: ... + @overload + @deprecated("string method") + def method(self: "C[str]", value: str) -> str: ... + def method(self, value: int | str) -> int | str: + return value + +def check(integer: C[int], string: C[str]): + integer.method(1) + string.method("one") # error: [deprecated] "string method" +``` + +## Deprecated constructors + +Calling a class can invoke `__new__`, `__init__`, or a custom metaclass's `__call__`. We warn about +deprecated methods that the call invokes, even when the class itself is not deprecated. + +### `__init__` + +Calling a class reports the deprecation of its initializer. + +```py +from typing_extensions import Self, deprecated + +class OldInit: + @deprecated("old init") + def __init__(self) -> None: ... + +OldInit() # error: [deprecated] "old init" +``` + +An explicit call on an instance also produces only one warning. + +```py +def explicit_init(value: OldInit): + value.__init__() # error: [deprecated] "old init" +``` + +If a non-deprecated `__new__` returns an instance of the class, Python still calls the inherited +deprecated initializer. + +```py +class NewWithOldInit(OldInit): + def __new__(cls) -> Self: + return super().__new__(cls) + +NewWithOldInit() # error: [deprecated] "old init" +``` + +When `__new__` returns an unrelated type, `__init__` does not run and produces no warning. + +```py +class ReturnsInt(OldInit): + def __new__(cls) -> int: + return 0 + +ReturnsInt() +``` + +### `__new__` + +Calling a class also reports the deprecation of the method that creates the instance. + +```py +from typing_extensions import Self, deprecated + +class OldNew: + @deprecated("old new") + def __new__(cls) -> Self: + return super().__new__(cls) + +OldNew() # error: [deprecated] "old new" +``` + +Calling `__new__` explicitly produces only one warning. + +```py +OldNew.__new__(OldNew) # error: [deprecated] "old new" +``` + +### Both constructor methods + +When both methods are deprecated, the class call produces one warning that points to both +declarations and includes both messages. + +```py +from typing_extensions import Self, deprecated + +class Both: + @deprecated("old new") + def __new__(cls) -> Self: + return super().__new__(cls) + + @deprecated("old init") + def __init__(self) -> None: ... + +# snapshot: deprecated +Both() +``` + +```snapshot +warning[deprecated]: Possible use of deprecated methods: `Both.__new__`, `Both.__init__` + --> src/mdtest_snippet.py:12:1 + | +12 | Both() + | ^^^^ +info: old new + --> src/mdtest_snippet.py:5:9 + | +5 | def __new__(cls) -> Self: + | ^^^^^^^ +info: old init + --> src/mdtest_snippet.py:9:9 + | +9 | def __init__(self) -> None: ... + | ^^^^^^^^ +``` + +### Metaclass `__call__` + +Calling a class with a custom metaclass invokes the metaclass's `__call__` method. If that method is +deprecated, the class call produces a warning. + +```py +from typing_extensions import deprecated + +class Meta(type): + @deprecated("metaclass call") + def __call__(cls) -> int: + return 0 + +class WithMeta(metaclass=Meta): ... + +WithMeta() # error: [deprecated] "metaclass call" +``` + +## Calls to an inherited deprecated method + +When both union members inherit the same deprecated method, a call reports that method once. + +```py +from typing_extensions import deprecated + +class Base: + @deprecated("base call") + def __call__(self) -> None: ... + +class First(Base): ... +class Second(Base): ... + +def check(value: First | Second): + value() # error: [deprecated] "base call" +``` + +Separate calls each produce a warning, even when they are on the same line. + +```py +def two_calls(value: First | Second): + # error: 6 [deprecated] "base call" + # error: 15 [deprecated] "base call" + (value(), value()) +``` + +## Calls to an inherited deprecated overload + +A deprecated overload inherited by both members of a union also produces only one warning per call. + +```py +from typing import overload +from typing_extensions import deprecated + +class Overloaded: + @overload + @deprecated("integer call") + def __call__(self, value: int) -> None: ... + @overload + def __call__(self, value: str) -> None: ... + def __call__(self, value: int | str) -> None: ... + +class FirstOverload(Overloaded): ... +class SecondOverload(Overloaded): ... + +def check_overload(value: FirstOverload | SecondOverload): + value(1) # error: [deprecated] "integer call" + value("one") +``` + +## Suppressing call deprecations + +An inline ignore suppresses the deprecation on an instance's implicit `__call__` invocation. + +```py +from typing_extensions import deprecated + +class Callable: + @deprecated("do not call") + def __call__(self) -> None: ... + +Callable()() # ty: ignore[deprecated] +``` + +An unreachable call does not produce a warning. + +```py +if False: + Callable()() +``` + +The same applies to calls inside a `no_type_check` function. + +```py +from typing import no_type_check + +@no_type_check +def unchecked(value: Callable): + value() +``` + +## Repeated binary operations + +Several combinations of union members can invoke the same deprecated operator. Each expression +reports that method once. Repeating the operation still produces a warning at the second expression. + +```py +from typing_extensions import Self, deprecated + +class Number: + @deprecated("addition") + def __add__(self, other: int | str) -> Self: + return self + +class First(Number): ... +class Second(Number): ... + +def check(number: First | Second, value: int | str): + number + value # error: [deprecated] "addition" + number + value # error: [deprecated] "addition" +``` + +The same rule applies when augmented assignment falls back to `__add__`. + +```py +def check_augmented(number: First | Second, value: int | str): + number += value # error: [deprecated] "addition" +``` + +When some members provide `__iadd__` and others fall back to `__add__`, the warning includes the +deprecations of both methods. + +```py +class InPlace(Number): + @deprecated("in-place addition") + def __iadd__(self, other: int | str) -> Self: + return self + +def check_mixed(number: First | InPlace, value: int | str): + # error: [deprecated] "`Number.__add__`, `InPlace.__iadd__`" + number += value +``` + +## Calls to different deprecated methods + +Deprecation messages can contain several sentences, semicolons, and line breaks. + +```py +from typing_extensions import deprecated + +class First: + @deprecated("Use `invoke` instead. Direct calls are deprecated; support ends in version 2.") + def __call__(self) -> None: ... + +class Second: + @deprecated("Use `invoke` instead.\nSupport ends in version 3.") + def __call__(self) -> None: ... +``` + +When either method can be called, the warning names both defining classes. The full diagnostic shows +each message beside its method's definition, preserving its punctuation and line breaks. + +```py +def check(value: First | Second): + # snapshot: deprecated + value() +``` + +```snapshot +warning[deprecated]: Possible use of deprecated methods: `First.__call__`, `Second.__call__` + --> src/mdtest_snippet.py:12:5 + | +12 | value() + | ^^^^^ +info: Use `invoke` instead. Direct calls are deprecated; support ends in version 2. + --> src/mdtest_snippet.py:5:9 + | +5 | def __call__(self) -> None: ... + | ^^^^^^^^ +info: Use `invoke` instead. +Support ends in version 3. + --> src/mdtest_snippet.py:9:9 + | +9 | def __call__(self) -> None: ... + | ^^^^^^^^ +``` + +## Calls to deprecated generic methods + +The warning includes the defining class's name even when both the class and method have type +parameters. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing_extensions import deprecated + +class First[T]: + @deprecated("first method") + def __call__[U](self, value: U) -> U: + return value + +class Second: + @deprecated("second method") + def __call__(self, value: int) -> int: + return value -f(1) # error: [deprecated] "unusable" -f("hello") # error: [deprecated] "unusable" +def check(value: First[int] | Second): + # error: [deprecated] "Possible use of deprecated methods: `First.__call__`, `Second.__call__`" + value(1) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md index 3dd52380d8..176a97b4cb 100644 --- a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md +++ b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md @@ -890,6 +890,30 @@ c.name = None c.name = 42 ``` +### Writing to a property's documentation + +A property stores its documentation in a writable descriptor even though property instances do not +have an instance dictionary. + +```py +class Example: + @property + def value(self) -> int: + return 1 + + value.__doc__ = "Updated documentation" +``` + +A property created directly has the same writable `__doc__` attribute. Assignments must still +respect its `str | None` annotation, and arbitrary instance attributes remain unsupported. + +```py +descriptor = property(lambda instance: 1) +descriptor.__doc__ = None +descriptor.__doc__ = 1 # error: [invalid-assignment] +descriptor.extra = 1 # error: [unresolved-attribute] +``` + ### Overriding properties in subclasses When a subclass overrides a property, accessing other inherited properties from within the @@ -1195,7 +1219,7 @@ python-version = "3.12" ``` ```py -type Recursive = int | Recursive +type Recursive = int | Recursive # error: [cyclic-type-alias-definition] class C: value: Recursive = 1 @@ -1203,10 +1227,10 @@ class C: C().value ``` -### Property getters reject invalid receiver specializations +### Property getters do not infer fixed owner type variables -A property getter checks the same specialized receiver as an ordinary method. A generic alias with -alternatives that impose different type-variable bounds can produce an invalid property access. +A property getter treats type variables fixed by the owner specialization as evidence, not as +inference targets. ```py from collections.abc import Callable @@ -1229,9 +1253,11 @@ AnyCallback = TypeVar("AnyCallback", bound=Callable[..., str]) Command = A[AnyCallback] | B[AnyCallback] Callback = TypeVar("Callback", bound=Callable[[int], str]) +# TODO: `Command[Callback]` produces `B[Callback]`, but `Callback` does not satisfy `BItem`'s +# upper bound. Report this at `Command[Callback]` once specialization validation can prove that +# every possible specialization of a symbolic assignment satisfies the destination domain. def access(value: Callback | Command[Callback]) -> None: if isinstance(value, A | B): - # error: [invalid-attribute-access] value.callback ``` @@ -1358,6 +1384,37 @@ def descriptor_value(descriptor: Descriptor) -> None: C().value ``` +### Intersection receivers preserve their complete owner type + +A descriptor can require its owner to satisfy both classes in an intersection. + +```py +from __future__ import annotations + +class Descriptor: + def __get__(self, instance: object, owner: type[Left] & type[Right]) -> int: + return 1 + +class Left: + value = Descriptor() + +class Right: ... + +def receiver(value: Left & Right) -> None: + # Only `Left` supplies the descriptor, but its owner must retain `Right` too. + # Passing `type[Left]` instead of `type[Left] & type[Right]` would cause an + # `invalid-attribute-access` error. + reveal_type(value.value) # revealed: int +``` + +A receiver known only to be `Left` does not satisfy the descriptor's owner type. + +```py +def incomplete_owner(value: Left) -> None: + # error: [invalid-attribute-access] "Expected `type[Left] & type[Right]`, found `type[Left]`" + value.value +``` + ### Every `__get__` definition must accept the call A conditionally defined method can have several callable signatures. The access is invalid if any @@ -1864,6 +1921,29 @@ class Example: pass ``` +An invalid descriptor receiver must not discard the inferred `ParamSpec` for its bound callable. +Even though `Concatenate` makes the receiver positional-only, the remaining parameters still retain +their precise types. + +```py +class Decorator(Generic[P]): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + def __get__(self: "Decorator[Concatenate[Any, P2]]", instance: Any, owner: Any) -> "Decorator[P2]": + raise NotImplementedError + +def decorate(fn: Callable[P, Any]) -> Decorator[P]: + raise NotImplementedError + +class Decorated: + @decorate + def method(self, value: str) -> None: ... + +# error: [invalid-attribute-access] +bound = Decorated().method +reveal_type(bound) # revealed: Decorator[(value: str)] +bound(1) # error: [invalid-argument-type] +``` + [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 diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_assignment.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_assignment.md index cc5ded04bf..7c3dc19bb2 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_assignment.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_assignment.md @@ -210,6 +210,44 @@ error[unresolved-attribute]: Unresolved attribute `non_existent` on type `C` | ^^^^^^^^^^^^^^^^^^^^^ ``` +## Attributes declared without instance storage + +An instance attribute annotation does not create storage. Assigning to an attribute declared on a +slotted class explains that the class has neither a matching slot nor an instance dictionary. + +```py +class Slotted: + value: int + __slots__ = () + +Slotted().value = 1 # snapshot: missing-slot +``` + +```snapshot +error[missing-slot]: Cannot assign to attribute `value`: `Slotted` has no slot or instance dictionary + --> src/mdtest_snippet.py:5:1 + | +5 | Slotted().value = 1 # snapshot: missing-slot + | ^^^^^^^^^^^^^^^ +info: Attribute `value` is declared but is not included in `__slots__` +``` + +A genuinely undeclared attribute keeps the ordinary unresolved-attribute diagnostic. + +```py +Slotted().missing = 1 # error: [unresolved-attribute] "Unresolved attribute `missing` on type `Slotted`" +``` + +An inherited annotation also does not provide storage for a slotted subclass. + +```py +class SlottedChild(Slotted): + __slots__ = () + +# error: [missing-slot] "Cannot assign to attribute `value`: `SlottedChild` has no slot or instance dictionary" +SlottedChild().value = 1 +``` + ## Possibly-missing attributes When trying to set an attribute that is not defined in all branches, we emit errors: diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/error_context.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/error_context.md index b2ff146490..0076213894 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/error_context.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/error_context.md @@ -210,6 +210,59 @@ error[invalid-assignment]: Object of type `tuple[int, str]` is not assignable to info: a tuple of length 2 is not assignable to a tuple of length 3 ``` +## Repeated successful comparisons before a mismatch + +Successful comparisons remain memoized while collecting context for a later mismatch. Fully +expanding the first tuple element below would produce over a million leaves, but the diagnostic can +identify the incompatible second element without visiting every repeated occurrence. + +```py +type Pair[T] = tuple[T, T] +type Quad[T] = Pair[Pair[T]] +type Sixteen[T] = Quad[Quad[T]] +type Nested[T] = Sixteen[Sixteen[Sixteen[Sixteen[Sixteen[T]]]]] + +def check(source: tuple[Nested[int], int]) -> tuple[Nested[object], str]: + return source # snapshot: invalid-return-type +``` + +```snapshot +error[invalid-return-type]: Return type does not match returned value + --> src/mdtest_snippet.py:7:12 + | +6 | def check(source: tuple[Nested[int], int]) -> tuple[Nested[object], str]: + | -------------------------- Expected `tuple[Nested[object], str]` because of return type +7 | return source # snapshot: invalid-return-type + | ^^^^^^ expected `tuple[Nested[object], str]`, found `tuple[Nested[int], int]` +info: the second tuple element is not compatible: `int` is not assignable to `str` +``` + +An explicit receiver can leave successful comparisons conditional on a type variable. Comparing the +first parameters below produces a satisfiable constraint on `S`. We reuse that result while +reporting the incompatible `y` parameter. + +```py +from typing import Callable + +class Receiver: + def method[S](self: S, x: Nested[S], y: int) -> int: + return 0 + +def check_receiver(receiver: Receiver) -> Callable[[Nested[int], str], int]: + return receiver.method # snapshot: invalid-return-type +``` + +```snapshot +error[invalid-return-type]: Return type does not match returned value + --> src/mdtest_snippet.py:15:12 + | +14 | def check_receiver(receiver: Receiver) -> Callable[[Nested[int], str], int]: + | --------------------------------- Expected `(Nested[int], str, /) -> int` because of return type +15 | return receiver.method # snapshot: invalid-return-type + | ^^^^^^^^^^^^^^^ expected `(Nested[int], str, /) -> int`, found `bound method Receiver.method[S](x: Nested[S], y: int) -> int` +info: the second parameter has an incompatible type: `str` is not assignable to `int` +``` + ## `Callable` Assigning a function to a `Callable` @@ -451,9 +504,8 @@ info: the first parameter is missing ## Missing parameters in nested generic calls involving `TypeVarTuple`s and `ParamSpec`s -In the following example, the signature of the `callback` function does not satisfy the `fn` -parameter of `wrapper` in the `accept()` call, because the arguments provided to `accept()` -following `fn` indicate that it must accept the value `1` as a positional argument, and it does not. +In the following example, the arguments provided to the `accept()` call are not accepted by the +signature of the `callback` function, and so we report an error on the outer call. We don't currently add error context in this code path, but we could add it in the future: @@ -466,20 +518,20 @@ def wrapper1[**P](fn: Callable[P, None]) -> Callable[P, None]: def accept1[**P](fn: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... def callback1() -> None: ... -accept1(wrapper1(callback1), 1) # snapshot: invalid-argument-type +accept1(wrapper1(callback1), 1) # snapshot: too-many-positional-arguments ``` ```snapshot -error[invalid-argument-type]: Argument to function `wrapper1` is incorrect - --> src/mdtest_snippet.py:9:18 +error[too-many-positional-arguments]: Too many positional arguments to function `accept1`: expected 0, got 1 + --> src/mdtest_snippet.py:9:30 | -9 | accept1(wrapper1(callback1), 1) # snapshot: invalid-argument-type - | ^^^^^^^^^ Expected `(**P@accept1) -> None`, found `def callback1()` -info: Function defined here - --> src/mdtest_snippet.py:3:5 +9 | accept1(wrapper1(callback1), 1) # snapshot: too-many-positional-arguments + | ^ +info: Function signature here + --> src/mdtest_snippet.py:6:5 | -3 | def wrapper1[**P](fn: Callable[P, None]) -> Callable[P, None]: - | ^^^^^^^^ --------------------- Parameter declared here +6 | def accept1[**P](fn: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``` The following case is similar, but exercises a different code path. Here, we could also add error @@ -492,24 +544,26 @@ def wrapper2[**P](fn: Callable[P, None]) -> Callable[P, None]: def accept2[**P](fn: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... def callback2(**kwargs: int) -> None: ... -accept2(wrapper2(callback2), 1) # snapshot: invalid-argument-type +accept2(wrapper2(callback2), 1) # snapshot: too-many-positional-arguments ``` ```snapshot -error[invalid-argument-type]: Argument to function `wrapper2` is incorrect - --> src/mdtest_snippet.py:16:18 +error[too-many-positional-arguments]: Too many positional arguments to function `accept2`: expected 0, got 1 + --> src/mdtest_snippet.py:16:30 | -16 | accept2(wrapper2(callback2), 1) # snapshot: invalid-argument-type - | ^^^^^^^^^ Expected `(**P@accept2) -> None`, found `def callback2(**kwargs: int)` -info: Function defined here - --> src/mdtest_snippet.py:10:5 +16 | accept2(wrapper2(callback2), 1) # snapshot: too-many-positional-arguments + | ^ +info: Function signature here + --> src/mdtest_snippet.py:13:5 | -10 | def wrapper2[**P](fn: Callable[P, None]) -> Callable[P, None]: - | ^^^^^^^^ --------------------- Parameter declared here +13 | def accept2[**P](fn: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``` -And the same applies to the following two examples too, which both use a `TypeVarTuple` instead of a -`ParamSpec`: +The following examples use a `TypeVarTuple` instead of a `ParamSpec`. In this case, we forward the +specialization of the outer `TypeVarTuple` to the inner call, and so report `callback3` as being +incompatible with the signature of `wrapper3`, instead of the provided arguments being incompatible +with the signature of `accept3`. ```py def wrapper3[*Ts](fn: Callable[[*Ts], None]) -> Callable[[*Ts], None]: @@ -796,7 +850,7 @@ error[invalid-assignment]: Object of type `PersonWithAge` is not assignable to ` | | | Declared type info: field "age" is required in TypedDict `PersonWithAge` but not required and mutable in TypedDict `PersonWithOptionalAge` -help: The required field could be removed through a destructive operation like `del` on the target. +help: The required field could be removed through a destructive operation like `del` on the target ``` Assigning a `TypedDict` to a `dict` @@ -815,8 +869,8 @@ error[invalid-assignment]: Object of type `Person` is not assignable to `dict[st | | | Declared type info: TypedDict `Person` is not assignable to `dict` -help: A TypedDict is not usually assignable to any `dict[..]` type; `dict` types allow destructive operations like `clear()`. -help: Consider using `Mapping[..]` instead of `dict[..]`. +help: A TypedDict is not usually assignable to any `dict[..]` type; `dict` types allow destructive operations like `clear()` +help: Consider using `Mapping[..]` instead of `dict[..]` ``` Assigning an open `TypedDict` to a specialized `Mapping`: @@ -842,18 +896,51 @@ error[invalid-return-type]: Return type does not match returned value 40 | return d # snapshot | ^ expected `Mapping[str, int]`, found `D` info: TypedDict `D` is not assignable to `Mapping[str, int]` -help: `D` would be assignable to this `Mapping` type if it were declared with `closed=True`, but TypedDicts are open by default. -help: A subclass of `D` could validly add a new field of an arbitrary type, violating subtyping with the `Mapping` type +help: `D` would be assignable to `Mapping[str, int]` if it were declared with `closed=True`, but TypedDicts are open by default +help: A subclass of `D` could validly add a new field of an arbitrary type, violating subtyping with `Mapping[str, int]` +``` + +## Open `TypedDict` and a union of specialized mappings + +Each mapping in a union receives its own explanation when an open `TypedDict` is incompatible with +every alternative. + +```py +from collections.abc import Mapping +from typing import TypedDict + +class Empty(TypedDict): + pass + +def _(value: Empty) -> Mapping[str, int] | Mapping[str, str]: + return value # snapshot +``` + +```snapshot +error[invalid-return-type]: Return type does not match returned value + --> src/mdtest_snippet.py:8:12 + | +7 | def _(value: Empty) -> Mapping[str, int] | Mapping[str, str]: + | ------------------------------------- Expected `Mapping[str, int] | Mapping[str, str]` because of return type +8 | return value # snapshot + | ^^^^^ expected `Mapping[str, int] | Mapping[str, str]`, found `Empty` +info: type `Empty` is not assignable to any element of the union `Mapping[str, int] | Mapping[str, str]` +info: ├── TypedDict `Empty` is not assignable to `Mapping[str, int]` +info: └── TypedDict `Empty` is not assignable to `Mapping[str, str]` +help: `Empty` would be assignable to `Mapping[str, int]` if it were declared with `closed=True`, but TypedDicts are open by default +help: A subclass of `Empty` could validly add a new field of an arbitrary type, violating subtyping with `Mapping[str, int]` +help: `Empty` would be assignable to `Mapping[str, str]` if it were declared with `closed=True`, but TypedDicts are open by default +help: A subclass of `Empty` could validly add a new field of an arbitrary type, violating subtyping with `Mapping[str, str]` ``` ## Generic `TypedDict` field conflicts in overload diagnostics -A generic `TypedDict` relation can be unsatisfiable without being the `never` terminal. The -resulting overload diagnostic should still explain which field introduced the conflicting -constraints. +A generic `TypedDict` relation can be unsatisfiable without being the `never` terminal. Capturing +its type variable from an enclosing function keeps the overload non-generic while retaining the +conflicting constraints. The resulting diagnostic should explain which field introduced them. ```py -from typing import Generic, Self, TypeVar, TypedDict, overload +from typing import Generic, TypeVar, TypedDict, overload T = TypeVar("T") @@ -865,38 +952,39 @@ class Fixed(TypedDict): first: int second: str -class OverloadedSelf: +def outer(value: T) -> None: @overload - def method(self, value: Fixed) -> None: ... # snapshot: invalid-overload + def inner(value: Fixed) -> None: ... # snapshot: invalid-overload @overload - def method(self, value: str) -> None: ... - def method(self, value: Pair[Self] | str) -> None: ... + def inner(value: str) -> None: ... + def inner(value: Pair[T] | str) -> None: ... ``` ```snapshot error[invalid-overload]: Implementation does not accept all arguments of this overload --> src/mdtest_snippet.py:15:9 | -15 | def method(self, value: Fixed) -> None: ... # snapshot: invalid-overload - | ^^^^^^ +15 | def inner(value: Fixed) -> None: ... # snapshot: invalid-overload + | ^^^^^ 16 | @overload -17 | def method(self, value: str) -> None: ... -18 | def method(self, value: Pair[Self] | str) -> None: ... - | ------ Implementation defined here -info: Implementation signature `(self, value: Pair[Self@method] | str) -> None` is not assignable to overload signature `(self, value: Fixed) -> None` -info: parameter `value` has an incompatible type: `Fixed` is not assignable to `Pair[Self@method] | str` -info: └── type `Fixed` is not assignable to any element of the union `Pair[Self@method] | str` -info: ├── field "second" on TypedDict `Fixed` has type `str` which is not assignable to type `Self@method` expected by TypedDict `Pair` +17 | def inner(value: str) -> None: ... +18 | def inner(value: Pair[T] | str) -> None: ... + | ----- Implementation defined here +info: Implementation signature `(value: Pair[T@outer] | str) -> None` is not assignable to overload signature `(value: Fixed) -> None` +info: parameter `value` has an incompatible type: `Fixed` is not assignable to `Pair[T@outer] | str` +info: └── type `Fixed` is not assignable to any element of the union `Pair[T@outer] | str` +info: ├── field "second" on TypedDict `Fixed` has type `str` which is not assignable to type `T@outer` expected by TypedDict `Pair` info: └── ... omitted 1 union element without additional context ``` ## Stop checking callable parameters after incompatible generic constraints Once earlier parameters produce an unsatisfiable nonterminal constraint set, continuing to a later -parameter must not replace the diagnostic context that explains the original incompatibility. +parameter must not replace the diagnostic context that explains the original incompatibility. The +type variable belongs to the enclosing function, so the overload itself remains non-generic. ```py -from typing import Generic, Self, TypeVar, TypedDict, overload +from typing import Generic, TypeVar, TypedDict, overload T = TypeVar("T") @@ -908,28 +996,28 @@ class Fixed(TypedDict): first: int second: str -class OverloadedSelf: +def outer(value: T) -> None: @overload - def method(self, value: Fixed, later: int) -> None: ... # snapshot: invalid-overload + def inner(value: Fixed, later: int) -> None: ... # snapshot: invalid-overload @overload - def method(self, value: str, later: str) -> None: ... - def method(self, value: Pair[Self] | str, later: str) -> None: ... + def inner(value: str, later: str) -> None: ... + def inner(value: Pair[T] | str, later: str) -> None: ... ``` ```snapshot error[invalid-overload]: Implementation does not accept all arguments of this overload --> src/mdtest_snippet.py:15:9 | -15 | def method(self, value: Fixed, later: int) -> None: ... # snapshot: invalid-overload - | ^^^^^^ +15 | def inner(value: Fixed, later: int) -> None: ... # snapshot: invalid-overload + | ^^^^^ 16 | @overload -17 | def method(self, value: str, later: str) -> None: ... -18 | def method(self, value: Pair[Self] | str, later: str) -> None: ... - | ------ Implementation defined here -info: Implementation signature `(self, value: Pair[Self@method] | str, later: str) -> None` is not assignable to overload signature `(self, value: Fixed, later: int) -> None` -info: parameter `value` has an incompatible type: `Fixed` is not assignable to `Pair[Self@method] | str` -info: └── type `Fixed` is not assignable to any element of the union `Pair[Self@method] | str` -info: ├── field "second" on TypedDict `Fixed` has type `str` which is not assignable to type `Self@method` expected by TypedDict `Pair` +17 | def inner(value: str, later: str) -> None: ... +18 | def inner(value: Pair[T] | str, later: str) -> None: ... + | ----- Implementation defined here +info: Implementation signature `(value: Pair[T@outer] | str, later: str) -> None` is not assignable to overload signature `(value: Fixed, later: int) -> None` +info: parameter `value` has an incompatible type: `Fixed` is not assignable to `Pair[T@outer] | str` +info: └── type `Fixed` is not assignable to any element of the union `Pair[T@outer] | str` +info: ├── field "second" on TypedDict `Fixed` has type `str` which is not assignable to type `T@outer` expected by TypedDict `Pair` info: └── ... omitted 1 union element without additional context ``` @@ -1238,6 +1326,216 @@ info: └── protocol member `check` is incompatible info: └── parameter `y` has an incompatible type: `str` is not assignable to `bytes` ``` +## Recursive protocol diagnostic context + +A value-constrained type parameter makes the recursive `child` override valid for each permitted +specialization. The explicit receiver on `Outer.expose` preserves unresolved type variables while +diagnostic context is collected, including the incompatible recursive member and its return type. + +```py +from __future__ import annotations + +from typing import Protocol + +class Chain[T](Protocol): + def child(self) -> Chain[T]: ... + def value(self) -> T: ... + +class Outer[T](Protocol): + def expose(self: Outer[T]) -> Chain[T]: ... + +class Concrete[T: (str, object)](Chain[T], Outer[T]): + def child(self) -> Concrete[str]: + raise NotImplementedError + + def expose(self: Outer[T]) -> Concrete[T]: + raise NotImplementedError + +def diagnose[T: (str, object)](value: Concrete[T]) -> None: + invalid: Outer[int] = value # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Concrete[T@diagnose]` is not assignable to `Outer[int]` + --> src/mdtest_snippet.py:20:27 + | +20 | invalid: Outer[int] = value # snapshot: invalid-assignment + | ---------- ^^^^^ Incompatible value of type `Concrete[T@diagnose]` + | | + | Declared type +info: type `Concrete[T@diagnose]` is not assignable to protocol `Chain[int]` +info: └── protocol member `child` is incompatible +info: └── incompatible return types: `Concrete[str]` is not assignable to `Chain[int]` +info: └── type `Concrete[str]` is not assignable to protocol `Chain[int]` +info: └── protocol member `value` is incompatible +info: └── incompatible return types: `str` is not assignable to `int` +``` + +## Recursive protocols in a union after overload comparison + +A recursive callable protocol can be incompatible with more than one member of a target union. The +diagnostic includes the nested `payload` mismatch for `HasPacket[str]`, even if the same member +types were already compared when checking the callable overloads. + +```py +from __future__ import annotations + +from typing import Callable, Protocol, overload + +class Packet[T](Protocol): + payload: T + +class HasPacket[T](Protocol): + def packet(self) -> Packet[T]: ... + +class Source[T](HasPacket[T], Protocol): + @overload + def __call__(self, x: int) -> Source[T]: ... + @overload + def __call__(self, x: str) -> Source[tuple[T]]: ... + +def check(source: Source[bytes]) -> Callable[[int], Source[str]] | HasPacket[str]: + return source # snapshot: invalid-return-type +``` + +```snapshot +error[invalid-return-type]: Return type does not match returned value + --> src/mdtest_snippet.py:18:12 + | +17 | def check(source: Source[bytes]) -> Callable[[int], Source[str]] | HasPacket[str]: + | --------------------------------------------- Expected `((int, /) -> Source[str]) | HasPacket[str]` because of return type +18 | return source # snapshot: invalid-return-type + | ^^^^^^ expected `((int, /) -> Source[str]) | HasPacket[str]`, found `Source[bytes]` +info: type `Source[bytes]` is not assignable to any element of the union `((int, /) -> Source[str]) | HasPacket[str]` +info: ├── type `Source[bytes]` has inferred callable type `Overload[(x: int) -> Source[bytes], (x: str) -> Source[tuple[bytes]]]` +info: └── protocol `Source[bytes]` is not assignable to protocol `HasPacket[str]` +info: └── protocol member `packet` is incompatible +info: └── incompatible return types: `Packet[bytes]` is not assignable to `Packet[str]` +info: └── protocol `Packet[bytes]` is not assignable to protocol `Packet[str]` +info: └── protocol member `payload` is incompatible +info: └── read type `bytes` is not assignable to `str` +``` + +## Protocol method parameter names + +Assignability errors against protocols are often caused because a method in the protocol class +should have used positional-only parameters, but didn't. In this situation, we point out the likely +cause of the assignability error in a dedicated `help:` message that points out that the issue may +be due to the protocol itself rather than the type being assigned to the protocol: + +```py +from typing import Protocol + +class Target(Protocol): + def run(self, expected: int) -> None: ... + +class Source: + def run(self, actual: int) -> None: ... + +target: Target = Source() # snapshot +``` + +```snapshot +error[invalid-assignment]: Object of type `Source` is not assignable to `Target` + --> src/mdtest_snippet.py:9:18 + | +9 | target: Target = Source() # snapshot + | ------ ^^^^^^^^ Incompatible value of type `Source` + | | + | Declared type +info: type `Source` is not assignable to protocol `Target` +info: └── protocol member `run` is incompatible +info: └── the parameter named `actual` does not match `expected` (and can be used as a keyword parameter) +help: `Source` might be assignable to `Target` if the parameter `expected` were made positional-only in `Target.run` +``` + +The same suggestion applies for the case where a positional-or-keyword parameter was apparently +demanded by a protocol member, but only a positional-only parameter was supplied in the type that +was assigned to the protocol: + +```py +class Target2(Protocol): + def run(self, expected: int) -> None: ... + +class Source2: + def run(self, actual: int, /) -> None: ... + +target: Target2 = Source2() # snapshot +``` + +```snapshot +error[invalid-assignment]: Object of type `Source2` is not assignable to `Target2` + --> src/mdtest_snippet.py:16:19 + | +16 | target: Target2 = Source2() # snapshot + | ------- ^^^^^^^^^ Incompatible value of type `Source2` + | | + | Declared type +info: type `Source2` is not assignable to protocol `Target2` +info: └── protocol member `run` is incompatible +info: └── parameter `actual` is positional-only but must also accept keyword arguments +help: `Source2` might be assignable to `Target2` if the parameter `expected` were made positional-only in `Target2.run` +``` + +Making a parameter positional-only resolves a name mismatch but does not necessarily make the method +compatible, because its parameter type can still be incorrect. For this reason, we hedge our bets a +little in our `help:` message (we say "*might* be assignable", rather than "*will* be assignable"): + +```py +class Target3(Protocol): + def run(self, expected: int) -> None: ... + +class Source3: + def run(self, actual: str) -> None: ... + +target: Target3 = Source3() # snapshot +``` + +```snapshot +error[invalid-assignment]: Object of type `Source3` is not assignable to `Target3` + --> src/mdtest_snippet.py:23:19 + | +23 | target: Target3 = Source3() # snapshot + | ------- ^^^^^^^^^ Incompatible value of type `Source3` + | | + | Declared type +info: type `Source3` is not assignable to protocol `Target3` +info: └── protocol member `run` is incompatible +info: └── the parameter named `actual` does not match `expected` (and can be used as a keyword parameter) +help: `Source3` might be assignable to `Target3` if the parameter `expected` were made positional-only in `Target3.run` +``` + +Suggestions for inherited protocol methods name the protocol that actually declares the method. + +```py +from typing import Protocol + +class Parent(Protocol): + def run(self, expected: int) -> None: ... + +class Child(Parent, Protocol): + pass + +class Source4: + def run(self, actual: int) -> None: ... + +target: Child = Source4() # snapshot +``` + +```snapshot +error[invalid-assignment]: Object of type `Source4` is not assignable to `Child` + --> src/mdtest_snippet.py:35:17 + | +35 | target: Child = Source4() # snapshot + | ----- ^^^^^^^^^ Incompatible value of type `Source4` + | | + | Declared type +info: type `Source4` is not assignable to protocol `Child` +info: └── protocol member `run` is incompatible +info: └── the parameter named `actual` does not match `expected` (and can be used as a keyword parameter) +help: `Source4` might be assignable to `Child` if the parameter `expected` were made positional-only in `Parent.run` +``` + ## Type aliases Type aliases should be expanded in diagnostics to understand the underlying incompatibilities: @@ -1441,6 +1739,7 @@ error[invalid-assignment]: Object of type `IncompatibleFoo` is not assignable to info: type `IncompatibleFoo` is not assignable to protocol `SupportsFooAndBar` info: └── protocol member `foo` is incompatible info: └── the parameter named `name_` does not match `name` (and can be used as a keyword parameter) +help: `IncompatibleFoo` might be assignable to `SupportsFooAndBar` if the parameter `name` were made positional-only in `SupportsFooAndBar.foo` ``` ## Assigning to `Iterable` diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment_syntactic_variants.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment_syntactic_variants.md index ee3be82f45..d6dc9659f1 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment_syntactic_variants.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment_syntactic_variants.md @@ -28,17 +28,197 @@ x: int x = "three" # snapshot: invalid-assignment ``` -Here, we could ideally point to the annotation as well, but for now, we just call out the declared -type in an annotation on the variable name: +The diagnostic points to the earlier type annotation as well as the incompatible value: ```snapshot error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` --> src/mdtest_snippet.py:2:5 | +1 | x: int + | --- Declared type 2 | x = "three" # snapshot: invalid-assignment - | - ^^^^^^^ Incompatible value of type `Literal["three"]` - | | - | Declared type `int` + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Previously initialized declaration + +The original annotation remains the source of the declared type after the variable has been +initialized. + +```py +x: int = 1 +x = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:2:5 + | +1 | x: int = 1 + | --- Declared type +2 | x = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Global declaration + +An assignment to a global variable points to the annotation in its defining scope. + +```py +x: int + +def assign() -> None: + global x + x = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:5:9 + | +1 | x: int + | --- Declared type +2 | +3 | def assign() -> None: +4 | global x +5 | x = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Annotated parameter + +An incompatible assignment to an annotated parameter points to the parameter's type annotation. + +```py +def assign(value: int) -> None: + value = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:2:13 + | +1 | def assign(value: int) -> None: + | --- Declared type +2 | value = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Variadic positional parameter + +A variadic positional parameter's annotation describes its arguments, while the parameter itself is +a tuple. + +```py +def assign(*values: int) -> None: + values = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `tuple[int, ...]` + --> src/mdtest_snippet.py:2:14 + | +1 | def assign(*values: int) -> None: + | --- Variadic parameter annotation declares the type as `tuple[int, ...]` +2 | values = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Variadic keyword parameter + +A variadic keyword parameter's annotation describes its values, while the parameter itself is a +dictionary. + +```py +def assign(**values: int) -> None: + values = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `dict[str, int]` + --> src/mdtest_snippet.py:2:14 + | +1 | def assign(**values: int) -> None: + | --- Keyword-variadic parameter annotation declares the type as `dict[str, int]` +2 | values = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Nonlocal declaration + +An assignment to a nonlocal variable points to the annotation in its enclosing scope. + +```py +def outer() -> None: + x: int = 1 + + def assign() -> None: + nonlocal x + x = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:6:13 + | +2 | x: int = 1 + | --- Declared type +3 | +4 | def assign() -> None: +5 | nonlocal x +6 | x = "three" # snapshot: invalid-assignment + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## Conflicting declarations + +When conflicting annotations contribute to the declared type, the diagnostic does not identify any +one annotation as the declared type. + +```py +def assign(flag: bool) -> None: + if flag: + x: int + else: + x: str + + # error: [conflicting-declarations] + x = b"three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal[b"three"]` is not assignable to `int | str` + --> src/mdtest_snippet.py:8:9 + | +8 | x = b"three" # snapshot: invalid-assignment + | - ^^^^^^^^ Incompatible value of type `Literal[b"three"]` + | | + | Declared type `int | str` +``` + +## Equivalent declarations + +When distinct branches declare the same type, neither annotation is the unique source of the +declared type. + +```py +def assign(flag: bool) -> None: + if flag: + x: int + else: + x: int + + x = "three" # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:7:9 + | +7 | x = "three" # snapshot: invalid-assignment + | - ^^^^^^^ Incompatible value of type `Literal["three"]` + | | + | Declared type `int` ``` ## Named expression @@ -49,16 +229,83 @@ x: int (x := "three") # snapshot: invalid-assignment ``` -Similar here, we could ideally point to the type annotation: - ```snapshot error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` --> src/mdtest_snippet.py:3:7 | +1 | x: int + | --- Declared type +2 | 3 | (x := "three") # snapshot: invalid-assignment - | - ^^^^^^^ Incompatible value of type `Literal["three"]` - | | - | Declared type `int` + | ^^^^^^^ Incompatible value of type `Literal["three"]` +``` + +## For-loop target + +```py +value: int + +for value in ["three"]: # snapshot: invalid-assignment + pass +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int` + --> src/mdtest_snippet.py:3:5 + | +1 | value: int + | --- Declared type +2 | +3 | for value in ["three"]: # snapshot: invalid-assignment + | ^^^^^ +``` + +## Context manager target + +```py +from contextlib import nullcontext + +value: int + +with nullcontext("three") as value: # snapshot: invalid-assignment + pass +``` + +```snapshot +error[invalid-assignment]: Object of type `str` is not assignable to `int` + --> src/mdtest_snippet.py:5:30 + | +3 | value: int + | --- Declared type +4 | +5 | with nullcontext("three") as value: # snapshot: invalid-assignment + | ^^^^^ +``` + +## Augmented assignment + +```py +value: int = 1 +value += 1.0 # snapshot: invalid-assignment + +reveal_type(value) # revealed: int +``` + +```snapshot +error[invalid-assignment]: Object of type `float` is not assignable to `int` + --> src/mdtest_snippet.py:2:1 + | +1 | value: int = 1 + | --- Declared type +2 | value += 1.0 # snapshot: invalid-assignment + | ^^^^^ Augmented assignment produces a value of type `float` +``` + +The concise diagnostic reports the incompatible assignment: + +```py +# error: [invalid-assignment] "Object of type `float` is not assignable to `int`" +value += 1.0 ``` ## Multiline expressions @@ -91,6 +338,8 @@ error[invalid-assignment]: Object of type `Literal[15]` is not assignable to `st ## Multiple targets +An unpacked assignment points to the particular value assigned to each incompatible target. + ```py x: int y: str @@ -100,26 +349,225 @@ x, y = ("a", "b") # snapshot: invalid-assignment x, y = (0, 0) # snapshot: invalid-assignment ``` -TODO: the right hand side annotation should ideally only point to the `"a"` part of the `("a", "b")` -tuple: - ```snapshot error[invalid-assignment]: Object of type `Literal["a"]` is not assignable to `int` - --> src/mdtest_snippet.py:4:8 + --> src/mdtest_snippet.py:4:9 | +1 | x: int + | --- Declared type +2 | y: str +3 | 4 | x, y = ("a", "b") # snapshot: invalid-assignment - | - ^^^^^^^^^^ Incompatible value of type `Literal["a"]` + | - ^^^ Incompatible value of type `Literal["a"]` | | - | Declared type `int` + | Assigned to this variable error[invalid-assignment]: Object of type `Literal[0]` is not assignable to `str` - --> src/mdtest_snippet.py:6:8 + --> src/mdtest_snippet.py:6:12 | +2 | y: str + | --- Declared type +3 | +4 | x, y = ("a", "b") # snapshot: invalid-assignment +5 | 6 | x, y = (0, 0) # snapshot: invalid-assignment - | - ^^^^^^ Incompatible value of type `Literal[0]` + | - ^ Incompatible value of type `Literal[0]` | | - | Declared type `str` + | Assigned to this variable +``` + +## Nested unpacking targets + +Nested tuple targets point to the corresponding nested value. + +```py +value: int +other, (value, last) = (0, ("wrong", 1)) # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:2:29 + | +1 | value: int + | --- Declared type +2 | other, (value, last) = (0, ("wrong", 1)) # snapshot: invalid-assignment + | ----- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable +``` + +## List unpacking values + +List literals can be matched to unpacking targets in the same way as tuple literals. + +```py +value: int +value, other = ["wrong", 1] # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:2:17 + | +1 | value: int + | --- Declared type +2 | value, other = ["wrong", 1] # snapshot: invalid-assignment + | ----- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable +``` + +## Starred unpacking targets + +Values before and after a starred target still have unambiguous source expressions. + +```py +first: int +last: int + +first, *middle, last = ("wrong", 1, 2) # snapshot: invalid-assignment +first, *middle, last = (1, 2, "wrong") # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:4:25 + | +1 | first: int + | --- Declared type +2 | last: int +3 | +4 | first, *middle, last = ("wrong", 1, 2) # snapshot: invalid-assignment + | ----- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable + + +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:5:31 + | +2 | last: int + | --- Declared type +3 | +4 | first, *middle, last = ("wrong", 1, 2) # snapshot: invalid-assignment +5 | first, *middle, last = (1, 2, "wrong") # snapshot: invalid-assignment + | ---- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable +``` + +## Starred unpacking values + +Explicit values before and after a starred value can also be matched to their targets. + +```py +first: int +last: int +middle = (1,) + +first, _, last = ("wrong", *middle, 2) # snapshot: invalid-assignment +first, _, last = (1, *middle, "wrong") # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:5:19 + | +1 | first: int + | --- Declared type +2 | last: int +3 | middle = (1,) +4 | +5 | first, _, last = ("wrong", *middle, 2) # snapshot: invalid-assignment + | ----- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable + + +error[invalid-assignment]: Object of type `Literal["wrong"]` is not assignable to `int` + --> src/mdtest_snippet.py:6:31 + | +6 | first, _, last = (1, *middle, "wrong") # snapshot: invalid-assignment + | ---- ^^^^^^^ Incompatible value of type `Literal["wrong"]` + | | + | Assigned to this variable + | + ::: src/mdtest_snippet.py:2:7 + | +2 | last: int + | --- Declared type +``` + +## Values assigned to starred unpacking targets + +A starred target collects a slice of values into a new list. Incompatible elements in that slice can +still be identified individually. + +```py +middle: list[int] +first, *middle, last = (1, "wrong", 2) # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `list[str]` is not assignable to `list[int]` + --> src/mdtest_snippet.py:2:28 + | +1 | middle: list[int] + | --------- Declared type +2 | first, *middle, last = (1, "wrong", 2) # snapshot: invalid-assignment + | ------ ^^^^^^^ Incompatible iterable element of type `str` (expected `int`) + | | + | Assigned to this variable +``` + +## Multiple values assigned to starred unpacking targets + +When a starred target collects several values, the diagnostic highlights the entire collected slice +without including values assigned to the surrounding targets. + +```py +middle: list[int] +first, *middle, last = 1, 2, 3, "wrong", 4 # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `list[int | str]` is not assignable to `list[int]` + --> src/mdtest_snippet.py:2:27 + | +1 | middle: list[int] + | --------- Declared type +2 | first, *middle, last = 1, 2, 3, "wrong", 4 # snapshot: invalid-assignment + | ------ ^^^^^^^^^^^^^ Incompatible iterable element of type `int | str` (expected `int`) + | | + | Assigned to this variable +info: element `str` of union `int | str` is not assignable to `int` +``` + +## Opaque unpacking values + +When an unpacked value is not a tuple or list literal, the entire value remains the best available +source expression. + +```py +def values() -> tuple[str, int]: + return "wrong", 1 + +value: int +value, other = values() # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `str` is not assignable to `int` + --> src/mdtest_snippet.py:5:16 + | +4 | value: int + | --- Declared type +5 | value, other = values() # snapshot: invalid-assignment + | ----- ^^^^^^^^ Incompatible value of type `str` + | | + | Assigned to this variable ``` ## Shadowing of classes and functions diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md index 5c0814b52f..5e87925e41 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/same_names.md @@ -61,6 +61,117 @@ class DataFrame: pass ``` +## Variadic positional parameter annotations + +A variadic positional parameter's annotation uses the same qualified type name as the assignment +diagnostic. + +`first.py`: + +```py +class Value: ... +``` + +`second.py`: + +```py +class Value: ... +``` + +```py +import first +import second + +def assign(*values: first.Value) -> None: + values = (second.Value(),) # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `tuple[second.Value]` is not assignable to `tuple[first.Value, ...]` + --> src/mdtest_snippet.py:5:14 + | +4 | def assign(*values: first.Value) -> None: + | ----------- Variadic parameter annotation declares the type as `tuple[first.Value, ...]` +5 | values = (second.Value(),) # snapshot: invalid-assignment + | ^^^^^^^^^^^^^^^^^ Incompatible value of type `tuple[second.Value]` +``` + +## Variadic keyword parameter annotations + +A variadic keyword parameter's annotation uses the same qualified type name as the assignment +diagnostic. + +`first.py`: + +```py +class Value: ... +``` + +`second.py`: + +```py +class Value: ... +``` + +```py +import first +import second + +def assign(**values: first.Value) -> None: + values = {"item": second.Value()} # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `dict[str, first.Value | second.Value]` is not assignable to `dict[str, first.Value]` + --> src/mdtest_snippet.py:5:14 + | +4 | def assign(**values: first.Value) -> None: + | ----------- Keyword-variadic parameter annotation declares the type as `dict[str, first.Value]` +5 | values = {"item": second.Value()} # snapshot: invalid-assignment + | ^^^^^^^^^^^^^^^^^^^^^^^^ Incompatible value of type `dict[str, first.Value | second.Value]` +info: element `second.Value` of union `first.Value | second.Value` is not assignable to `first.Value` +``` + +## Ambiguous declaration origins + +When distinct branches declare the same type, the fallback annotation still distinguishes the +declared class from a same-named assigned class. + +`first.py`: + +```py +class Value: ... +``` + +`second.py`: + +```py +class Value: ... +``` + +```py +import first +import second + +def assign(flag: bool) -> None: + if flag: + value: first.Value + else: + value: first.Value + + value = second.Value() # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `second.Value` is not assignable to `first.Value` + --> src/mdtest_snippet.py:10:13 + | +10 | value = second.Value() # snapshot: invalid-assignment + | ----- ^^^^^^^^^^^^^^ Incompatible value of type `second.Value` + | | + | Declared type `first.Value` +``` + ## Class from different module with the same qualified name `package/__init__.py`: @@ -304,3 +415,376 @@ def get_models_tuple() -> tuple[Model]: # error: [invalid-return-type] "Return type does not match returned value: expected `tuple[mdtest_snippet.Model]`, found `tuple[module.Model]`" return (Model(),) ``` + +## Callable special forms + +ty distinguishes same-named classes nested in the signatures of two callable special forms. + +`first.py`: + +```py +from typing import Callable + +class StartResponse: ... + +Application = Callable[[StartResponse], int] +``` + +```py +from typing import Callable + +try: + from first import Application, StartResponse +except ImportError: + class StartResponse: ... + + # error: [invalid-assignment] "Object of type ` int'>` is not assignable to ` int'>`" + Application = Callable[[StartResponse], int] +``` + +## Method and constructor descriptions + +ty distinguishes the defining class of a bound method, unbound method, or constructor from a +same-named argument type. Method owners with no visible ambiguity remain unqualified. + +`first.py`: + +```py +class Model: ... +``` + +`second.py`: + +```py +import first + +class Model: + def __init__(self, value: first.Model) -> None: ... + def method(self, value: first.Model) -> None: ... + +class Other: + def method(self, value: first.Model) -> None: ... +``` + +```py +import second + +def calls(value: second.Model, other: second.Other) -> None: + # error: [invalid-argument-type] "Argument to bound method `second.Model.method` is incorrect: Expected `first.Model`, found `Literal[1]`" + value.method(1) + + # error: [invalid-argument-type] "Argument to function `second.Model.method` is incorrect: Expected `first.Model`, found `Literal[1]`" + second.Model.method(value, 1) + + # error: [invalid-argument-type] "Argument to `second.Model.__init__` is incorrect: Expected `first.Model`, found `Literal[1]`" + second.Model(1) + + # No competing type named `Other` appears in this diagnostic, so its method owner stays unqualified. + # error: [invalid-argument-type] "Argument to bound method `Other.method` is incorrect: Expected `Model`, found `Literal[1]`" + other.method(1) +``` + +## Builtin class descriptions + +ty distinguishes a builtin class used as a callable from a same-named argument type. + +```py +import builtins + +class tuple: ... + +def convert(value: tuple) -> None: + # error: [invalid-argument-type] "Argument to class `builtins.tuple` is incorrect: Expected `Iterable[Unknown]`, found `mdtest_snippet.tuple`" + builtins.tuple(value) +``` + +## Identifying union members + +ty uses the same qualification for a union member missing an attribute as for the complete union. + +`first.py`: + +```py +class Model: + present: int +``` + +`second.py`: + +```py +class Model: ... +``` + +```py +import first +import second + +def missing_attribute(value: first.Model | second.Model) -> int: + # error: [unresolved-attribute] "Attribute `present` is not defined on `second.Model` in union `first.Model | second.Model`" + return value.present +``` + +## Aliased union members + +ty distinguishes a union's type alias from a same-named member that does not define an attribute. + +```toml +[environment] +python-version = "3.12" +``` + +`first.py`: + +```py +class Present: + present: int +``` + +`second.py`: + +```py +class Model: ... +``` + +`alias.py`: + +```py +import first +import second + +type Model = first.Present | second.Model +``` + +```py +from alias import Model + +def missing_attribute(value: Model) -> int: + # error: [unresolved-attribute] "Attribute `present` is not defined on `second.Model` in union `alias.Model`" + return value.present +``` + +## Redefined union members + +When distinct union members have the same name in the same module, ty identifies the missing member +using both its source location and its module name. + +`test.py`: + +```py +def coinflip() -> bool: + return True + +if coinflip(): + class Model: + present: int + +else: + class Model: ... + +# error: [unresolved-attribute] "Attribute `present` is not defined on `test.Model @ src/test.py:9:11` in union `test.Model @ src/test.py:5:11 | test.Model @ src/test.py:9:11`" +Model().present +``` + +## Attribute assignments + +For ordinary and union attribute assignments, ty distinguishes the assigned class from a same-named +class appearing elsewhere in the diagnostic. + +`first.py`: + +```py +class Model: ... +``` + +`second.py`: + +```py +class Model: ... +``` + +```py +import first +import second + +class Owner: + item: first.Model + +class Other: + item: int + +def assign_attribute(owner: Owner, value: second.Model) -> None: + # error: [invalid-assignment] "Object of type `second.Model` is not assignable to attribute `item` of type `first.Model`" + owner.item = value + +def assign_union_attribute(owner: first.Model | Other, value: second.Model) -> None: + # error: [invalid-assignment] "Object of type `second.Model` is not assignable to attribute `item` on type `first.Model | Other`" + owner.item = value +``` + +## Subscript assignments + +ty distinguishes an incompatible assigned value or subscript key from a same-named class nested in +the subscripted object's type. + +`first.py`: + +```py +class Model: ... +``` + +`second.py`: + +```py +class Model: ... +``` + +```py +import first +import second + +def assign_value(values: list[first.Model], value: second.Model) -> None: + # error: [invalid-assignment] "Invalid subscript assignment with key of type `Literal[0]` and value of type `second.Model` on object of type `list[first.Model]`" + values[0] = value + +def assign_key(values: dict[first.Model, int], key: second.Model) -> None: + # error: [invalid-assignment] "Invalid subscript assignment with key of type `second.Model` and value of type `Literal[1]` on object of type `dict[first.Model, int]`" + values[key] = 1 +``` + +## Type assertions + +ty distinguishes an asserted class from a same-named inferred class. + +```toml +[environment] +python-version = "3.11" +``` + +`first.py`: + +```py +class Model: ... +``` + +`second.py`: + +```py +class Model: ... +``` + +```py +from typing import assert_type + +import first +import second + +def invalid_assertion(value: second.Model) -> None: + assert_type(value, first.Model) # snapshot: type-assertion-failure +``` + +```snapshot +error[type-assertion-failure]: Argument does not have asserted type `first.Model` + --> src/mdtest_snippet.py:7:5 + | +7 | assert_type(value, first.Model) # snapshot: type-assertion-failure + | ^^^^^^^^^^^^-----^^^^^^^^^^^^^^ + | | + | Inferred type is `second.Model` +info: `first.Model` and `second.Model` are not equivalent types +``` + +## Unspellable subtype assertions + +ty distinguishes same-named classes throughout a type assertion about an unspellable intersection. + +```toml +[environment] +python-version = "3.11" +``` + +`first.py`: + +```py +class Model: ... +``` + +`second.py`: + +```py +class Model: ... +``` + +```py +from typing import assert_type + +import first +import second + +def invalid_subtype_assertion(value: first.Model) -> None: + if isinstance(value, second.Model): + assert_type(value, second.Model) # snapshot: assert-type-unspellable-subtype +``` + +```snapshot +error[assert-type-unspellable-subtype]: Argument does not have asserted type `second.Model` + --> src/mdtest_snippet.py:8:9 + | +8 | assert_type(value, second.Model) # snapshot: assert-type-unspellable-subtype + | ^^^^^^^^^^^^-----^^^^^^^^^^^^^^^ + | | + | Inferred type is `first.Model & second.Model` +info: `first.Model & second.Model` is a subtype of `second.Model`, but they are not equivalent +``` + +## Incompatible inherited methods + +ty distinguishes a derived class from its same-named base when their inherited methods are +incompatible. + +`first.py`: + +```py +class Model: + def method(self, value: int) -> int: + return value +``` + +`second.py`: + +```py +class Different: + def method(self, value: str) -> str: + return value +``` + +```py +import first +import second + +# error: [invalid-method-override] "Base classes for class `mdtest_snippet.Model` define method `method` incompatibly: `first.Model.method` is incompatible with `Different.method`" +class Model(first.Model, second.Different): ... +``` + +## Conflicting metaclasses + +ty distinguishes same-named classes and metaclasses throughout a metaclass-conflict diagnostic. + +`first.py`: + +```py +class Meta(type): ... +class Model(metaclass=Meta): ... +``` + +```py +import first + +class OtherMeta(type): ... + +# error: [conflicting-metaclass] "derived class (`mdtest_snippet.Model`) must be a subclass of the metaclasses of all its bases, but `OtherMeta` (metaclass of `mdtest_snippet.Model`) and `Meta` (metaclass of base class `first.Model`) have no subclass relationship" +class Model(first.Model, metaclass=OtherMeta): ... +class Meta(type): ... + +# error: [conflicting-metaclass] "derived class (`Other`) must be a subclass of the metaclasses of all its bases, but `mdtest_snippet.Meta` (metaclass of `Other`) and `first.Meta` (metaclass of base class `Model`) have no subclass relationship" +class Other(first.Model, metaclass=Meta): ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md index c8a23e3150..d35ac1dce5 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md @@ -151,6 +151,33 @@ match obj: pass ``` +## Duplicate keyword arguments + +```toml +[environment] +python-version = "3.12" +``` + +```py +def f(x: int) -> None: ... + +# error: [invalid-syntax] "Duplicate keyword argument `x`" +f(x=1, x=2) + +# error: [parameter-already-assigned] "Multiple values provided for parameter `x` of function `f`" +f(1, x=2) +``` + +Duplicate keywords are also invalid in class definitions: + +```py +# error: [invalid-syntax] "Duplicate keyword argument `metaclass`" +class C(metaclass=type, metaclass=type): ... + +# error: [invalid-syntax] "Duplicate keyword argument `metaclass`" +class Generic[T](metaclass=type, metaclass=type): ... +``` + ## `return`, `yield`, `yield from`, and `await` outside function ```py @@ -607,3 +634,59 @@ error[invalid-syntax]: name `a` cannot refer to a parameter and a global variabl 27 | global a # snapshot: invalid-syntax | ^ ``` + +## name cannot refer to a parameter and a nonlocal variable + +```py +a = None + +def outer(): + a = None + def f(a): + nonlocal a # snapshot: invalid-syntax + +def outer(): + a = None + def g(a): + if True: + nonlocal a # error: [invalid-syntax] + +def h(a): + def inner(): + nonlocal a + +def outer(): + a = None + def i(a): + try: + nonlocal a # error: [invalid-syntax] + except Exception: + pass + +def outer(): + a = None + def f(a): + a = 1 + a = 2 + nonlocal a # error: [invalid-syntax] + +def f(a): + class Inner: + nonlocal a + +def f(a): + def inner(a): + nonlocal a # error: [invalid-syntax] + +def f(a=1): + def inner(): + nonlocal a +``` + +```snapshot +error[invalid-syntax]: name `a` cannot refer to a parameter and a nonlocal variable + --> src/mdtest_snippet.py:6:18 + | +6 | nonlocal a # snapshot: invalid-syntax + | ^ +``` diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md index 10f95d65e4..07df248f2b 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md @@ -179,8 +179,6 @@ class B: T = TypeVar("T", A, B) def _(x: T, y: int) -> T: - # error: [invalid-argument-type] - # error: [invalid-argument-type] # error: [invalid-argument-type] return x.foo(y) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/unresolved_reference.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/unresolved_reference.md index b7a43defdd..11107d7eea 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/unresolved_reference.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/unresolved_reference.md @@ -20,16 +20,63 @@ version of Python. (full diagnostic captured in snapshot) ### Info present in Python 3.9+ - - ```toml [environment] python-version = "3.9" ``` ```py -foo: List[int] # error: [unresolved-reference] -bar: Type # error: [unresolved-reference] +foo: List[int] # snapshot: unresolved-reference +bar: Type # snapshot: unresolved-reference +``` + +```snapshot +error[unresolved-reference]: Name `List` used when not defined + --> src/mdtest_snippet.py:1:6 + | +1 | foo: List[int] # snapshot: unresolved-reference + | ^^^^ Did you mean `list`? +help: Replace with `list` + | + - foo: List[int] # snapshot: unresolved-reference +1 + foo: list[int] # snapshot: unresolved-reference +2 | bar: Type # snapshot: unresolved-reference + | +note: This is an unsafe fix and may change runtime behavior + + +error[unresolved-reference]: Name `Type` used when not defined + --> src/mdtest_snippet.py:2:6 + | +2 | bar: Type # snapshot: unresolved-reference + | ^^^^ Did you mean `type`? +help: Replace with `type` + | +1 | foo: List[int] # snapshot: unresolved-reference + - bar: Type # snapshot: unresolved-reference +2 + bar: type # snapshot: unresolved-reference + | +note: This is an unsafe fix and may change runtime behavior +``` + +### Builtin replacement shadowed at module scope + +A module-level binding named `list` also shadows the standard builtin inside a nested function, so +the unresolved `List` annotation cannot safely be replaced with `list`. + +```py +list = object + +def check(): + value: List[int] # snapshot: unresolved-reference +``` + +```snapshot +error[unresolved-reference]: Name `List` used when not defined + --> src/mdtest_snippet.py:4:12 + | +4 | value: List[int] # snapshot: unresolved-reference + | ^^^^ Did you mean `list`? ``` ### Info not present before Python 3.9 diff --git a/crates/ty_python_semantic/resources/mdtest/directives/cast.md b/crates/ty_python_semantic/resources/mdtest/directives/cast.md index 0311c79766..d9e0369228 100644 --- a/crates/ty_python_semantic/resources/mdtest/directives/cast.md +++ b/crates/ty_python_semantic/resources/mdtest/directives/cast.md @@ -2,6 +2,16 @@ ## Behavior +```toml +[environment] +python-version = "3.12" + +[rules] +# Disabled by default in production, but enabled by default in mdtests. +# Tests for this rule are lower down in the file; for this section, we disable the rule. +disjoint-cast = "ignore" +``` + `cast()` takes two arguments, one type and one value, and returns a value of the given type. The (inferred) type of the value and the given type do not need to have any correlation. @@ -83,11 +93,6 @@ def f(x: Any, y: Unknown, z: Any | str | int): Recursive aliases that fall back to `Divergent` should not trigger `redundant-cast`. -```toml -[environment] -python-version = "3.12" -``` - ```py from typing import cast @@ -97,6 +102,657 @@ def f(x: RecursiveAlias): cast(RecursiveAlias, x) ``` +## Redundant casts of tuple classes with unknown elements + +A tuple class with an `Unknown` element is not fully static, even when its other element is `object` +and their union simplifies to `object`. A cast involving that tuple class must not be reported as +redundant. + +```py +from typing import cast +from ty_extensions._internal import Unknown + +def cast_gradual_tuple_class(value: type[tuple[object, Unknown]]) -> None: + cast(type[tuple[object, Unknown]], value) +``` + +## Disjoint casts + +### Basics + +Casting between disjoint types often indicates a mistake in the user's code. When enabled, +`disjoint-cast` reports casts whose source and destination types have no overlap. + +```py +from typing import cast +from typing_extensions import cast as extension_cast + +def incompatible_casts(integer: int, string: str) -> None: + # error: [disjoint-cast] "Cast from `int` to disjoint type `str`" + cast(str, integer) + + # error: [disjoint-cast] "Cast from `str` to disjoint type `int`" + cast(int, string) + + # error: [disjoint-cast] "Cast from `int` to disjoint type `str`" + cast(val=integer, typ=str) + + # error: [disjoint-cast] "Cast from `int` to disjoint type `str`" + extension_cast(str, integer) +``` + +### Disjoint casts involving literals and unions + +Literal types and unions are rejected only when none of their possible values overlaps with the +destination type. + +```py +from typing import Literal, cast + +# error: [disjoint-cast] "Cast from `Literal[1]` to disjoint type `str`" +cast(str, 1) + +# error: [disjoint-cast] "Cast from `Literal["left"]` to disjoint type `Literal["right"]`" +cast(Literal["right"], "left") + +def cast_union(value: int | str) -> None: + # error: [disjoint-cast] "Cast from `int | str` to disjoint type `bytes`" + cast(bytes, value) + + cast(str, value) + cast(int | bytes, value) +``` + +### Disjoint casts involving generic types + +Incompatible generic specializations are rejected: + +```py +from typing import Any, cast +from ty_extensions import Intersection + +def cast_generic( + list_of_integers: list[int], + list_of_integers_or_any: list[int | Any], + dynamic_list_of_integers: Intersection[list[int], Any], + list_of_dynamic_integers: list[Intersection[int, Any]], +) -> None: + # error: [disjoint-cast] "Cast from `list[int]` to disjoint type `list[str]`" + cast(list[str], list_of_integers) + # error: [disjoint-cast] + cast(list[str], list_of_integers_or_any) + # error: [disjoint-cast] + cast(list[str], dynamic_list_of_integers) + # error: [disjoint-cast] + cast(list[str], list_of_dynamic_integers) +``` + +But `cast`s are permitted between two different specializations of the same invariant generic type +when those two different specializations overlap. This can occur with certain dynamic +specializations of invariant generics: + +```py +def cast_generic_invalid( + list_of_integers: list[int], + dynamic_list_of_integers: Intersection[list[int], Any], + list_of_dynamic_integers: list[Intersection[int, Any]], + list_of_integers_or_any: list[int | Any], + any_or_list_of_integers: list[int] | Any, + any_or_list_of_integers_or_any: list[int | Any] | Any, + list_of_any: list[Any], + just_any: Any, +): + cast(Any, list_of_integers) # no diagnostic + cast(list[str], list_of_any) # no diagnostic + cast(str, just_any) # no diagnostic + cast(list[Intersection[int, Any]], list_of_integers) # no diagnostic + cast(list[int], dynamic_list_of_integers) # no diagnostic + cast(list[int], list_of_dynamic_integers) # no diagnostic + cast(list[int], list_of_integers_or_any) # no diagnostic + cast(list[int], any_or_list_of_integers) # no diagnostic + + # `Any | list[int]` could materialize to `list[str] | list[int]`, + # which is not disjoint from `list[str]` + cast(list[str], any_or_list_of_integers) # no diagnostic + + # similarly `Any | list[int | Any]` could also materialize to `list[str] | list[int]` + cast(list[str], any_or_list_of_integers_or_any) # no diagnostic +``` + +### Disjoint casts between identically named types + +Disjoint types with the same display name are qualified so the diagnostic identifies which type +comes from each module. + +```py +from typing import cast + +import first +import second + +def cast_identically_named(value: first.Value) -> None: + # error: [disjoint-cast] "Cast from `first.Value` to disjoint type `second.Value`" + cast(second.Value, value) +``` + +`first.py`: + +```py +from typing import final + +@final +class Value: + pass +``` + +`second.py`: + +```py +from typing import final + +@final +class Value: + pass +``` + +### Invariant type arguments in disjoint-cast explanations + +Distinct specializations of an invariant container are disjoint even when their element types +overlap. The explanation identifies the invariant parameter and the failed subtype check. + +```py +from typing import cast + +def narrow_elements(values: list[int | str]) -> None: + # snapshot: disjoint-cast + cast(list[int], values) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:5:5 + | + 5 | cast(list[int], values) + | ^^^^^---------^^------^ + | | | + | | Inferred as `list[int | str]` + | Disjoint from the inferred type + | + ::: stdlib/builtins.byi:2647:7 + | +2647 | class list[in out Element](MutableSequence[Element]): + | ---- `list` defined here +info: `list[int]` is disjoint from `list[int | str]` +info: `int | str` and `int` are not mutual subtypes of each other, but must be due to invariance +info: └── element `str` of union `int | str` is not a subtype of `int` +``` + +### Nominal subclasses of protocols + +Inheriting from a protocol does not make a class a protocol. A final class can satisfy the protocol +structurally, but cannot also be an instance of its unrelated nominal subclass. + +```py +from typing import Protocol, cast, final + +class HasName(Protocol): + name: str + +class Named(HasName): + pass + +@final +class Function: + name: str + +def cast_function(function: Function) -> None: + cast(HasName, function) + + # snapshot: disjoint-cast + cast(Named, function) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:17:5 + | +17 | cast(Named, function) + | ^^^^^-----^^--------^ + | | | + | | Inferred as `Function` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:6:7 + | + 6 | class Named(HasName): + | ----- `Named` defined here + 7 | pass + 8 | + 9 | / @final +10 | | class Function: + | |______________- `Function` defined here +info: `Named` is disjoint from `Function` +info: `Function` is `@final` and not a subclass of `Named` +``` + +### Explaining every disjoint union element + +A union is disjoint from the destination only when every element is disjoint. Each element +contributes its own explanation. + +```py +from typing import cast + +def cast_union(value: list[str] | list[bytes]) -> None: + # snapshot: disjoint-cast + cast(list[int], value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:5:5 + | + 5 | cast(list[int], value) + | ^^^^^---------^^-----^ + | | | + | | Inferred as `list[str] | list[bytes]` + | Disjoint from the inferred type + | + ::: stdlib/builtins.byi:2647:7 + | +2647 | class list[in out Element](MutableSequence[Element]): + | ---- `list` defined here +info: `list[int]` is disjoint from `list[str] | list[bytes]` +info: every element of union `list[str] | list[bytes]` is disjoint from `list[int]` +info: ├── `str` and `int` are not mutual subtypes of each other, but must be due to invariance +info: └── `bytes` and `int` are not mutual subtypes of each other, but must be due to invariance +``` + +### Disjoint tuple elements + +Two tuples of the same length are disjoint when a required element has disjoint types. The +explanation identifies the position of that element. + +```py +from typing import cast + +def cast_tuple(value: tuple[int, str]) -> None: + # snapshot: disjoint-cast + cast(tuple[int, int], value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:5:5 + | + 5 | cast(tuple[int, int], value) + | ^^^^^---------------^^-----^ + | | | + | | Inferred as `tuple[int, str]` + | Disjoint from the inferred type + | + ::: stdlib/builtins.byi:2586:7 + | +2586 | class tuple[out Element](Sequence[Element]): + | ----- `tuple` defined here +info: `tuple[int, int]` is disjoint from `tuple[int, str]` +info: tuple element 2 has disjoint types `str` and `int` +info: └── `str` and `int` are disjoint due to incompatible instance layouts +``` + +### Disjoint tuple lengths + +A fixed-length tuple cannot overlap with a tuple that requires more elements. + +```py +from typing import cast + +def cast_tuple(value: tuple[int]) -> None: + # snapshot: disjoint-cast + cast(tuple[int, int], value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:5:5 + | + 5 | cast(tuple[int, int], value) + | ^^^^^---------------^^-----^ + | | | + | | Inferred as `tuple[int]` + | Disjoint from the inferred type + | + ::: stdlib/builtins.byi:2586:7 + | +2586 | class tuple[out Element](Sequence[Element]): + | ----- `tuple` defined here +info: `tuple[int, int]` is disjoint from `tuple[int]` +info: the tuples have incompatible lengths: 1 and 2 +``` + +### Missing protocol members + +A missing member makes a final class disjoint from a protocol. The explanation does not retain +unsuccessful attempts to prove that an earlier, compatible member is disjoint. + +```py +from typing import Protocol, cast, final + +class Target(Protocol): + compatible: int | str + missing: str + +@final +class Source: + compatible: int + +def cast_protocol(value: Source) -> None: + # snapshot: disjoint-cast + cast(Target, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:13:5 + | +13 | cast(Target, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Source` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:3:7 + | + 3 | class Target(Protocol): + | ------ `Target` defined here + 4 | compatible: int | str + 5 | missing: str + 6 | + 7 | / @final + 8 | | class Source: + | |____________- `Source` defined here +info: protocol `Target` is disjoint from `Source` +info: `@final` type `Source` does not provide all members of protocol `Target` +info: └── protocol member `missing` is not defined on type `Source` +``` + +The same explanation applies when casting from the protocol to the final class. + +```py +def cast_final(value: Target) -> None: + # snapshot: disjoint-cast + cast(Source, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:16:5 + | +16 | cast(Source, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Target` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:3:7 + | + 3 | class Target(Protocol): + | ------ `Target` defined here + 4 | compatible: int | str + 5 | missing: str + 6 | + 7 | / @final + 8 | | class Source: + | |____________- `Source` defined here +info: `Source` is disjoint from protocol `Target` +info: `@final` type `Source` does not provide all members of protocol `Target` +info: └── protocol member `missing` is not defined on type `Source` +``` + +### Disjoint protocol method returns + +The explanation follows the protocol member's return type into its incompatible generic +specialization. + +```py +from typing import Protocol, cast, final + +class Target(Protocol): + def values(self) -> list[int]: ... + +@final +class Source: + def values(self) -> list[str]: + return [] + +def cast_protocol(value: Source) -> None: + # snapshot: disjoint-cast + cast(Target, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:13:5 + | +13 | cast(Target, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Source` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:3:7 + | + 3 | class Target(Protocol): + | ------ `Target` defined here + 4 | def values(self) -> list[int]: ... + 5 | + 6 | / @final + 7 | | class Source: + | |____________- `Source` defined here +info: protocol `Target` is disjoint from `Source` +info: protocol member `values` is incompatible +info: └── return types `list[int]` and `list[str]` are disjoint +info: └── `int` and `str` are not mutual subtypes of each other, but must be due to invariance +``` + +### Disjoint mutable TypedDict fields + +Mutable fields must accept assignments in both directions. The explanation identifies the field and +the failed assignability check, rather than a subtype check. + +```py +from typing import TypedDict, cast + +class Source(TypedDict): + value: int | str + +class Target(TypedDict): + value: int + +def cast_fields(value: Source) -> None: + # snapshot: disjoint-cast + cast(Target, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:11:5 + | +11 | cast(Target, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Source` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:3:7 + | + 3 | class Source(TypedDict): + | ------ `Source` defined here + 4 | value: int | str + 5 | + 6 | class Target(TypedDict): + | ------ `Target` defined here +info: `Target` is disjoint from `Source` +info: field `value` has incompatible types `int | str` and `int` +info: └── element `str` of union `int | str` is not assignable to `int` +``` + +### Conflicting TypedDict requiredness + +A required field cannot also be a mutable optional field: the optional declaration permits deleting +it. + +```py +from typing import TypedDict, cast +from typing_extensions import NotRequired + +class Source(TypedDict): + value: int + +class Target(TypedDict): + value: NotRequired[int] + +def cast_fields(value: Source) -> None: + # snapshot: disjoint-cast + cast(Target, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:12:5 + | +12 | cast(Target, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Source` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:4:7 + | + 4 | class Source(TypedDict): + | ------ `Source` defined here + 5 | value: int + 6 | + 7 | class Target(TypedDict): + | ------ `Target` defined here +info: `Target` is disjoint from `Source` +info: field `value` is required in `Source` but mutable and not-required in `Target` +``` + +Reversing the cast does not change which TypedDict requires the field. + +```py +def cast_required(value: Target) -> None: + # snapshot: disjoint-cast + cast(Source, value) +``` + +```snapshot +info[disjoint-cast]: Cast to a disjoint type + --> src/mdtest_snippet.py:15:5 + | +15 | cast(Source, value) + | ^^^^^------^^-----^ + | | | + | | Inferred as `Target` + | Disjoint from the inferred type + | + ::: src/mdtest_snippet.py:4:7 + | + 4 | class Source(TypedDict): + | ------ `Source` defined here + 5 | value: int + 6 | + 7 | class Target(TypedDict): + | ------ `Target` defined here +info: `Source` is disjoint from `Target` +info: field `value` is required in `Source` but mutable and not-required in `Target` +``` + +### Casts to `Never` + +`Never` is disjoint from every type, but excluded from `disjoint-cast`. It is assumed that the user +knows what they're doing if they cast to `Never` explicitly: + +```py +from typing_extensions import Never, cast + +x = cast(Never, 0) # no diagnostic +``` + +Upcasts from a `Never`-inferred type to a supertype are also permitted without the rule being +triggered: + +```py +from typing_extensions import Never, cast + +def test(x: Never): + y = cast(str, x) # no diagnostic +``` + +The reason why casting to or from `Never` is allowed is that the normal rationale for this rule does +not apply to either case. + +This rule seeks to prevent you from `cast`ing from a type `X` to a type `Y` if ty would never +provide any way for you to soundly narrow a type `X` to a type `Y`. Casting from `Never` to any +other type, however, poses no soundness issues: all types are supertypes of `Never`, so this can +never be unsound. Meanwhile, casting from `int` to `Never` is unsound, of course, but not really in +a different category than casting from `int` to `bool` (which would still be allowed under this +rule). `Never` is a subtype of `int` just the same way that `bool` is a subtype of `int`, and there +are lots of mechanisms ty provides that would let you soundly narrow a type from `int` to `Never` +without using a `cast`. + +### Casts in stub files + +`disjoint-cast` is not applied to stub files: + +`stub.pyi`: + +```pyi +from typing import cast + +x = cast(int, ...) # no diagnostic +``` + +This is partly to accommodate the fact that the typing spec +[recommends](https://typing.python.org/en/latest/spec/enums.html#defining-members) using +`cast(, ...)` to declare enum members in stub files in cases where the type of the +member's value cannot be unambiguously expressed as a static assignment. Without a special case for +stubs, we would emit a false-positive diagnostic on this example from the spec. This is due to the +fact that `EllipsisType` (the type of `...`) is disjoint from almost every other type, since it is +`@final`: + +`stub2.pyi`: + +```pyi +from enum import Enum +from typing import cast + +class Pet(Enum): + genus: str # Non-member attribute + species: str # Non-member attribute + + CAT = 1 # Member attribute with known value and type + DOG = cast(int, ...) # Member attribute with unknown value and known type + BIRD = ... # Member attribute with unknown value and type +``` + +But the rule would also serve little purpose in stub files. Since stub files are never executed at +runtime, the only possible useful applications of `cast` in a stub file are special cases like the +enum one above. There are no soundness implications to using `cast` in a stub file. + +For similar reasons, we also do not apply the rule in `if TYPE_CHECKING` blocks, which are also not +executed at runtime: + +`regular_py_file.py`: + +```py +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + x = cast(int, ...) # no diagnostic +``` + ## Diagnostic snapshots ```py @@ -203,3 +859,142 @@ help: Remove the redundant `cast` 16 + print(x + y) | ``` + +## Fixes for multiline conditional expressions + +Removing a redundant cast preserves the parentheses that allow its argument to span multiple lines. + +```py +from typing import cast + +# fmt: off +def choose(x: int, y: int, flag: bool) -> int: + # snapshot: redundant-cast + return cast(int, (x if flag + else y)) +``` + +```snapshot +warning[redundant-cast]: Value is already of type `int` + --> src/mdtest_snippet.py:6:12 + | +6 | return cast(int, (x if flag + | ____________^ +7 | | else y)) + | |_____________________________^ +help: Remove the redundant `cast` + | +5 | # snapshot: redundant-cast + - return cast(int, (x if flag + - else y)) +6 + return (x if flag +7 + else y) + | +``` + +## Fixes for multiline arithmetic expressions + +An argument can rely on the call's parentheses for line continuation without having parentheses of +its own. Removing the call adds parentheses to keep the arithmetic expression on one logical line. + +```py +from typing import cast + +# fmt: off +def add(x: int, y: int) -> int: + # snapshot: redundant-cast + return cast(int, x + + y) +``` + +```snapshot +warning[redundant-cast]: Value is already of type `int` + --> src/mdtest_snippet.py:6:12 + | +6 | return cast(int, x + + | ____________^ +7 | | y) + | |______________________^ +help: Remove the redundant `cast` + | +5 | # snapshot: redundant-cast + - return cast(int, x + +6 + return (x + +7 | y) + | +``` + +A line break before an operator also needs parentheses. Without them, the following fix would +produce valid syntax but return only `x`, leaving `+ y` as an unreachable statement. + +```py +# fmt: off +def add_with_leading_operator(x: int, y: int) -> int: + # snapshot: redundant-cast + return cast(int, x + + y) +``` + +```snapshot +warning[redundant-cast]: Value is already of type `int` + --> src/mdtest_snippet.py:11:12 + | +11 | return cast(int, x + | ____________^ +12 | | + y) + | |________^ +help: Remove the redundant `cast` + | +10 | # snapshot: redundant-cast + - return cast(int, x +11 + return (x +12 | + y) + | +``` + +## Fixes preserve comments in parenthesized arguments + +The fix retains comments inside an argument's parentheses, including when the value is passed by +keyword before the type argument. + +```py +from typing import cast + +def add(x: int, y: int) -> int: + # snapshot: redundant-cast + return cast( + val=( + # Leading comment. + x + y # Trailing comment. + ), + typ=int, + ) +``` + +```snapshot +warning[redundant-cast]: Value is already of type `int` + --> src/mdtest_snippet.py:5:12 + | + 5 | return cast( + | ____________^ + 6 | | val=( + 7 | | # Leading comment. + 8 | | x + y # Trailing comment. + 9 | | ), +10 | | typ=int, +11 | | ) + | |_____^ +help: Remove the redundant `cast` + | +4 | # snapshot: redundant-cast + - return cast( + - val=( +5 + return ( +6 | # Leading comment. +7 | x + y # Trailing comment. + - ), + - typ=int, + - ) +8 + ) + | +``` diff --git a/crates/ty_python_semantic/resources/mdtest/enums.md b/crates/ty_python_semantic/resources/mdtest/enums.md index e49edc8d0a..539ca5d92e 100644 --- a/crates/ty_python_semantic/resources/mdtest/enums.md +++ b/crates/ty_python_semantic/resources/mdtest/enums.md @@ -811,6 +811,67 @@ reveal_type(InheritedWeirdEnum.FROM_INT) # revealed: Literal[InheritedWeirdEnum reveal_type(enum_members(InheritedWeirdEnum)) # revealed: Unknown ``` +### Generic data-type mixin `__new__` + +A data-type mixin may be generic. When an enum lists a specialized alias of that mixin as a base, +members are validated against the specialized `__new__` signature, not against one whose typevars +are still free. Here `T` is `str`, so a `str` member is accepted and an `int` member is not: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from enum import Enum +from typing import Self + +class GenericMixin[T]: + def __new__(cls, value: T) -> Self: + return object.__new__(cls) + +class Specialized(GenericMixin[str], Enum): + A = "a" + B = 1 # error: [invalid-assignment] +``` + +The specialization is applied through intermediate generic bases, too. `Middle[int]` binds +`GenericMixin`'s `T` to `int` one step further up the MRO: + +```py +class Middle[T](GenericMixin[T]): ... + +class Inherited(Middle[int], Enum): + A = 1 + B = "b" # error: [invalid-assignment] +``` + +A mixin with several type parameters is specialized the same way, and each element of a member's +tuple payload is checked against the corresponding specialized parameter: + +```py +class Pair[T, U]: + def __new__(cls, first: T, second: U) -> Self: + return object.__new__(cls) + +class Unpacked(Pair[str, int], Enum): + A = ("a", 1) + B = ("b", "c") # error: [invalid-assignment] +``` + +A mixin whose `__new__` does not mention its type parameters at all is accepted as well. Before the +specialization was applied, the free typevar made the synthesized `cls` argument fail to match, so +even a fully permissive signature rejected every member: + +```py +class Ignored[T]: + def __new__(cls, *args: object, **kwargs: object) -> Self: + return object.__new__(cls) + +class Permissive(Ignored[str], Enum): + A = "a" +``` + ### Built-in data types An enum with an `int` or `str` data type stores the value produced by that type's constructor. @@ -1214,6 +1275,39 @@ class InheritedChoices(BaseChoices): reveal_type(InheritedChoices.A.value) # revealed: str ``` +### Subclasses of `enum.property` + +An inherited property initializer and accessor-copy methods retain the descriptor's subclass. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from enum import Enum, property as enum_property + +class CustomProperty(enum_property): ... + +def get_value(obj: object) -> int: + return 1 + +def set_value(obj: object, value: str) -> None: + pass + +descriptor = CustomProperty(get_value).setter(set_value) +reveal_type(descriptor) # revealed: CustomProperty +retained: CustomProperty = descriptor + +class Choice(Enum): + A = 1 + value = descriptor + +reveal_type(Choice.A.value) # revealed: int +Choice.A.value = "new" +Choice.A.value = 1 # error: [invalid-assignment] +``` + ### `types.DynamicClassAttribute` Attributes defined using `types.DynamicClassAttribute` are not considered members: @@ -1237,7 +1331,9 @@ reveal_type(enum_members(Answer)) ### In stubs -Stubs can optionally use `...` for the actual value: +Stubs can optionally use `...` for the actual value. They should use `cast()` to declare the type of +the enum member's value in cases where the value type cannot be unambiguously expressed as a static +assignment without a type annotation: ```pyi from enum import Enum @@ -2177,8 +2273,7 @@ class Color(Enum): for color in Color: reveal_type(color) # revealed: Color -# TODO: Should be `list[Color]` -reveal_type(list(Color)) # revealed: list[Unknown] +reveal_type(list(Color)) # revealed: list[Color] ``` ## Methods / non-member attributes @@ -3722,10 +3817,28 @@ def color_name_misses_one_variant(color: Color) -> str: assert_never(color) # error: [type-assertion-failure] "Type `Literal[Color.BLUE]` is not equivalent to `Never`" ``` +A functional enum inherits `object.__eq__`, so comparing members with `==` and `!=` narrows just as +`is` does: + +```py +def equality(color: Color) -> None: + if color == Color.RED: + reveal_type(color) # revealed: Literal[Color.RED] + else: + reveal_type(color) # revealed: Literal[Color.GREEN, Color.BLUE] + +def inequality(color: Color) -> None: + if color != Color.RED: + reveal_type(color) # revealed: Literal[Color.GREEN, Color.BLUE] + else: + reveal_type(color) # revealed: Literal[Color.RED] +``` + ## `match` statements (function syntax) -TODO: `match` exhaustiveness does not yet work for functional enums. The pattern matching narrowing -path does not resolve functional enum members the same way `is` comparisons do. +Value patterns narrow members of a functional enum exactly as they do for an enum declared with +class syntax. A `match` that covers every member is exhaustive, so the wildcard case is unreachable +and `assert_never` holds: ```toml [environment] @@ -3738,19 +3851,22 @@ from typing_extensions import assert_never Color = Enum("Color", "RED GREEN BLUE") -# TODO: `assert_never` should not fire here (exhaustive match). def color_name(color: Color) -> str: match color: case Color.RED: + reveal_type(color) # revealed: Literal[Color.RED] return "Red" case Color.GREEN: return "Green" case Color.BLUE: return "Blue" case _: - assert_never(color) # error: [type-assertion-failure] + assert_never(color) +``` -# TODO: This should ideally emit `Literal[Color.BLUE]` in the assertion, not `Color`. +When a member is left uncovered, the wildcard case receives exactly that member: + +```py def color_name_misses_one_variant(color: Color) -> str: match color: case Color.RED: @@ -3758,7 +3874,7 @@ def color_name_misses_one_variant(color: Color) -> str: case Color.GREEN: return "Green" case _: - assert_never(color) # error: [type-assertion-failure] "Type `Color` is not equivalent to `Never`" + assert_never(color) # error: [type-assertion-failure] "Type `Literal[Color.BLUE]` is not equivalent to `Never`" ``` ## `__eq__` and `__ne__` diff --git a/crates/ty_python_semantic/resources/mdtest/exception/basic.md b/crates/ty_python_semantic/resources/mdtest/exception/basic.md index 8a76d4db5a..8ae0c0ad89 100644 --- a/crates/ty_python_semantic/resources/mdtest/exception/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/exception/basic.md @@ -171,13 +171,13 @@ def silence4[T: type[BaseException] | tuple[type[BaseException], ...]]( ```py try: - pass + raise Exception # error: [invalid-exception-caught] except 3 as e: reveal_type(e) # revealed: Unknown try: - pass + raise Exception # error: [invalid-exception-caught] except (ValueError, OSError, "foo", b"bar") as e: reveal_type(e) # revealed: ValueError | OSError | Unknown diff --git a/crates/ty_python_semantic/resources/mdtest/exception/control_flow.md b/crates/ty_python_semantic/resources/mdtest/exception/control_flow.md index 78ad65600c..5bc0823306 100644 --- a/crates/ty_python_semantic/resources/mdtest/exception/control_flow.md +++ b/crates/ty_python_semantic/resources/mdtest/exception/control_flow.md @@ -1,16 +1,1279 @@ # Control flow for exception handlers -These tests assert that we understand the possible "definition states" (which symbols might or might -not be defined) in the various branches of a `try`/`except`/`else`/`finally` block. +These tests describe which names are defined and what types they have in the branches of a +`try`/`except`/`else`/`finally` statement. + +The analysis models exceptions from ordinary Python operations. It intentionally does not treat +every possible interruption, such as an exception raised by a signal handler, as an exception point. For a full writeup on the semantics of exception handlers, see [this document][1]. -The tests throughout this Markdown document use functions with names starting with `could_raise_*` -to mark definitions that might or might not succeed (as the function could raise an exception). A -type checker must assume that any arbitrary function call could raise an exception in Python; this -is just a naming convention used in these tests for clarity, and to future-proof the tests against -possible future improvements whereby certain statements or expressions could potentially be inferred -as being incapable of causing an exception to be raised. +Functions whose names start with `could_raise_` make it clear that a call may raise an exception +before an assignment completes. Any other function call can raise as well. + +## Operations that cannot raise + +Under this model, an exception handler is reachable only if the `try` block contains an operation +that can raise. Assigning a literal to a local name does not introduce an exception point: + +```py +x = 1 +try: + x = 2 +except: + x = "unreachable" + +reveal_type(x) # revealed: Literal[2] +``` + +Testing literals, comparing identities, combining these conditions, and iterating over a list +literal cannot raise either: + +```py +def known_safe_conditions(value: int | None) -> None: + state = 0 + try: + if not False: + state = 1 + if not (value is None): + state = 1 + if True and True: + state = 1 + if False or True: + state = 1 + for _ in [0]: + state = 1 + except: + state = 2 + + reveal_type(state) # revealed: Literal[1] +``` + +## Annotated assignments that can raise + +An annotation applies to assignments in the exception handler even if evaluating the annotated +assignment's right-hand side raises. In particular, it provides type context for a collection +literal in the handler. + +```py +from typing import Any + +def could_raise_dict() -> dict[str, Any]: + return {} + +def requires_str(value: str) -> None: ... +def fallback() -> None: + try: + result: dict[str, Any] = could_raise_dict() + except Exception: + result = {"correct": False, "message": "fallback"} + reveal_type(result) # revealed: dict[str, Any] + + reveal_type(result) # revealed: dict[str, Any] + requires_str(result["message"]) +``` + +The declaration also rejects an incompatible assignment in the handler. + +```py +def could_raise_int() -> int: + return 1 + +def incompatible_fallback() -> None: + try: + value: int = could_raise_int() + except Exception: + value = "wrong" # error: [invalid-assignment] +``` + +An earlier call in the `try` block does not hide a declaration reached before a later call raises. + +```py +def declaration_after_call() -> None: + value = int() + try: + could_raise_int() + value: int = could_raise_int() + except Exception: + value = "wrong" # error: [invalid-assignment] +``` + +The declaration does not make the new value available before the assignment completes. A handler +still sees the previous value, or an unbound name if there was no previous binding. + +```py +def previous_binding() -> None: + value = 0 + try: + value: int = could_raise_int() + except Exception: + reveal_type(value) # revealed: Literal[0] + +def no_previous_binding() -> None: + try: + value: int = could_raise_int() + except Exception: + # error: [unresolved-reference] + reveal_type(value) # revealed: Unknown +``` + +A new annotation replaces an earlier declared type even if its right-hand side raises. + +```py +def reannotated() -> None: + value: object = None + try: + value: int = could_raise_int() + except Exception: + value = 1 + + reveal_type(value) # revealed: int +``` + +Assignments made while evaluating the right-hand side still reach the handler. When the call +returns, its result replaces the value assigned by the walrus expression on the successful path. + +```py +from collections.abc import Callable +from typing import Literal + +def assignment_in_rhs(could_raise_after: Callable[[int], Literal[3]]) -> None: + value = 0 + try: + value: int = could_raise_after(value := 2) + except Exception: + reveal_type(value) # revealed: Literal[0, 2] + else: + reveal_type(value) # revealed: Literal[3] + + reveal_type(value) # revealed: Literal[0, 2, 3] +``` + +## Looking up an undefined name + +An undefined name raises `NameError`, so an exception handler can provide its value: + +```py +try: + fallback # ty: ignore[unresolved-reference] +except NameError: + fallback = 1 + +def use_fallback() -> None: + reveal_type(fallback) # revealed: Literal[1] +``` + +A conditionally defined name may retain its original value or receive a value from the handler: + +```py +def possibly_bound(flag: bool) -> None: + if flag: + value = 1 + + try: + value # ty: ignore[possibly-unresolved-reference] + except NameError: + value = 2 + + def use_value() -> None: + reveal_type(value) # revealed: Literal[1, 2] +``` + +A name that is definitely defined in the current scope cannot raise `NameError`: + +```py +def definitely_bound(local_value: int) -> None: + state = 0 + try: + local_value + except NameError: + state = 1 + + reveal_type(state) # revealed: Literal[0] +``` + +A later local assignment can shadow a builtin and make an earlier reference raise +`UnboundLocalError`: + +```py +def shadowed_builtin() -> None: + try: + int # ty: ignore[unresolved-reference] + except NameError: + int = 1 + + def use_shadowed_builtin() -> None: + reveal_type(int) # revealed: Literal[1] +``` + +## Undefined attribute and subscript receivers + +An exception handler can also provide a missing name when that name is used as an attribute +receiver: + +```py +try: + receiver.attribute # ty: ignore[unresolved-reference] +except NameError: + receiver = object() + +def use_receiver() -> None: + reveal_type(receiver) # revealed: object +``` + +Likewise, a subscript receiver may raise before its index is evaluated. The subscript itself may +raise after the index has been evaluated: + +```py +state = "before" +try: + state = 0 + missing[(state := 1)] # ty: ignore[unresolved-reference] +except NameError: + reveal_type(state) # revealed: Literal[0, 1] +``` + +## Function arguments are evaluated before the call + +If a function call raises, an assignment in one of its arguments has already completed: + +```py +def may_raise(value: object) -> None: ... + +x = 0 +try: + may_raise(x := 1) +except: + reveal_type(x) # revealed: Literal[1] +``` + +## Failed imports do not create bindings + +When an import fails, its target has not been assigned. An exception handler can therefore provide a +fallback without conflicting with the imported module's type: + +```py +try: + import ssl +except ImportError: + ssl = None +``` + +When importing several names, an earlier name may already be defined when a later import fails: + +```py +first = 0 +try: + from collections.abc import Awaitable as first, Iterable as second +except ImportError: + second = None + reveal_type(first) # revealed: Literal[0] | +``` + +## Explicit raises and failing assertions + +A `raise` statement runs after earlier assignments have completed: + +```py +x = 1 +try: + x = 2 + raise RuntimeError +except: + reveal_type(x) # revealed: Literal[2] +``` + +A failing assertion preserves the narrowing implied by its failed condition: + +```py +def check_assertion(x: int | None) -> None: + try: + assert x is not None + except: + reveal_type(x) # revealed: None +``` + +Short-circuiting determines whether an assignment inside the assertion has run: + +```py +def check_short_circuit_assertion(flag: bool) -> None: + state = 2 + try: + assert flag and (state := 0) + except: + reveal_type(state) # revealed: Literal[2, 0] +``` + +## Attribute access and subscripting + +Attribute access can raise after its receiver has been evaluated: + +```py +class C: + value: int + +def attribute_access(c: C) -> None: + state: C | int = 0 + try: + (state := c).value + except: + reveal_type(state) # revealed: C +``` + +A subscript can raise after its index has been evaluated: + +```py +def subscript_access(values: list[int]) -> None: + state = 0 + try: + values[state := 1] + except: + reveal_type(state) # revealed: Literal[1] +``` + +## Repeated potentially raising operations + +Repeated calls with unchanged bindings do not alter the values visible to an exception handler, but +a later reassignment must still be included: + +```py +def may_raise() -> None: ... +def repeated_calls() -> None: + state = 0 + try: + may_raise() + may_raise() + state = "changed" + may_raise() + except: + reveal_type(state) # revealed: Literal[0, "changed"] +``` + +A branch that does not change any bindings preserves the state visible to the handler: + +```py +def unchanged_branch(flag: bool) -> None: + state = 0 + try: + may_raise() + if flag is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" + pass + may_raise() + except: + reveal_type(state) # revealed: Literal[0] +``` + +Branch narrowing changes the state visible to the handler even when neither branch introduces a new +binding: + +```py +def narrowed_branch(value: int | None) -> None: + try: + if value is not None: + may_raise() + may_raise() + except: + reveal_type(value) # revealed: int +``` + +Both sides of a restored branch remain visible when each can raise: + +```py +def restored_branches(value: int | None) -> None: + try: + if value is not None: + may_raise() + else: + may_raise() + except: + reveal_type(value) # revealed: int | None +``` + +Match guards also distinguish successful and failed branches without introducing a new binding: + +```py +def guarded_match_branches(value: int | None) -> None: + try: + match value: + case _ if value is not None: + may_raise() + case _: + may_raise() + except: + reveal_type(value) # revealed: int | None +``` + +Deleting a binding changes the flow state even though the name remains present in the scope: + +```py +def deleted_binding() -> None: + state = 1 + try: + may_raise() + del state + may_raise() + except: + # error: [possibly-unresolved-reference] + reveal_type(state) # revealed: Literal[1] +``` + +A call that cannot return still prevents later assignments from reaching the exception handler: + +```py +from typing import NoReturn + +def stop() -> NoReturn: + raise RuntimeError + +def call_never_returns() -> None: + state = 0 + try: + stop() + state = "unreachable" + may_raise() + except: + reveal_type(state) # revealed: Literal[0] +``` + +## Nested handlers with merged bindings + +An inner handler can preserve the original binding while its `else` suite sees a later assignment. +After those paths merge, an exception must expose both bindings to the outer handler: + +```py +def may_raise() -> None: ... +def nested_try() -> None: + state = 0 + try: + try: + may_raise() + state = "changed" + except: + pass + else: + may_raise() + may_raise() + except: + reveal_type(state) # revealed: Literal[0, "changed"] +``` + +## Caught calls that never return + +Catching an exception from a `NoReturn` call makes the following code reachable again, even if no +bindings changed. The unreachable inner `else` suite must not hide the later exception: + +```py +from typing import NoReturn + +def may_raise() -> None: ... +def stop() -> NoReturn: + raise RuntimeError + +def nested_terminal() -> None: + state = 0 + try: + try: + stop() + except: + pass + else: + may_raise() + may_raise() + except: + reveal_type(state) # revealed: Literal[0] +``` + +## Operators and augmented assignments + +An arithmetic operator can raise after evaluating both operands: + +```py +class Number: + def __truediv__(self, other: int) -> int: + raise NotImplementedError + + def __lt__(self, other: int) -> bool: + raise NotImplementedError + +def division(number: Number) -> None: + state = 0 + try: + number / (state := 1) + except: + reveal_type(state) # revealed: Literal[1] +``` + +A comparison is also evaluated after its operands: + +```py +def comparison(number: Number) -> None: + state = 0 + try: + number < (state := 1) + except: + reveal_type(state) # revealed: Literal[1] +``` + +Augmented assignment evaluates the target before its right-hand side. Reading the target can raise +before the right-hand side runs: + +```py +def augmented_assignment(values: list[int]) -> None: + target_state = 0 + rhs_state = 0 + try: + values[target_state := 1] += (rhs_state := 1) + except: + reveal_type(target_state) # revealed: Literal[1] + reveal_type(rhs_state) # revealed: Literal[0, 1] +``` + +## Conditions can raise + +Evaluating an `if` condition can call `__bool__` or `__len__` and raise before its body runs: + +```py +def if_condition(value: object) -> None: + state = 0 + try: + if value: + state = 1 + except: + reveal_type(state) # revealed: Literal[0] +``` + +An assignment expression with a safe value cannot raise, including when it appears in an identity +comparison: + +```py +def safe_named_expressions() -> None: + caught = False + try: + if value := 1: + pass + if (value := 1) is not None: + pass + except: + caught = True + + reveal_type(caught) # revealed: Literal[False] +``` + +An assignment expression can still raise while calling its right-hand side or testing an unknown +value's truthiness: + +```py +def unsafe_named_expressions(value: object, may_raise) -> None: + caught = False + try: + if bound := may_raise(): + pass + except: + caught = True + + reveal_type(caught) # revealed: bool + + caught = False + try: + if bound := value: + pass + except: + caught = True + + reveal_type(caught) # revealed: bool +``` + +A `while` condition can fail before its first iteration or after an earlier iteration: + +```py +def while_condition(value: object) -> None: + state = 0 + try: + while value: + state = 1 + except: + reveal_type(state) # revealed: Literal[0, 1] +``` + +## Pattern matching can raise + +A sequence pattern can raise before its capture target or case body is assigned: + +```py +def sequence_pattern(value: object) -> None: + state = 0 + try: + state = 1 + match value: + case [item]: + state = 2 + except: + reveal_type(state) # revealed: Literal[1] + item # error: [unresolved-reference] +``` + +Mapping, class, and literal patterns can call user-defined matching or equality operations: + +```py +class Point: + x: int + +def mapping_pattern(value: object) -> None: + state = 0 + try: + state = 1 + match value: + case {"x": item}: + state = 2 + except: + reveal_type(state) # revealed: Literal[1] + +def class_pattern(value: object) -> None: + state = 0 + try: + state = 1 + match value: + case Point(x=item): + state = 2 + except: + reveal_type(state) # revealed: Literal[1] + +def literal_pattern(value: object) -> None: + state = 0 + try: + state = 1 + match value: + case 1: + state = 2 + except: + reveal_type(state) # revealed: Literal[1] +``` + +Wildcard, capture, and singleton patterns do not invoke user-defined operations: + +```py +def safe_patterns(value: object) -> None: + caught = False + try: + match value: + case None: + pass + case captured: + pass + match value: + case _: + pass + except: + caught = True + + reveal_type(caught) # revealed: Literal[False] +``` + +## Iteration can raise + +An iterator can fail before producing its first item or after an earlier iteration has completed: + +```py +from collections.abc import AsyncIterable, Iterable + +def iteration(values: Iterable[int]) -> None: + state = 0 + try: + state = 1 + for _ in values: + state = 2 + except: + reveal_type(state) # revealed: Literal[1, 2] +``` + +Assigning an iteration target can also fail before or after an earlier iteration: + +```py +class C: + value: int + +def iteration_target(target: C) -> None: + state = 0 + try: + state = 1 + for target.value in [0, 1]: + state = 2 + except: + reveal_type(state) # revealed: Literal[1, 2] +``` + +The same possibilities apply to asynchronous iteration: + +```py +async def async_iteration(values: AsyncIterable[int]) -> None: + state = 0 + try: + state = 1 + async for _ in values: + state = 2 + except: + reveal_type(state) # revealed: Literal[1, 2] +``` + +## Context-manager entry and exit can raise + +A context manager can raise before its body runs or after the body completes: + +```py +def context_manager_entry_and_exit(manager) -> None: + state = 0 + try: + with manager: + state = 1 + except: + reveal_type(state) # revealed: Literal[0, 1] +``` + +Asynchronous context managers have the same entry and exit behavior: + +```py +async def async_context_manager_entry_and_exit(manager) -> None: + state = 0 + try: + async with manager: + state = 1 + except: + reveal_type(state) # revealed: Literal[0, 1] +``` + +If entering the context manager fails, its `as` target has not yet been assigned: + +```py +def context_manager_target_may_be_unbound(manager) -> None: + try: + with manager as value: + pass + except: + value # error: [possibly-unresolved-reference] +``` + +Earlier context managers have already entered when a later manager raises: + +```py +from typing import Literal + +class FirstManager: + def __enter__(self) -> Literal[1]: + return 1 + + def __exit__(self, *_): + pass + +def multiple_context_managers(first: FirstManager, second) -> None: + state = 0 + try: + with first as state, second: + state = 2 + except: + reveal_type(state) # revealed: Literal[0, 1, 2] +``` + +## Unpacking can raise + +Unpacking can fail before the assignments following it run: + +```py +from collections.abc import Iterable + +def unpacking(values: Iterable[int]) -> None: + state = 0 + try: + # error: [refutable-unpacking] "`Iterable[int]` may not have exactly 2 elements, which would raise `ValueError` when unpacked" + first, second = values + state = 1 + except: + reveal_type(state) # revealed: Literal[0] +``` + +## Awaiting and yielding can raise + +Awaiting can raise when a coroutine resumes: + +```py +from collections.abc import Awaitable, Iterable + +async def awaiting(value: Awaitable[int]) -> None: + state = 0 + try: + state = 1 + await value + except: + reveal_type(state) # revealed: Literal[1] +``` + +Delegating to another iterable can raise while the generator is resumed: + +```py +def yielding_from(values: Iterable[int]): + state = 0 + try: + state = 1 + yield from values + except: + reveal_type(state) # revealed: Literal[1] +``` + +A plain `yield` can also raise when an exception is sent into the generator: + +```py +def yielding(): + state = 0 + try: + state = 1 + yield + except: + reveal_type(state) # revealed: Literal[1] +``` + +## Immediately and lazily evaluated scopes + +A class body runs immediately, so an exception raised there reaches the surrounding handler: + +```py +def may_raise() -> None: ... + +x = 0 +try: + class C: + may_raise() + +except: + x = 1 + +reveal_type(x) # revealed: Literal[0, 1] +``` + +A class-body assignment to a nonlocal variable is visible when the body raises: + +```py +def class_nonlocal_assignment_raises() -> None: + state = "before" + try: + class C: + nonlocal state + state = 1 + raise ValueError + + except ValueError: + reveal_type(state) # revealed: Literal["before", 1] +``` + +A nested class body also runs eagerly, so its nonlocal assignment reaches the surrounding handler: + +```py +def nested_class_nonlocal_assignment_raises() -> None: + state = "before" + try: + class Outer: + class Inner: + nonlocal state + state = 1 + raise ValueError + + except ValueError: + reveal_type(state) # revealed: Literal["before", 1] +``` + +A class can fail during construction even when its body contains only an assignment: + +```py +def class_construction_can_raise() -> None: + state = "before" + caught = False + try: + class C: + nonlocal state + state = 1 + + except: + caught = True + + reveal_type(caught) # revealed: bool +``` + +A class-construction hook runs after the class body's nonlocal assignment: + +```py +class RaisingBase: + def __init_subclass__(cls) -> None: + raise ValueError + +def class_construction_hook_raises() -> None: + state = 0 + try: + class C(RaisingBase): + nonlocal state + state = 1 + + except ValueError: + reveal_type(state) # revealed: Literal[0, 1] +``` + +A class decorator is applied after the class body has run: + +```py +def class_decorator_raises(decorator) -> None: + state = 0 + try: + @decorator + class C: + nonlocal state + state = 1 + + except ValueError: + reveal_type(state) # revealed: Literal[0, 1] +``` + +A function decorator is applied after its parameter defaults have been evaluated: + +```py +from typing import Any, Callable + +def function_decorator_raises(decorator: Callable[[Any], None]) -> None: + state = 0 + try: + @decorator + def inner(value=(state := 1)) -> None: + pass + + except Exception: + reveal_type(state) # revealed: Literal[1] +``` + +Decorator application can also raise when the function has no parameter defaults: + +```py +def function_decorator_without_defaults(decorator: Callable[[Any], None]) -> None: + caught = False + try: + @decorator + def inner() -> None: + pass + + except Exception: + caught = True + + reveal_type(caught) # revealed: bool +``` + +A list comprehension also runs immediately: + +```py +y = 0 +try: + [may_raise() for _ in [0]] +except: + y = 1 + +reveal_type(y) # revealed: Literal[0, 1] +``` + +Generator expressions are also assumed to run eagerly for exception-flow analysis, since in practice +they are almost always eagerly consumed in real-world code: + +```py +z = 0 +try: + (may_raise() for _ in [0]) +except: + z = 1 + +reveal_type(z) # revealed: Literal[0, 1] +``` + +A nested function body also runs later, so its exceptions cannot reach the handler surrounding its +definition: + +```py +function_caught = False +try: + def nested_function() -> None: + may_raise() + +except: + function_caught = True + +reveal_type(function_caught) # revealed: Literal[False] +``` + +An exception handler inside a lazily evaluated function still catches exceptions raised within that +function: + +```py +outer_caught = False +try: + def nested_function_with_handler() -> None: + inner_caught = False + try: + may_raise() + except: + inner_caught = True + + reveal_type(inner_caught) # revealed: bool + +except: + outer_caught = True + +reveal_type(outer_caught) # revealed: Literal[False] +``` + +## Assignments in comprehensions + +A handler includes the value from before a comprehension and the value visible once it finishes. +Assignments overwritten inside the comprehension are not tracked separately, while normal completion +still preserves the final assignment: + +```py +def comprehension_may_raise() -> None: ... +def overwritten_comprehension_assignment() -> None: + state = None + try: + [(state := 1, comprehension_may_raise(), state := "later") for _ in [0]] + except: + # TODO: Include `int` from the assignment before the raising call. + reveal_type(state) # revealed: None | str + return + + reveal_type(state) # revealed: str +``` + +Dictionary comprehensions are also evaluated eagerly: + +```py +def dict_comprehension_assignment() -> None: + state = "before" + try: + {item: (state := 1, comprehension_may_raise()) for item in [0]} + except: + reveal_type(state) # revealed: Literal["before"] | int +``` + +## Assignments in generator expressions + +Generator expressions are assumed to run eagerly, so their assignments and calls can reach the +surrounding exception handler. Strictly speaking generator expressions *can* be lazy, but in +practice they are almost always eagerly consumed in real-world code: + +```py +def generator_may_raise() -> None: ... +def generator_assignment() -> None: + state = 0 + caught = False + try: + ((state := 1, generator_may_raise()) for _ in [0]) + except: + reveal_type(state) # revealed: int + caught = True + + reveal_type(caught) # revealed: bool + reveal_type(state) # revealed: int +``` + +## Nested comprehension assignments + +An assignment in an inner comprehension still updates the scope containing the outermost +comprehension: + +```py +def comprehension_may_raise() -> None: ... +def nested_comprehension_assignments() -> None: + state = None + try: + [[(state := 1, comprehension_may_raise()) for _ in [0]] for _ in [0]] + except: + reveal_type(state) # revealed: None | int +``` + +## Module and global assignments in comprehensions + +An assignment expression in a module-level comprehension updates the module-level name: + +```py +def comprehension_may_raise() -> None: ... + +module_comprehension_state = "before" +try: + [(module_comprehension_state := 1, comprehension_may_raise()) for _ in [0]] +except: + reveal_type(module_comprehension_state) # revealed: Literal["before"] | int +``` + +An explicitly global assignment updates the same name from inside a function: + +```py +global_comprehension_state = "before" + +def global_comprehension_assignment() -> None: + global global_comprehension_state + try: + [(global_comprehension_state := 1, comprehension_may_raise()) for _ in [0]] + except: + reveal_type(global_comprehension_state) # revealed: int | Literal["before"] +``` + +## Assignments in asynchronous comprehensions + +An asynchronous comprehension can raise during iteration or after an assignment has completed: + +```py +from collections.abc import AsyncIterable, Awaitable + +async def async_comprehension_assignment(values: AsyncIterable[int], awaitable: Awaitable[int]) -> None: + state = "before" + try: + state = "ready" + [(state := 1, await awaitable) async for _ in values] + except: + reveal_type(state) # revealed: Literal["ready"] | int +``` + +## Exceptions passing through a `finally` clause + +An outer handler includes assignments from an intervening `finally` clause: + +```py +state = 0 +try: + try: + state = 1 + raise ValueError + finally: + state = 2 +except ValueError: + reveal_type(state) # revealed: Literal[1, 2] +``` + +Cleanup also runs before an exception escapes an inner handler for a different exception type: + +```py +state = 0 +try: + try: + state = 1 + raise ValueError + except TypeError: + state = 3 + finally: + state = 2 +except ValueError: + reveal_type(state) # revealed: Literal[1, 2] +``` + +An exception path must not contaminate the normal continuation after cleanup: + +```py +def may_raise() -> None: ... + +state = 0 +try: + try: + may_raise() + state = 1 + finally: + pass + + reveal_type(state) # revealed: Literal[1] +except: + pass +``` + +Cleanup remains visible when earlier and later calls share the same exception checkpoint: + +```py +state = 0 +try: + may_raise() + try: + may_raise() + state = 1 + finally: + state = 2 +except: + reveal_type(state) # revealed: Literal[0, 2] +``` + +A return passing through non-raising cleanup does not make an outer exception handler reachable: + +```py +def return_through_cleanup() -> None: + try: + try: + return + finally: + state = 2 + except: + reveal_type(state) # revealed: Never +``` + +## Nested exception handlers + +A bare inner handler catches an exception before it can reach the outer handler: + +```py +def may_raise() -> None: ... + +x = 0 +try: + try: + x = 1 + may_raise() + except: + x = 2 +except: + x = "outer" + +reveal_type(x) # revealed: Literal[1, 2] +``` + +An exception raised inside the inner handler can still reach the outer handler: + +```py +try: + try: + may_raise() + except: + x = 3 + may_raise() +except: + reveal_type(x) # revealed: Literal[3] +``` + +A newly entered inner handler receives exceptions even if an earlier call already reached the outer +handler without changing any bindings: + +```py +def inner_handler_after_outer_checkpoint() -> None: + try: + may_raise() + try: + may_raise() + except: + caught_inside = True + + reveal_type(caught_inside) # revealed: Literal[True] + except: + pass +``` + +Code in an unreachable inner handler cannot make the outer handler reachable: + +```py +z = 0 +try: + try: + pass + except: + may_raise() +except: + z = 1 + +reveal_type(z) # revealed: Literal[0] +``` ## A single bare `except` @@ -20,14 +1283,10 @@ have been taken from the perspective of code following this block. The inferred block's conclusion is therefore the union of the type at the end of the `try` suite (`str`) and the type at the end of the `except` suite (`Literal[2]`). -*Within* the `except` suite, we must infer a union of all possible "definition states" we could have -been in at any point during the `try` suite. This is because control flow could have jumped to the -`except` suite without any of the `try`-suite definitions successfully completing, with only *some* -of the `try`-suite definitions successfully completing, or indeed with *all* of them successfully -completing. The type of `x` at the beginning of the `except` suite in this example is therefore -`Literal[1] | str`, taking into account that we might have jumped to the `except` suite before the -`x = could_raise_returns_str()` redefinition, but we *also* could have jumped to the `except` suite -*after* that redefinition. +*Within* the `except` suite, we infer a union of the definition states at each exception checkpoint +in the `try` suite. The type of `x` at the beginning of the `except` suite in this example is +therefore `Literal[1] | str`: the call on the right-hand side can raise before the redefinition +completes, while the later `reveal_type` call can raise after it completes. ```py def could_raise_returns_str() -> str: @@ -436,13 +1695,10 @@ reveal_type(x) # revealed: C | E | G ## Nested `try`/`except` blocks -It would take advanced analysis, which we are not yet capable of, to be able to determine that an -exception handler always suppresses all exceptions. This is partly because it is possible for -statements in `except`, `else` and `finally` suites to raise exceptions as well as statements in -`try` suites. This means that if an exception handler is nested inside the `try` statement of an -enclosing exception handler, it should (at least for now) be treated the same as any other node: as -a suite containing statements that could possibly raise exceptions, which would lead to control flow -jumping out of that suite prior to the suite running to completion. +A checkpoint in a nested `try` suite propagates to both the nested and enclosing handlers unless the +nested statement has a bare handler. Checkpoints in its `except`, `else`, and `finally` suites +propagate only to the enclosing handler, because exceptions raised there are not handled by the same +`try` statement. ```py class A: ... diff --git a/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md b/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md index f9fea8b7cb..ebed2c0af0 100644 --- a/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md +++ b/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md @@ -4,7 +4,22 @@ ```py try: - print + print() except as e: # error: [invalid-syntax] reveal_type(e) # revealed: Unknown ``` + +## Invalid handler syntax does not create an exception path + +A syntax error in an exception handler does not make that handler reachable if the `try` block +cannot raise. + +```py +state = 0 +try: + state = 1 +except as e: # error: [invalid-syntax] + state = "unreachable" + +reveal_type(state) # revealed: Literal[1] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md index c5c1883356..b09c0a4a98 100644 --- a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md +++ b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md @@ -283,7 +283,11 @@ def match_non_exhaustive(x: Color): assert_never(x) # error: [type-assertion-failure] ``` -Matching every named member is not exhaustive for enums that can also have unnamed members. +Matching every named member is not exhaustive for `Flag` classes. + +Custom `_missing_` methods technically could create a new undeclared member via `object.__new__`, +but this is also possible outside a `_missing_` method. We choose to in general ignore this +possibility; we don't assume that a `_missing_` method will do this. ```py from enum import Enum, Flag @@ -296,19 +300,49 @@ class MissingValueEnum(Enum): @classmethod def _missing_(cls, value: object) -> "MissingValueEnum": - return object.__new__(cls) + return cls.ONLY def match_flag(value: Permission) -> int: # error: [invalid-return-type] match value: case Permission.READ: return 1 -def match_open_enum(value: MissingValueEnum) -> int: # error: [invalid-return-type] +def match_custom_missing_enum(value: MissingValueEnum) -> int: match value: case MissingValueEnum.ONLY: return 1 ``` +## Checks on enums with custom missing methods + +An enum remains exhaustive when it overrides `_missing_`, even if its value comes from a function +with the enum as its return type. + +```py +from enum import Enum +from typing import assert_never + +class FallbackColor(Enum): + RED = 1 + BLUE = 2 + + @classmethod + def _missing_(cls, value: object) -> "FallbackColor": + return FallbackColor.RED + +def get_color() -> FallbackColor: + return FallbackColor.BLUE + +color = get_color() +match color: + case FallbackColor.RED: + pass + case FallbackColor.BLUE: + pass + case _: + assert_never(color) +``` + ## Checks on enum literal subsets ```py @@ -574,6 +608,48 @@ def no_invalid_return_diagnostic_here_either[T](x: A[T]) -> ASub[T]: return x ``` +## Class patterns with variadic generics + +A class pattern matches every specialization of its variadic generic class, including a symbolic +type variable tuple. + +```py +from typing import Generic, TypeVarTuple, assert_never + +Ts = TypeVarTuple("Ts") + +class Variadic(Generic[*Ts]): ... + +def symbolic(value: Variadic[*Ts]) -> None: + match value: + case Variadic(): + reveal_type(value) # revealed: Variadic[*tuple[*Ts@symbolic]] + case _: + assert_never(value) +``` + +The same pattern is exhaustive when the type variable tuple has an empty specialization. + +```py +def empty(value: Variadic[()]) -> None: + match value: + case Variadic(): + reveal_type(value) # revealed: Variadic[()] + case _: + assert_never(value) +``` + +A nonempty specialization must also remain reachable and exhaustive. + +```py +def nonempty(value: Variadic[int]) -> None: + match value: + case Variadic(): + reveal_type(value) # revealed: Variadic[int] + case _: + assert_never(value) +``` + ## More `match` pattern types ### `as` patterns diff --git a/crates/ty_python_semantic/resources/mdtest/expression/lambda.md b/crates/ty_python_semantic/resources/mdtest/expression/lambda.md index f6bd2fbdfe..2c57c75341 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/lambda.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/lambda.md @@ -55,7 +55,7 @@ reveal_type(lambda **kwargs: kwargs) # revealed: (**kwargs) -> dict[str, Unknow Mixing all of them together: ```py -# revealed: (a, b, /, c: bool = True, *args, *, d: str = "default", e: int = 5, **kwargs) -> None +# revealed: (a, b, /, c: bool = True, *args, d: str = "default", e: int = 5, **kwargs) -> None reveal_type(lambda a, b, /, c=True, *args, d="default", e=5, **kwargs: None) ``` @@ -97,6 +97,44 @@ expression. reveal_type(lambda a=lambda x, y: 0: 2) # revealed: (a: (x, y) -> int = ...) -> Literal[2] ``` +## Defaults in string annotations + +`Annotated` metadata can contain lambdas. Names in their default values must still be resolved in +the enclosing string annotation, whose expressions are not part of the module's semantic index. + +```py +from typing_extensions import Annotated + +def f(value: "Annotated[int, lambda default=int: None]"): + reveal_type(value) # revealed: int + +# error: [unresolved-reference] +def invalid(value: "Annotated[int, lambda default=missing: None]"): ... +``` + +Nested lambdas must retain the same context. Dynamic classes created in a default value also need +the original string annotation as their source anchor. + +```py +def nested(value: "Annotated[int, lambda outer=(lambda inner=int: None): None]"): + reveal_type(value) # revealed: int + +def dynamic(value: "Annotated[int, lambda default=type('C', (), {}): None]"): + reveal_type(value) # revealed: int +``` + +## Defaults in stub string annotations + +Stub files must preserve the string-annotation context too, including for positional-only and +keyword-only defaults. + +```pyi +from typing_extensions import Annotated + +value: "Annotated[int, lambda positional=int, /, normal=str, *, keyword=bytes: None]" +reveal_type(value) # revealed: int +``` + ## Assignment This does not enumerate all combinations of parameter kinds as that should be covered by the diff --git a/crates/ty_python_semantic/resources/mdtest/expression/yield_and_yield_from.md b/crates/ty_python_semantic/resources/mdtest/expression/yield_and_yield_from.md index 45091d4971..6e126479b5 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/yield_and_yield_from.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/yield_and_yield_from.md @@ -24,8 +24,8 @@ def outer_generator(): ## `yield from` with a custom iterable -`yield from` can also be used with custom iterable types. In that case, the type of the `yield from` -expression cannot be determined +`yield from` can also be used with custom iterable types. If the iterator returned by `__iter__` is +not a generator, the type of the `yield from` expression cannot be determined: ```py from typing import Generator, TypeVar, Generic @@ -69,6 +69,181 @@ def generator() -> Generator[str]: reveal_type(result) # revealed: Unknown ``` +## `yield from` with a custom iterable whose `__iter__` returns a generator + +`yield from x` delegates to `iter(x)`. The send and return types of the `yield from` expression are +therefore determined by the iterator returned by `x.__iter__()`, even if `x` itself is not a +generator: + +```py +from typing import Generator + +class Box: + def __iter__(self) -> Generator[str, None, int]: + yield "hello" + return 42 + +def main() -> Generator[str, None, int]: + x = yield from Box() + reveal_type(x) # revealed: int + + y: str = yield from Box() # error: [invalid-assignment] + return x +``` + +The send type of the inner generator is also validated against the outer generator's send type: + +```py +class SendBox: + def __iter__(self) -> Generator[int, int, None]: + x = yield 1 + +def outer() -> Generator[int, str, None]: + # error: [invalid-yield] "Send type `int` does not match annotated send type `str`" + yield from SendBox() + +def outer_ok() -> Generator[int, int, None]: + yield from SendBox() +``` + +## `yield from` with a plain `Iterator` + +An `Iterator` annotation specifies the yielded type, but not the value of `StopIteration.value`. The +result of delegating to such an iterator is therefore `Unknown`, even when it is returned by a +custom iterable's `__iter__` method: + +```py +from typing import Generator, Iterator + +class Finished: + def __iter__(self) -> "Finished": + return self + + def __next__(self) -> str: + raise StopIteration(42) + +class Plain: + def __iter__(self) -> Iterator[str]: + return Finished() + +def plain() -> Generator[str, None, int]: + result = yield from Plain() + reveal_type(result) # revealed: Unknown + return result +``` + +The same applies to an iterator used directly and to a built-in iterable whose `__iter__` method +returns a plain `Iterator`: + +```py +def direct(iterator: Iterator[str]) -> Generator[str, None, int]: + result = yield from iterator + reveal_type(result) # revealed: Unknown + return result + +def builtin() -> Generator[str, None, None]: + result = yield from ["a", "b"] + reveal_type(result) # revealed: Unknown +``` + +## `yield from` with alternative iteration protocols + +An iterable union can mix a generator-returning `__iter__` with the sequence protocol. The +`__getitem__` alternative contributes to the result of `yield from`, so the return type of the +generator alone does not describe every possible result: + +```py +from typing import Generator + +class Wrapped: + def __iter__(self) -> Generator[int, int | None, str]: + yield 1 + return "done" + +class Sequence: + def __getitem__(self, index: int) -> int: + raise IndexError + +def mixed(value: Wrapped | Sequence) -> Generator[int, None, object]: + result = yield from value + reveal_type(result) # revealed: Unknown + return result +``` + +The sequence iterator does not support sending a non-`None` value, even though the generator does: + +```py +def mixed_send(value: Wrapped | Sequence) -> Generator[int, int, None]: + # error: [invalid-yield] "Send type `None` does not match annotated send type `int`" + yield from value +``` + +## `yield from` with union send types + +The outer generator's send type must be accepted by every possible delegated generator. An `int` +cannot be forwarded to an iterator that might require `str`: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Generator + +class IntBox: + def __iter__(self) -> Generator[int, int, None]: + yield 1 + +class StrBox: + def __iter__(self) -> Generator[int, str, None]: + yield 1 + +def incompatible_boxes(box: IntBox | StrBox) -> Generator[int, int, None]: + yield from box # error: [invalid-yield] +``` + +The same check applies when `__iter__` itself returns a union, including through a type alias, or +when the operand is already a union of generators: + +```py +type EitherGenerator = Generator[int, int, None] | Generator[int, str, None] + +class UnionBox: + def __iter__(self) -> EitherGenerator: + yield 1 + +def incompatible_iterators() -> Generator[int, int, None]: + yield from UnionBox() # error: [invalid-yield] + +def incompatible_generators(inner: EitherGenerator) -> Generator[int, int, None]: + yield from inner # error: [invalid-yield] +``` + +Delegation is valid when every alternative accepts the outer send type, even if the alternatives +also accept different additional types: + +```py +class OverlappingBox: + def __iter__(self) -> Generator[int, int | str, None] | Generator[int, int | bytes, None]: + yield 1 + +def compatible_iterators() -> Generator[int, int, None]: + yield from OverlappingBox() +``` + +Gradual send types are checked against each alternative separately. `list[Any]` is assignable to +both `list[int]` and `list[str]`, so this delegation is accepted: + +```py +from typing import Any + +def gradual_send( + inner: Generator[int, list[int], None] | Generator[int, list[str], None], +) -> Generator[int, list[Any], None]: + yield from inner +``` + ## `yield from` with a generator that return `types.GeneratorType` `types.GeneratorType` is a nominal type that implements the `typing.Generator` protocol: @@ -169,8 +344,9 @@ def mixing_generator_async_generator() -> Generator[int, int, None] | AsyncGener return None ``` -`Iterator` has no send type or return type, It is equivalent to using `Generator` with send set to -`None` and return type to `Unknown`. +Within generator functions annotated as `Iterator` or `AsyncIterator`, we infer `None` for `yield` +expressions. These annotations expose iteration with `next()` or `anext()`, not a `send` or `asend` +method. ```py def iterator_send_none() -> Iterator[int]: @@ -186,6 +362,52 @@ def iterator_yield_from() -> Generator[int, None, int]: return 1 ``` +## `yield from` with an `Iterator` return annotation + +An outer `Iterator` annotation does not expose a `send` method. Advancing the outer iterator with +`next()` also advances the delegated generator with `next()`, so its send type does not restrict +this delegation: + +```py +from typing import Generator, Iterator + +class Wrapped: + def __iter__(self) -> Generator[int, str, None]: + yield 1 + +def iterator() -> Iterator[int]: + yield from Wrapped() +``` + +The yielded values are still checked against the outer annotation: + +```py +def invalid_yield() -> Iterator[str]: + # error: [invalid-yield] "Yield type `int` does not match annotated yield type `str`" + yield from Wrapped() +``` + +An explicit `Generator` annotation still constrains the values sent to the delegated generator: + +```py +def invalid_send() -> Generator[int, int, None]: + # error: [invalid-yield] "Send type `str` does not match annotated send type `int`" + yield from Wrapped() +``` + +An `Iterator` member in a return-type union does not remove the other members' explicit send +requirements. Here the yielded `int` values require the `Generator` alternative, whose send type +must be compatible with the delegated generator: + +```py +def mixed_annotation() -> Iterator[str] | Generator[int, int, None]: + # error: [invalid-yield] "Send type `str` does not match annotated send type `int`" + yield from Wrapped() + +def compatible_mixed_annotation() -> Iterator[str] | Generator[int, str, None]: + yield from Wrapped() +``` + ## Generator type aliases ty "sees through" type aliases used as return annotations when inferring a generator's yield type. @@ -218,6 +440,9 @@ def invalid_iterator_return() -> IteratorAlias[int]: yield 42 return "foo" # error: [invalid-return-type] +def aliased_iterator(inner: Generator[int, str, None]) -> IteratorAlias[int]: + yield from inner + type AsyncGeneratorAlias[T] = AsyncGenerator[T] async def invalid_async_yield() -> AsyncGeneratorAlias[int]: diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index 9a0cbda628..2e9fd7e12a 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -152,6 +152,7 @@ Scalar types follow the Python-input conversions in Pydantic's [conversion table import re from datetime import date, datetime, time, timedelta from decimal import Decimal +from fractions import Fraction from ipaddress import ( IPv4Address, IPv4Interface, @@ -174,6 +175,7 @@ LaxBool(value=1.0) LaxBool(value=1) LaxBool(value=Decimal(1)) LaxBool(value="true") +LaxBool(value=b"true") LaxBool(value=[True]) # error: [invalid-argument-type] class LaxBytes(BaseModel): @@ -217,6 +219,7 @@ LaxFloat(value=True) LaxFloat(value=b"1.0") LaxFloat(value="1.0") LaxFloat(value=Decimal("1.0")) +LaxFloat(value=Fraction(1, 2)) LaxFloat(value=(1, 0)) # error: [invalid-argument-type] class LaxInt(BaseModel): @@ -228,6 +231,7 @@ LaxInt(value=b"1") LaxInt(value=1.0) LaxInt(value="1") LaxInt(value=Decimal(1)) +LaxInt(value=Fraction(2, 1)) LaxInt(value=(1,)) # error: [invalid-argument-type] class LaxStr(BaseModel): @@ -738,6 +742,101 @@ JsonValueModel(value=SomethingElse()) # error: [invalid-argument-type] JsonValueModel(value={"outer": [1, {"inner": SomethingElse()}]}) ``` +### Enum values for string fields + +In lax mode, Pydantic converts enum members to strings regardless of the member's underlying value. + +```py +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +class StringEnum(Enum): + VALUE = "value" + +class IntegerEnum(Enum): + VALUE = 1 + +class LaxModel(BaseModel): + value: str + +LaxModel(value=StringEnum.VALUE) +LaxModel(value=IntegerEnum.VALUE) +``` + +Strict models and fields reject ordinary enum members because they are not strings. + +```py +class StrictModel(BaseModel): + model_config = ConfigDict(strict=True) + + value: str + +class StrictFieldModel(BaseModel): + value: str = Field(strict=True) + +StrictModel(value=StringEnum.VALUE) # error: [invalid-argument-type] +StrictModel(value=IntegerEnum.VALUE) # error: [invalid-argument-type] +StrictFieldModel(value=StringEnum.VALUE) # error: [invalid-argument-type] +StrictFieldModel(value=IntegerEnum.VALUE) # error: [invalid-argument-type] +``` + +A field that opts out of model-wide strict mode accepts enum members again. + +```py +class LaxFieldModel(BaseModel): + model_config = ConfigDict(strict=True) + + value: str = Field(strict=False) + +LaxFieldModel(value=StringEnum.VALUE) +LaxFieldModel(value=IntegerEnum.VALUE) +``` + +### Enum values for integer fields + +In lax mode, Pydantic accepts enum members as integers by using their underlying values. + +```py +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +class IntegerEnum(Enum): + VALUE = 1 + +class LaxModel(BaseModel): + value: int + +LaxModel(value=IntegerEnum.VALUE) +``` + +Strict models and fields reject ordinary enum members because they are not integers. + +```py +class StrictModel(BaseModel): + model_config = ConfigDict(strict=True) + + value: int + +class StrictFieldModel(BaseModel): + value: int = Field(strict=True) + +StrictModel(value=IntegerEnum.VALUE) # error: [invalid-argument-type] +StrictFieldModel(value=IntegerEnum.VALUE) # error: [invalid-argument-type] +``` + +A field that opts out of model-wide strict mode accepts enum members again. + +```py +class LaxFieldModel(BaseModel): + model_config = ConfigDict(strict=True) + + value: int = Field(strict=False) + +LaxFieldModel(value=IntegerEnum.VALUE) +``` + ### Changing a specific field Strict mode can also be activated for a specific field only: diff --git a/crates/ty_python_semantic/resources/mdtest/function/return_type.md b/crates/ty_python_semantic/resources/mdtest/function/return_type.md index 1fd2fea400..9b879f731f 100644 --- a/crates/ty_python_semantic/resources/mdtest/function/return_type.md +++ b/crates/ty_python_semantic/resources/mdtest/function/return_type.md @@ -70,7 +70,7 @@ class Baz(Bar): # error: [empty-body] def f(self) -> int: ... -T = TypeVar("T") +T = TypeVar("T", covariant=True) class Qux(Protocol[T]): def f(self) -> int: ... @@ -898,6 +898,127 @@ def returns_list_containing_any() -> list[int]: return [returns_any()] ``` +## Regression test: `unsound-return-statement` with gradual generic declarations + +A specialized generic type is fully static if it has been specialized with fully static types, even +if the type parameter(s) it is generic over have non-fully-static bounds, constraints, or defaults. +A previous version of the rule incorrectly considered these specialized generic types as being +non-fully-static, leading to false negatives in the below examples: + +```toml +[environment] +python-version = "3.13" + +[rules] +unsound-return-statement = "error" +``` + +```py +from typing import Any, Generator, Generic, TypeVar + +class Bounded[T: Any]: ... +class Constrained[T: (int, Any)]: ... +class Defaulted[T = Any]: ... + +# `Bounded[int]`, `Constrained[int]` and `Defaulted[int]` are all fully static, +# despite their bounds/constraints/defaults not being fully static +def returns_bounded(value: Any) -> Bounded[int]: + return value # error: [unsound-return-statement] + +def returns_constrained(value: Any) -> Constrained[int]: + return value # error: [unsound-return-statement] + +def returns_defaulted(value: Any) -> Defaulted[int]: + return value # error: [unsound-return-statement] +``` + +The same applies to classes declared with legacy type variables: + +```py +BoundedT = TypeVar("BoundedT", bound=Any) +ConstrainedT = TypeVar("ConstrainedT", int, Any) +DefaultedT = TypeVar("DefaultedT", default=Any) + +class LegacyBounded(Generic[BoundedT]): ... +class LegacyConstrained(Generic[ConstrainedT]): ... +class LegacyDefaulted(Generic[DefaultedT]): ... + +def returns_legacy_bounded(value: Any) -> LegacyBounded[int]: + return value # error: [unsound-return-statement] + +def returns_legacy_constrained(value: Any) -> LegacyConstrained[int]: + return value # error: [unsound-return-statement] + +def returns_legacy_defaulted(value: Any) -> LegacyDefaulted[int]: + return value # error: [unsound-return-statement] +``` + +and to `return` statements in generator functions: + +```py +def generator_returns_bounded(value: Any) -> Generator[None, None, Bounded[int]]: + yield + return value # error: [unsound-return-statement] +``` + +A specialized generic type is nonetheless considered to be non-fully-static if it is specialized +with non-fully-static types: + +```py +def returns_gradual_bounded(value: Any) -> Bounded[Any]: + # no error + return value + +def returns_gradual_constrained(value: Any) -> Constrained[Any]: + # no error + return value + +def returns_gradual_defaulted(value: Any) -> Defaulted[Any]: + # no error + return value + +def returns_nested_gradual_bounded(value: Any) -> Bounded[list[Any]]: + # no error + return value +``` + +## Regression test: `unsound-return-statement` with tuple class objects + +A tuple class has only one generic parameter, so its element types are combined into a union. Its +original element types must still determine whether the tuple class is fully static. + +```toml +[environment] +python-version = "3.11" + +[rules] +unsound-return-statement = "error" +``` + +A tuple class with fully static elements forms a fully static return boundary: + +```py +from typing import Any + +def returns_static_tuple_class(value: Any) -> type[tuple[int, object]]: + return value # error: [unsound-return-statement] +``` + +A tuple class with an `Any` element remains gradual even though the union `object | Any` simplifies +to `object`: + +```py +def returns_gradual_tuple_class(value: Any) -> type[tuple[object, Any]]: + return value +``` + +An unpacked gradual tuple likewise makes the entire tuple class gradual: + +```py +def returns_gradual_variadic_tuple_class(value: Any) -> type[tuple[object, *tuple[Any, ...]]]: + return value +``` + ## Regression test: `unsound-return-statement` uses "pure redundancy" Internally, the rule uses "pure redundancy" rather than "impure redundancy". The following example @@ -916,7 +1037,7 @@ unsound-return-statement = "error" ```py from typing import Generator, Protocol, TypeVar -T = TypeVar("T") +T = TypeVar("T", covariant=True) class Phantom(Protocol[T]): def ping(self) -> int: ... @@ -991,11 +1112,11 @@ unsound-return-statement = "error" ```py from typing import Any, Protocol, TypeVar -T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) -class Growing(Protocol[T]): +class Growing(Protocol[T_co]): @property - def next(self) -> "Growing[list[T]]": ... + def next(self) -> "Growing[list[T_co]]": ... def returns_recursive_protocol(value: Any) -> Growing[int]: return value @@ -1007,6 +1128,8 @@ containing dictionary. ```py from typing import Generic, TypedDict +T = TypeVar("T") + class GrowingPayload(TypedDict, Generic[T]): child: "GrowingPayload[list[T]]" diff --git a/crates/ty_python_semantic/resources/mdtest/generics/builtins.md b/crates/ty_python_semantic/resources/mdtest/generics/builtins.md index db92bd3eee..71f5c39d10 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/builtins.md @@ -1,5 +1,17 @@ # Generic builtins +## Unbound inherited methods + +In typeshed, `list` inherits `clear` from `MutableSequence`, and `dict` inherits it from +`MutableMapping`. We can call these methods through `list` and `dict` without supplying type +arguments. + +```py +def clear_containers(items: list[int], mapping: dict[str, int]) -> None: + list.clear(items) + dict.clear(mapping) +``` + ## Variadic keyword arguments with a custom `dict` When we define `dict` in a custom typeshed, we must take care to define it as a generic class in the diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md index 06eab72da0..71ec2604eb 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md @@ -296,6 +296,44 @@ def f(val: str | bytes) -> None: reveal_type(accepts_callable(f)) # revealed: str | bytes ``` +## Rejected overloaded callbacks preserve valid specializations + +An overloaded callback may contain one alternative whose return type violates a type variable's +upper bound or declared constraints. The valid alternative must determine the specialization +regardless of the order in which the overloads appear. + +```py +from typing import Callable, TypeVar, overload + +Bounded = TypeVar("Bounded", bound=int) +Constrained = TypeVar("Constrained", int, bytes) + +@overload +def invalid_first(value: str) -> str: ... +@overload +def invalid_first(value: int) -> int: ... +def invalid_first(value: str | int) -> str | int: + return value + +@overload +def invalid_last(value: int) -> int: ... +@overload +def invalid_last(value: str) -> str: ... +def invalid_last(value: str | int) -> str | int: + return value + +def infer_bound(callback: Callable[..., Bounded]) -> Bounded: + raise NotImplementedError + +def infer_constrained(callback: Callable[..., Constrained]) -> Constrained: + raise NotImplementedError + +reveal_type(infer_bound(invalid_first)) # revealed: int +reveal_type(infer_bound(invalid_last)) # revealed: int +reveal_type(infer_constrained(invalid_first)) # revealed: int +reveal_type(infer_constrained(invalid_last)) # revealed: int +``` + ## Overloaded callable with a constrained type variable When `T` is constrained to a union by other arguments, the overloaded callable must still be treated @@ -348,6 +386,91 @@ def singleton(flag: bool = False) -> Callable[[Callable[[int], S]], Callable[[in return wrapper ``` +## Return type inference from partially annotated overloads + +The catch-all overload returns `object`, which is preserved when inferring a return type from the +whole callback even though the literal-specific overloads have unannotated return types. + +```py +from typing import Callable, Literal, TypeVar, overload +from typing_extensions import assert_type + +R = TypeVar("R") +T = TypeVar("T") + +def infer_return(callback: Callable[[T], R]) -> R: + raise NotImplementedError + +@overload +def callback(value: Literal["a"]): ... +@overload +def callback(value: Literal["b"]): ... +@overload +def callback(value: Literal["c"]): ... +@overload +def callback(value: Literal["d", "e"]): ... +@overload +def callback(value: Literal["f", "g"]): ... +@overload +def callback(value: Literal["h", "i"]): ... +@overload +def callback(value: Literal["j", "k"]): ... +@overload +def callback(value: object) -> object: ... +def callback(value): + raise NotImplementedError + +assert_type(infer_return(callback), object) +``` + +## Generic inference after projection budget exhaustion + +Each tuple element independently matches one of the callback's overloads. The combined alternative +bindings exceed generic inference's projection limits. The precise type of `default=0` does not +replace the missing callback evidence: we recover with `Unknown` in either argument order. + +```py +from typing import Callable, Literal, TypeVar, overload +from typing_extensions import assert_type +from ty_extensions._internal import Unknown + +R = TypeVar("R") +T = TypeVar("T") +U = TypeVar("U") +V = TypeVar("V") + +def infer_return(callback: tuple[Callable[[T], R], Callable[[U], R], Callable[[V], R]], default: R) -> R: + raise NotImplementedError + +@overload +def callback(value: Literal[0, 1]): ... +@overload +def callback(value: Literal[2, 3]): ... +@overload +def callback(value: Literal[4, 5]): ... +@overload +def callback(value: Literal[6, 7]): ... +@overload +def callback(value: Literal[8, 9]): ... +@overload +def callback(value: Literal[10, 11]): ... +@overload +def callback(value: Literal[12, 13]): ... +@overload +def callback(value: Literal[14, 15]): ... +@overload +def callback(value: Literal[16, 17]): ... +@overload +def callback(value: Literal[18, 19]): ... +@overload +def callback(value: object) -> object: ... +def callback(value): + raise NotImplementedError + +assert_type(infer_return((callback, callback, callback), 0), Unknown) +assert_type(infer_return(default=0, callback=(callback, callback, callback)), Unknown) +``` + ## Multiple occurrences of a higher-order generic callable If a generic callable is used more than once in a higher-order call, each occurrence should get its diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index 0469cec067..5fbdf147ef 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -168,10 +168,54 @@ reveal_type(generic_context(ExplicitInheritedGenericPartiallySpecialized)) reveal_type(generic_context(ExplicitInheritedGenericPartiallySpecializedExtraTypevar)) ``` +## Class-preserving decorators + +A decorator that returns its class argument preserves the generic context of a base class. A +subclass can forward a type variable to the decorated base. + +```py +import collections.abc +from typing import Generic, TypeVar +from ty_extensions._internal import generic_context + +T = TypeVar("T") + +@collections.abc.Mapping.register +class Base(Generic[T]): ... + +reveal_type(generic_context(Base)) # revealed: ty_extensions._internal.GenericContext[T@Base] + +class Child(Base[T]): ... + +child: Child[int] +``` + +## Unknown decorators on generic bases + +An unresolved decorator preserves the class binding and its generic context. A subclass can forward +a type variable to the decorated base and be specialized without a cascading error. + +```py +from typing import Generic, TypeVar +from ty_extensions._internal import generic_context + +T = TypeVar("T") + +# error: [unresolved-reference] "Name `unknown_decorator` used when not defined" +@unknown_decorator +class Base(Generic[T]): ... + +reveal_type(generic_context(Base)) # revealed: ty_extensions._internal.GenericContext[T@Base] + +class Child(Base[T]): ... + +child: Child[int] +``` + ## Specializing classes with unavailable generic context When an earlier error prevents ty from determining a class's generic context, specializing the class -can emit a cascading `not-subscriptable` diagnostic. +can emit a cascading `invalid-type-form` diagnostic. ### Conditional typing compatibility imports @@ -192,34 +236,7 @@ T = typing.TypeVar("T") class Parser(typing.Generic[T]): ... # TODO: Remove this cascading error when https://github.com/astral-sh/ty/issues/1585 is fixed. -parser: Parser[int] # error: [not-subscriptable] "Cannot subscript non-generic type ``" -``` - -### Decorated generic bases - -A decorator that ty cannot fully understand can obscure the generic context of a base class. A -subclass that forwards type variables to that base remains possibly generic. - -```py -import collections.abc -from typing import Generic, TypeVar -from ty_extensions._internal import generic_context - -K = TypeVar("K") -V = TypeVar("V") - -# error: [unresolved-attribute] "Class `Mapping` has no attribute `register`" -@collections.abc.Mapping.register -class Mapping(Generic[K, V]): ... - -# TODO: Invalid decorator causes us to lose the generic context from the class... -reveal_type(generic_context(Mapping)) # revealed: None - -class FrozenDict(Mapping[K, V]): ... - -# TODO: ...which then causes us to emit this -# error: [not-subscriptable] "Cannot subscript non-generic type ``" -mapping: FrozenDict[str, int] +parser: Parser[int] # error: [invalid-type-form] "Non-generic class `Parser` cannot be specialized in a type expression" ``` ### Unresolved generic bases @@ -235,7 +252,7 @@ T = TypeVar("T") class Child(Base[T]): ... -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `Child` cannot be specialized in a type expression" child: Child[int] ``` @@ -274,7 +291,7 @@ T = TypeVar("T") # error: [unsupported-base] class Child(Base[T]): ... -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `Child` cannot be specialized in a type expression" child: Child[int] ``` @@ -1024,7 +1041,7 @@ When a generic subclass fills its superclass's type parameter with one of its ow propagate through: ```py -from typing_extensions import Generic, TypeVar +from typing_extensions import Generic, Self, TypeVar T = TypeVar("T") U = TypeVar("U") @@ -1034,6 +1051,17 @@ W = TypeVar("W") class Parent(Generic[T]): x: T + @staticmethod + def static(value: T) -> T: + return value + + @classmethod + def class_method(cls, value: T) -> T: + return value + + def method(self, value: T, other: U) -> U: + return other + class ExplicitlyGenericChild(Parent[U], Generic[U]): ... class ExplicitlyGenericGrandchild(ExplicitlyGenericChild[V], Generic[V]): ... class ExplicitlyGenericGreatgrandchild(ExplicitlyGenericGrandchild[W], Generic[W]): ... @@ -1050,6 +1078,73 @@ reveal_type(ExplicitlyGenericGreatgrandchild[int]().x) # revealed: int reveal_type(ImplicitlyGenericGreatgrandchild[int]().x) # revealed: int ``` +Implicitly generic subclasses, explicitly generic subclasses, and longer inheritance chains all +replace an unresolved class type variable with `Unknown`. Accessing a generic instance attribute +through a class is invalid, but its recovery type still uses this specialization. + +```py +# error: [invalid-attribute-access] +reveal_type(Parent.x) # revealed: Unknown +# error: [invalid-attribute-access] +reveal_type(ExplicitlyGenericChild.x) # revealed: Unknown +# error: [invalid-attribute-access] +reveal_type(ImplicitlyGenericChild.x) # revealed: Unknown +# error: [invalid-attribute-access] +reveal_type(ImplicitlyGenericGrandchild.x) # revealed: Unknown +``` + +The same specialization applies to inherited static methods, class methods, and ordinary methods. +Type variables belonging to a method remain generic. + +```py +# revealed: def static(value: Unknown) -> Unknown +reveal_type(ImplicitlyGenericChild.static) +# revealed: bound method .class_method(value: Unknown) -> Unknown +reveal_type(ImplicitlyGenericChild.class_method) +# revealed: def method[U](self, value: Unknown, other: U) -> U +reveal_type(ImplicitlyGenericChild.method) + +ImplicitlyGenericChild.static(1) +ImplicitlyGenericChild.class_method(1) +reveal_type(ImplicitlyGenericChild[int].static(1)) # revealed: int +``` + +Constructor methods inherit their class's type variables into their own generic contexts, so they +remain generic when accessed explicitly. Calling the class itself also infers its type arguments. + +```py +class ConstructorParent(Generic[T]): + def __new__(cls, value: T) -> Self: + return super().__new__(cls) + + def __init__(self, value: T) -> None: ... + +class ConstructorChild(ConstructorParent[T]): ... + +# revealed: def __new__[Self, T](cls, value: T) -> Self +reveal_type(ConstructorChild.__new__) +# revealed: def __init__[T](self, value: T) +reveal_type(ConstructorChild.__init__) +reveal_type(ConstructorChild(1)) # revealed: ConstructorChild[int] +``` + +A generic descriptor inherited from the parent also receives the receiver's specialization before +its `__get__` method is called. + +```py +class Descriptor(Generic[T]): + def __get__(self, instance: object | None, owner: type[object]) -> T: + raise NotImplementedError + +class DescriptorParent(Generic[T]): + descriptor: Descriptor[T] = Descriptor() + +class DescriptorChild(DescriptorParent[T]): ... + +reveal_type(DescriptorChild.descriptor) # revealed: Unknown +reveal_type(DescriptorChild[int].descriptor) # revealed: int +``` + ## Generic methods Generic classes can contain methods that are themselves generic. The generic methods can refer to @@ -1093,6 +1188,315 @@ reveal_type(generic_context(c.method)) reveal_type(generic_context(c.generic_method)) ``` +## Members of constrained type variables + +Member lookup distributes over the constraints of a non-inferable type variable. Each member is +bound to its matching receiver alternative, while `Self` continues to refer to the original type +variable. + +```py +from typing_extensions import Self, TypeVar + +class TextStream: + @property + def closed(self) -> bool: + return False + + def close(self) -> None: ... + def clone(self) -> Self: + raise NotImplementedError + +class BinaryStream: + @property + def closed(self) -> bool: + return False + + def close(self) -> None: ... + def clone(self) -> Self: + raise NotImplementedError + +Stream = TypeVar("Stream", TextStream, BinaryStream) + +def use_stream(stream: Stream) -> Stream: + # revealed: bool + reveal_type(stream.closed) + # revealed: (bound method Stream@use_stream when TextStream.close()) | (bound method Stream@use_stream when BinaryStream.close()) + reveal_type(stream.close) + if not stream.closed: + stream.close() + return stream.clone() +``` + +## Members of type variables with union upper bounds + +Unlike constraints, a union upper bound does not enumerate the possible assignments of a type +variable. Member lookup can still use the upper bound to prove that a common member is available. + +```py +from typing_extensions import Generic, TypeVar + +T = TypeVar("T") + +class Base(Generic[T]): + @property + def value(self) -> T: + raise NotImplementedError + +class A(Base[int]): ... +class B(Base[str]): ... + +U = TypeVar("U", bound=A | B) + +def use_union(value: A | B): + # revealed: int | str + reveal_type(value.value) + +def use_typevar(value: U): + # TODO: This should not error once member lookup supports union upper bounds. + # error: [invalid-attribute-access] "Invalid access to descriptor attribute `value`" + # revealed: int | str + reveal_type(value.value) +``` + +## Correlated constrained receiver calls + +Multiple occurrences of the same constrained type variable have the same assignment. Distributing +member lookup over the receiver's constraints must preserve that correlation when checking method +arguments. + +```py +from typing_extensions import TypeVar + +class A: + def combine(self, other: "A") -> None: ... + +class B: + def combine(self, other: "B") -> None: ... + +T = TypeVar("T", A, B) + +def combine(left: T, right: T) -> None: + # revealed: (bound method T@combine when A.combine(other: A)) | (bound method T@combine when B.combine(other: B)) + reveal_type(left.combine) + # TODO: This should not error once callable binding preserves the receiver branch correlation. + # error: [invalid-argument-type] "Argument to bound method `A.combine` is incorrect" + # error: [invalid-argument-type] "Argument to bound method `B.combine` is incorrect" + left.combine(right) +``` + +## Generic instance attributes accessed through classes + +An attribute whose type depends on a class type variable belongs to instances, not to a particular +specialization of the class. We reject reading or writing it through either the unspecialized class +or a generic alias, but retain its type for error recovery. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Node(Generic[T]): + label: T + + def __init__(self, label: T) -> None: + self.label = label + +# error: [invalid-attribute-access] "Cannot access generic instance attribute `label` through a class" +Node[int].label = 1 +# error: [invalid-attribute-access] +reveal_type(Node[int].label) # revealed: int +# error: [invalid-attribute-access] +Node.label = 1 +# error: [invalid-attribute-access] +Node.label + +node = Node(1) +reveal_type(node.label) # revealed: int +node.label = 2 +reveal_type(Node[int](1).label) # revealed: int +``` + +## Class attributes independent of type variables + +Generic classes can expose class variables, ordinary attributes whose types do not depend on their +type parameters, and methods. A generic instance attribute remains restricted even when it has a +default value in the class body. + +```py +from typing import ClassVar, Generic, TypeVar + +T = TypeVar("T") + +class Box(Generic[T]): + value: T | None = None + count: int = 0 + shared: ClassVar[int] = 0 + + def get(self) -> T | None: + return self.value + +# error: [invalid-attribute-access] +Box[int].value +# error: [invalid-attribute-access] +Box.value = None + +Box[int].count = 1 +reveal_type(Box.count) # revealed: int +Box.shared = 2 +reveal_type(Box[int].shared) # revealed: int +reveal_type(Box[int].get) # revealed: def get(self) -> int | None +``` + +## Inherited generic instance attributes + +The restriction also applies to inherited attributes. A subclass that fixes the type argument can +expose the inherited attribute without ambiguity. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Parent(Generic[T]): + value: list[T] + +class Child(Parent[T]): ... +class Concrete(Parent[int]): ... + +# error: [invalid-attribute-access] +Child.value +# error: [invalid-attribute-access] +Child[int].value = [1] +reveal_type(Concrete.value) # revealed: list[int] +``` + +Augmented assignments report the invalid access once. Deleting a generic instance attribute through +the generic class or alias is also invalid. + +```py +# error: [invalid-attribute-access] +Child[int].value += [1] +# error: [invalid-attribute-access] +del Child[int].value +``` + +## Generic attributes accessed through subclass receivers + +A `type[Parent[int]]` receiver can refer to a concrete subclass with its own class attributes. We +allow reads, writes, and deletion through these receivers, while still checking assignment types. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Parent(Generic[T]): + value: list[T] + +class Concrete(Parent[int]): + value = [1] + +def access(cls: type[Parent[int]], instance: Parent[int]) -> None: + reveal_type(cls.value) # revealed: list[int] + reveal_type(type(instance).value) # revealed: list[int] + cls.value = [1] + cls.value += [1] + del cls.value + + # error: [invalid-assignment] + cls.value = ["wrong"] + +access(Concrete, Concrete()) +``` + +The receiver can also retain an enclosing type variable, so the attribute has the specialization +supplied by the caller. + +```py +def generic_access(cls: type[Parent[T]]) -> list[T]: + return cls.value + +reveal_type(generic_access(Concrete)) # revealed: list[int] +``` + +## Descriptors on generic classes + +Descriptors define their own behavior for class access. A type variable in the descriptor's type +does not make accessing its result an ambiguous read of instance storage. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Descriptor(Generic[T]): + def __get__(self, instance: object, owner: type) -> int: + return 1 + +class Box(Generic[T]): + field: Descriptor[T] = Descriptor() + + @property + def value(self) -> T: + raise NotImplementedError + +reveal_type(Box.field) # revealed: int +reveal_type(Box[int].field) # revealed: int +reveal_type(Box.value) # revealed: property +reveal_type(Box[int].value) # revealed: property +``` + +When an attribute can be either a descriptor or an ordinary value, each alternative is checked +separately. A descriptor does not make class access to a generic list safe. + +```py +class Mixed(Generic[T]): + value: list[T] | Descriptor[T] = [] + +# error: [invalid-attribute-access] +Mixed[int].value = [1] +# error: [invalid-attribute-access] +reveal_type(Mixed[str].value) # revealed: list[str] | int +``` + +If only the descriptor depends on the type variable, class access is still valid. The ordinary value +has the same type for every specialization. + +```py +class DescriptorOrInt(Generic[T]): + value: int | Descriptor[T] = 0 + +reveal_type(DescriptorOrInt[str].value) # revealed: int +DescriptorOrInt[int].value = 1 +``` + +## Metaclass descriptors shadow generic instance attributes + +A data descriptor on the metaclass governs class access even when instances have an attribute of the +same name whose type depends on a type variable. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Meta(type): + @property + def value(cls) -> int: + return 1 + + @value.setter + def value(cls, value: int) -> None: ... + +class Box(Generic[T], metaclass=Meta): + value: T + +reveal_type(Box.value) # revealed: int +reveal_type(Box[str].value) # revealed: int +Box.value = 2 +reveal_type(Box[str]().value) # revealed: str +``` + ## Specializations propagate In a specialized generic alias, the specialization is applied to the attributes and methods of the @@ -1184,13 +1588,14 @@ from typing_extensions import Generic, ParamSpec, Protocol, TypeVar P = ParamSpec("P") T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) class GenericClass(Generic[P, T]): def hint(self) -> Callable[P, T]: raise NotImplementedError -class GenericProtocol(Protocol[P, T]): - def hint(self) -> Callable[P, T]: ... +class GenericProtocol(Protocol[P, T_co]): + def hint(self) -> Callable[P, T_co]: ... def class_case(x: GenericClass[[int], str]) -> None: # revealed: bound method GenericClass[(int, /), str].hint() -> ((int, /) -> str) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md index fa8e9b134d..af7c0c7cf3 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md @@ -75,9 +75,10 @@ argument _explicitly_ implements the protocol by listing it as a base class. from typing import Protocol, TypeVar T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) -class CanIndex(Protocol[T]): - def __getitem__(self, index: int, /) -> T: ... +class CanIndex(Protocol[T_co]): + def __getitem__(self, index: int, /) -> T_co: ... class ExplicitlyImplements(CanIndex[T]): def __getitem__(self, index: int, /) -> T: @@ -121,6 +122,11 @@ def takes_in_type(x: type[T]) -> type[T]: return x reveal_type(takes_in_type(int)) # revealed: type[int] + +def takes_in_type_of_list(x: type[list[T]]) -> T: + raise NotImplementedError + +reveal_type(takes_in_type_of_list(list[int])) # revealed: int ``` This also works when passing in arguments that are subclasses of the parameter type. @@ -135,6 +141,9 @@ reveal_type(takes_in_protocol(Sub())) # revealed: int reveal_type(takes_in_list(GenericSub[str]())) # revealed: list[str] reveal_type(takes_in_protocol(GenericSub[str]())) # revealed: str +reveal_type(takes_in_type_of_list(Sub)) # revealed: int +reveal_type(takes_in_type_of_list(GenericSub[str])) # revealed: str + class ExplicitSub(ExplicitlyImplements[int]): ... class ExplicitGenericSub(ExplicitlyImplements[T]): ... @@ -162,6 +171,113 @@ def pick(x: object) -> str | bool: reveal_type(pick([1])) # revealed: bool ``` +## Inferring generic typed-dictionary parameters + +A type variable that appears only inside a typed dictionary still makes the function generic, so +specialized typed dictionaries can be passed to it. + +```py +from typing import Generic, TypeVar, TypedDict + +T = TypeVar("T") + +class Item(TypedDict, Generic[T]): + value: T + +def accept(value: Item[T]) -> None: ... + +item: Item[int] = {"value": 1} + +reveal_type(accept) # revealed: def accept[T](value: Item[T]) +accept(item) +``` + +## Inferring a class-object parameter through a generic factory + +A factory can infer its type arguments from a specialized subclass of its class-object parameter. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + +class Base(Generic[T, U]): ... +class Specialized(Base[int, str]): ... + +def create(cls: type[Base[T, U]]) -> tuple[T, U]: + raise NotImplementedError + +reveal_type(create(Specialized)) # revealed: tuple[int, str] +``` + +## Inferring a class-object parameter through a generic method + +A method can likewise infer a type argument from the specialized bases of its class-object +parameter. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Option(Generic[T]): ... +class StringOption(Option[str]): ... + +class Options: + def get_value_for(self, option: type[Option[T]]) -> T: + raise NotImplementedError + +reveal_type(Options().get_value_for(StringOption)) # revealed: str +``` + +## Inferring a class-object parameter in a contravariant position + +A class-object parameter inside a contravariant generic places an upper bound on its inferred type +argument. A more specific witness should determine the result, including when the type variable has +its own declared upper bound. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") +StrT = TypeVar("StrT", bound=str) +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Covariant(Generic[T_co]): ... +class Sink(Generic[T_contra]): ... + +def infer(sink: Sink[type[Covariant[T]]], witness: T) -> T: + return witness + +def infer_bounded(sink: Sink[type[Covariant[StrT]]], witness: StrT) -> StrT: + return witness + +def _(sink: Sink[type[Covariant[object]]]) -> None: + reveal_type(infer(sink, 1)) # revealed: Literal[1] + reveal_type(infer_bounded(sink, "ok")) # revealed: Literal["ok"] +``` + +## Inferring a class-object parameter for a final generic class + +A final generic class has no subclasses, so its class-object parameter is an exact generic alias. +Its type arguments should still participate in inference. + +```py +from typing import Generic, TypeVar, final + +T = TypeVar("T") + +@final +class Final(Generic[T]): ... + +def infer(cls: type[Final[T]]) -> T: + raise NotImplementedError + +reveal_type(infer(Final[int])) # revealed: int +``` + ## Inferring tuple parameter types ```toml @@ -207,6 +323,58 @@ reveal_type(takes_homogeneous_tuple((42,))) # revealed: Literal[42] reveal_type(takes_homogeneous_tuple((42, 43))) # revealed: Literal[42, 43] ``` +## Inferring tuple parameter types from unions + +```toml +[environment] +python-version = "3.11" +``` + +Every member of a union argument contributes to the inferred element type of a homogeneous tuple +parameter. Different tuple lengths do not prevent inference, and an empty tuple contributes no +element types. + +```py +from typing import TypeVar + +class A: ... +class B: ... +class C: ... +class D: ... + +T = TypeVar("T") + +def elements(values: tuple[T, ...]) -> tuple[T, ...]: + return values + +def _( + same: tuple[A, A] | tuple[A, A, A], + mixed: tuple[A] | tuple[B, B], + possibly_empty: tuple[()] | tuple[A, A], +): + reveal_type(elements(same)) # revealed: tuple[A, ...] + reveal_type(elements(mixed)) # revealed: tuple[A | B, ...] + reveal_type(elements(possibly_empty)) # revealed: tuple[A, ...] +``` + +Fixed-length and mixed tuples infer type parameters from their corresponding element positions. + +```py +U = TypeVar("U") + +def swap(values: tuple[U, T]) -> tuple[T, U]: + return values[1], values[0] + +def _(pairs: tuple[A, B] | tuple[C, D]): + reveal_type(swap(pairs)) # revealed: tuple[B | D, A | C] + +def tail(values: tuple[A, *tuple[T, ...]]) -> tuple[T, ...]: + return values[1:] + +def _(tails: tuple[A, B] | tuple[A, C, C]): + reveal_type(tail(tails)) # revealed: tuple[B | C, ...] +``` + ## Inferring a bound typevar ```py @@ -436,10 +604,11 @@ def consume_callback(callback: Callable[[Row], None]) -> Row: reveal_type(consume_callback(callback)) # revealed: tuple[Any, ...] ``` -## Incompatible invariant protocol members +## Gradual invariant protocol members -When the same inferred type variable appears in multiple invariant protocol members, those members -must agree on one exact specialization. Gradual consistency between their types is not sufficient. +When the same inferred type variable appears in multiple invariant protocol members, fully static +member types must agree on one exact specialization. Gradual members remain conservative +alternatives because their equality cannot justify a transitive sequent proof. ```py from typing import Any, Generic, Protocol, TypeVar @@ -460,7 +629,7 @@ def infer_pair(value: Pair[T]) -> T: def check_pair(value: GradualPair[U]) -> None: # TODO: error: [invalid-argument-type] "Argument to function `infer_pair` is incorrect" - reveal_type(infer_pair(value)) # revealed: Unknown + reveal_type(infer_pair(value)) # revealed: tuple[U@check_pair, Any] | tuple[U@check_pair, int] ``` ## Prefer specific compatible constraints over gradual constraints @@ -869,6 +1038,7 @@ def opaque_decorator(f: Any) -> Any: def transparent_decorator(f: F) -> F: return f +# error: [dynamic-function-decorator-return] @opaque_decorator def decorated(t: T) -> None: # error: [redundant-cast] @@ -994,6 +1164,71 @@ def f(x: str): NamedTemporaryFile(prefix=x, suffix=".tar.gz") # Fine ``` +## Gradual bounds in generic union members + +A gradual bound does not prevent inference from an invariant union member: `str` satisfies `Any`, +and `list[str]` satisfies `list[Any]`. + +```py +from typing import Any, TypeVar + +class Other: ... + +T = TypeVar("T", bound=Any) + +def infer_any_bound(value: list[T] | Other) -> T: + raise NotImplementedError + +ListBoundT = TypeVar("ListBoundT", bound=list[Any]) + +def infer_list_bound(value: list[ListBoundT] | Other) -> ListBoundT: + raise NotImplementedError + +reveal_type(infer_any_bound(list[str]())) # revealed: str +reveal_type(infer_list_bound(list[list[str]]())) # revealed: list[str] +``` + +## Invalid bounds in generic union members + +An argument that violates a type variable's bound is rejected even when another union member is not +disjoint from the argument. `list[object]` and `Other` can have a common subclass, but +`list[object]` is not assignable to `Other`, and `object` does not satisfy the bound of `T`. + +```py +from typing import TypeVar + +class Other: ... + +T = TypeVar("T", bound=str) + +def accept(value: list[T] | Other) -> None: + pass + +accept([]) +accept(["valid"]) +accept(Other()) + +accept([object()]) # error: [invalid-argument-type] "does not satisfy upper bound `str`" +accept([1]) # error: [invalid-argument-type] "does not satisfy upper bound `str`" +``` + +## Disjoint generic union members + +The `list[T]` member cannot match a string or `None`. Inference through the remaining `T` member +rejects `None`, which satisfies neither of its constraints. + +```py +from typing import TypeVar + +T = TypeVar("T", str, bytes) + +def accept(value: T | list[T]) -> None: + pass + +def _(value: str | None): + accept(value) # error: [invalid-argument-type] "does not satisfy constraints" +``` + ## Nested functions see typevars bound in outer function ```py @@ -1179,6 +1414,15 @@ def list_caller(value: list[Any]) -> None: reveal_type(choose(value, [1])) # revealed: int | list[int] ``` +The `Unknown` returned by a lambda without declared parameter types is also gradual evidence: + +```py +lambda_identity = lambda value: value + +def lambda_caller(value: Any) -> None: + reveal_type(identity(lambda_identity(value))) # revealed: Unknown +``` + ## Ambiguous constrained TypeVar inference from a gradual callable return Constraint-set-native inference also preserves gradual evidence nested inside a callable. As above, diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 769951e064..ca7fc316d3 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -162,6 +162,195 @@ AmbiguousInferVariance = ParamSpec("AmbiguousInferVariance", infer_variance=cond CovariantAndInferred = ParamSpec("CovariantAndInferred", covariant=True, infer_variance=True) ``` +### Variance in method signatures + +Returning `Callable[P, None]` uses `P` contravariantly. Accepting that callable as a parameter +reverses the direction, using `P` covariantly. An explicit variance declaration must permit these +uses. + +```py +from typing import Callable, Generic, ParamSpec + +P_co = ParamSpec("P_co", covariant=True) +P_contra = ParamSpec("P_contra", contravariant=True) + +class Covariant(Generic[P_co]): + def accepts(self, callback: Callable[P_co, None]) -> None: ... + + # snapshot: invalid-generic-class + def returns(self) -> Callable[P_co, None]: + raise NotImplementedError + +class Contravariant(Generic[P_contra]): + def returns(self) -> Callable[P_contra, None]: + raise NotImplementedError + + # error: [invalid-generic-class] "Variance of type variable `P_contra` is incompatible with method `accepts`" + def accepts(self, callback: Callable[P_contra, None]) -> None: ... +``` + +```snapshot +error[invalid-generic-class]: Variance of type variable `P_co` is incompatible with method `returns` + --> src/mdtest_snippet.py:10:26 + | +10 | def returns(self) -> Callable[P_co, None]: + | ^^^^^^^^^^^^^^^^^^^^ +info: Type variable `P_co` is declared as covariant, but this method requires it to be contravariant +``` + +Forwarding `P.args` and `P.kwargs` also consumes `P`. + +```py +class CovariantForwarder(Generic[P_co]): + # snapshot: invalid-generic-class + def call(self, *args: P_co.args, **kwargs: P_co.kwargs) -> None: ... + +class ContravariantForwarder(Generic[P_contra]): + def call(self, *args: P_contra.args, **kwargs: P_contra.kwargs) -> None: ... + def returns(self) -> Callable[P_contra, None]: + raise NotImplementedError +``` + +```snapshot +error[invalid-generic-class]: Variance of type variable `P_co` is incompatible with method `call` + --> src/mdtest_snippet.py:21:27 + | +21 | def call(self, *args: P_co.args, **kwargs: P_co.kwargs) -> None: ... + | ^^^^^^^^^ +info: Type variable `P_co` is declared as covariant, but this method requires it to be contravariant +``` + +Constructors can establish a specialization without respecting its declared variance. A `ParamSpec` +bound to a function or method instead of the class ignores its declared variance. + +```py +class Constructed(Generic[P_co]): + def __init__(self, *args: P_co.args, **kwargs: P_co.kwargs) -> None: ... + def __new__(cls, *args: P_co.args, **kwargs: P_co.kwargs) -> "Constructed[P_co]": + raise NotImplementedError + +def returns() -> Callable[P_co, None]: + raise NotImplementedError + +class NotGeneric: + def returns(self) -> Callable[P_co, None]: + raise NotImplementedError +``` + +Class methods are checked too. A static method has no receiver, so its first parameter contributes +to its variance. + +```py +class MethodKinds(Generic[P_contra]): + @classmethod + # error: [invalid-generic-class] + def class_method(cls, callback: Callable[P_contra, None]) -> None: ... + @staticmethod + # error: [invalid-generic-class] + def static_method(callback: Callable[P_contra, None]) -> None: ... +``` + +### Variance in overloaded methods + +TODO: Variance validation is deferred for overloaded methods until it accounts for the complete +overload set. We miss the invalid use of a covariant `ParamSpec` in the first overload's return +type. + +```py +from typing import Callable, Generic, ParamSpec, overload + +P_co = ParamSpec("P_co", covariant=True) + +class Overloaded(Generic[P_co]): + @overload + # TODO: Emit `invalid-generic-class`; this use of `P_co` requires contravariance. + def method(self, value: int) -> Callable[P_co, None]: ... + @overload + def method(self, value: str) -> None: ... + def method(self, value: object) -> Callable[P_co, None] | None: + return None +``` + +### Variance in generic methods + +A method's independent type variable can accept any argument. The `Callable[P_contra, None]` arm in +the parameter annotation is redundant because `T` already accepts that argument, and the result +includes both types. This signature respects the class's contravariance. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Callable, Generic, ParamSpec, TypeVar + +P_contra = ParamSpec("P_contra", contravariant=True) +T = TypeVar("T") + +class Contravariant(Generic[P_contra]): + def identity(self, value: Callable[P_contra, None] | T) -> Callable[P_contra, None] | T: + return value +``` + +TODO: Until those relationships are checked, we defer validation for generic methods, including type +parameters scoped to a returned callable. The incompatible return annotations below are not yet +reported. + +```py +P_co = ParamSpec("P_co", covariant=True) + +class GenericMethods(Generic[P_co]): + # TODO: Emit `invalid-generic-class`; this use of `P_co` requires contravariance. + def legacy(self, value: T) -> Callable[P_co, T]: + raise NotImplementedError + + # TODO: Emit `invalid-generic-class`; this use of `P_co` requires contravariance. + def pep695[U](self, value: U) -> Callable[P_co, U]: + raise NotImplementedError + + # TODO: Emit `invalid-generic-class`; this use of `P_co` requires contravariance. + def returned_callable(self) -> Callable[P_co, T]: + raise NotImplementedError +``` + +### Variance with explicit receivers + +`Self` and the class's own `ParamSpec` do not restrict the receiver. A covariant `ParamSpec` in a +returned callable's parameters still violates covariance. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import Callable, Generic, ParamSpec, Self + +P_co = ParamSpec("P_co", covariant=True) + +class Unrestricted(Generic[P_co]): + # error: [invalid-generic-class] + def method(self: Self) -> Callable[P_co, None]: + raise NotImplementedError + + @classmethod + # error: [invalid-generic-class] + def class_method(cls: type["Unrestricted[P_co]"]) -> Callable[P_co, None]: + raise NotImplementedError + + def accepts(self: "Unrestricted[P_co]", callback: Callable[P_co, None]) -> None: ... +``` + +A specialized receiver does not make this contravariant use of `P_co` valid. + +```py +class Restricted(Generic[P_co]): + # error: [invalid-generic-class] + def method(self: "Restricted[[int]]") -> Callable[P_co, None]: + raise NotImplementedError +``` + ### Defaults ```toml @@ -611,7 +800,7 @@ reveal_type(OnlyParamSpec[...]().attr) # revealed: (...) -> None def func(c: Callable[P2, None]): reveal_type(OnlyParamSpec[P2]().attr) # revealed: (**P2@func) -> None -# error: [invalid-type-arguments] "ParamSpec `P2` is unbound" +# error: [unbound-type-variable] "Type variable `P2` is not bound to any outer generic context" reveal_type(OnlyParamSpec[P2]().attr) # revealed: (...) -> None # error: [invalid-type-arguments] "No type argument provided for required type variable `P1` of class `OnlyParamSpec`" @@ -656,7 +845,7 @@ reveal_type(TypeVarAndParamSpec[int, [str]]().attr) # revealed: (str, /) -> int reveal_type(TypeVarAndParamSpec[int, ...]().attr) # revealed: (...) -> int reveal_type(ParamSpecAndTypeVar[[int, str], str]().attr) # revealed: (int, str, /) -> str -# error: [invalid-type-arguments] "ParamSpec `P2` is unbound" +# error: [unbound-type-variable] "Type variable `P2` is not bound to any outer generic context" reveal_type(TypeVarAndParamSpec[int, P2]().attr) # revealed: (...) -> int # error: [invalid-type-arguments] "Type argument for `ParamSpec` must be either a list of types, `ParamSpec`, `Concatenate`, or `...`" reveal_type(TypeVarAndParamSpec[int, int]().attr) # revealed: (...) -> int @@ -746,6 +935,203 @@ takes_int_job(defaulted_job) takes_int_job(wrong_job) # error: [invalid-argument-type] ``` +A fixed `ParamSpec` can contain required parameters. A wrapper around such a callback cannot be used +as a wrapper around a callback that accepts no arguments. + +```py +def erase_parameters(job: Job[P]) -> Job[[]]: + return job # error: [invalid-return-type] +``` + +The same restriction applies in the other direction when a class consumes callbacks. A consumer of +callbacks with no parameters cannot accept a callback with arbitrary required parameters. + +```py +P_co = ParamSpec("P_co", covariant=True) + +class CallbackConsumer(Generic[P_co]): + def consume(self, callback: Callable[P_co, None]) -> None: ... + +def broaden_parameters(consumer: CallbackConsumer[[]]) -> CallbackConsumer[P_co]: + return consumer # error: [invalid-return-type] +``` + +## Inferring an invariant `ParamSpec` through `Concatenate` + +A `Concatenate` prefix is positional-only, so a callback whose first parameter also accepts a +keyword is not compatible with an invariant wrapper. Even though that argument is rejected, its +remaining parameters must still be inferred precisely. + +```py +from typing import Callable, Concatenate, Generic, ParamSpec + +P = ParamSpec("P") + +class Callback(Generic[P]): + def __init__(self, callback: Callable[P, None]) -> None: ... + +def without_first(callback: Callback[Concatenate[object, P]]) -> Callable[P, None]: + raise NotImplementedError + +def original(first: object, value: str) -> None: ... + +wrapped = Callback(original) +remaining = without_first(wrapped) # error: [invalid-argument-type] +reveal_type(remaining) # revealed: (value: str) -> None +remaining(1) # error: [invalid-argument-type] +``` + +When constructed inline, `Callback` infers the positional-only prefix based on the outer type +context: + +```py +remaining = without_first(Callback(original)) +reveal_type(remaining) # revealed: (value: str) -> None +remaining(1) # error: [invalid-argument-type] +``` + +## Inferring a contravariant `ParamSpec` through `Concatenate` + +The same callback is compatible with a contravariant wrapper, and its remaining parameters must +still be inferred precisely enough to reject an incompatible later argument. + +```py +from typing import Callable, Concatenate, Generic, ParamSpec, TypeVar + +P = ParamSpec("P", contravariant=True) + +class Callback(Generic[P]): + def __init__(self, callback: Callable[P, None]) -> None: ... + +def without_first(callback: Callback[Concatenate[object, P]]) -> Callable[P, None]: + raise NotImplementedError + +def original(first: object, value: str) -> None: ... + +remaining = without_first(Callback(original)) +reveal_type(remaining) # revealed: (value: str) -> None +remaining(1) # error: [invalid-argument-type] +``` + +A contravariant callback can accept a broader positional-only prefix than a bounded type variable +requires. The separate `Middle()` argument determines the narrower specialization without rejecting +the valid callback. + +```py +class Base: ... +class Middle(Base): ... +class Other: ... + +Bounded = TypeVar("Bounded", bound=Middle) +Q = ParamSpec("Q") + +def accepts_base(first: Base, /, value: str) -> None: ... +def bounded(callback: Callback[Concatenate[Bounded, Q]], witness: Bounded) -> tuple[Bounded, Callable[Q, None]]: + raise NotImplementedError + +bounded_result = bounded(Callback(accepts_base), Middle()) +reveal_type(bounded_result) # revealed: tuple[Middle, (value: str) -> None] +bounded_result[1](1) # error: [invalid-argument-type] +``` + +The same contravariant relationship remains valid when the prefix type variable has explicit +constraints instead of an upper bound. + +```py +Constrained = TypeVar("Constrained", Middle, Other) + +def constrained(callback: Callback[Concatenate[Constrained, Q]], witness: Constrained) -> tuple[Constrained, Callable[Q, None]]: + raise NotImplementedError + +constrained_result = constrained(Callback(accepts_base), Middle()) +reveal_type(constrained_result) # revealed: tuple[Middle, (value: str) -> None] +constrained_result[1](1) # error: [invalid-argument-type] +``` + +## Inferring a covariant `ParamSpec` through `Concatenate` + +A covariant wrapper containing a callback with a narrower positional-only prefix remains valid, +whether the wrapper is stored first or constructed inline. + +```py +from typing import Callable, Concatenate, Generic, ParamSpec + +P = ParamSpec("P", covariant=True) +Q = ParamSpec("Q") + +class Base: ... +class Middle(Base): ... + +class Callback(Generic[P]): + def __init__(self, callback: Callable[P, None]) -> None: ... + +def without_first(callback: Callback[Concatenate[Base, Q]]) -> Callable[Q, None]: + raise NotImplementedError + +def original(first: Middle, /, value: str) -> None: ... + +wrapped = Callback(original) +# TODO: Should reveal `(value: str) -> None`. Needs ParamSpecs in the new constraint solver. +reveal_type(without_first(wrapped)) # revealed: (...) -> None +# TODO: Should reveal `(value: str) -> None`. Needs ParamSpecs in the new constraint solver. +reveal_type(without_first(Callback(original))) # revealed: (...) -> None +``` + +## Inferring through unions of structural `ParamSpec` protocols + +Different protocols with the same parameter list can satisfy a target protocol structurally, even +when they appear in a union nested inside an invariant or contravariant wrapper. + +```py +from typing import Generic, ParamSpec, Protocol, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") +TContra = TypeVar("TContra", contravariant=True) + +class Invariant(Generic[T]): + value: T + +class Contravariant(Generic[TContra]): + def put(self, value: TContra) -> None: ... + +class Target(Protocol[P]): + def call(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +class Actual(Protocol[P]): + def call(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +class Other(Protocol[P]): + def call(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def invariant(value: Invariant[Target[P]]) -> Target[P]: + raise NotImplementedError + +def contravariant(value: Contravariant[Target[P]]) -> Target[P]: + raise NotImplementedError + +def compatible( + first: Invariant[Actual[[str]] | Other[[str]]], + second: Contravariant[Actual[[str]] | Other[[str]]], +) -> None: + reveal_type(invariant(first)) # revealed: Target[(str, /)] + reveal_type(contravariant(second)) # revealed: Target[(str, /)] +``` + +Union members with incompatible parameter lists cannot satisfy either wrapper. Their signatures must +remain visible in the inferred result instead of collapsing to a gradual parameter list. + +```py +def incompatible( + first: Invariant[Actual[[str]] | Other[[bytes]]], + second: Contravariant[Actual[[str]] | Other[[bytes]]], +) -> None: + # error: [invalid-argument-type] + reveal_type(invariant(first)) # revealed: Target[((str, /)) | ((bytes, /))] + # error: [invalid-argument-type] + reveal_type(contravariant(second)) # revealed: Target[((str, /)) | ((bytes, /))] +``` + ## `ParamSpec` cannot specialize a `TypeVar`, and vice versa diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md index 832cb3dda7..bfe26fd946 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md @@ -242,6 +242,184 @@ Ts_Contra = TypeVarTuple("Ts_Contra", contravariant=True) Ts_Inferred = TypeVarTuple("Ts_Inferred", infer_variance=True) ``` +### Variance in method signatures + +A tuple is covariant in its unpacked type variables. Returning `tuple[*Ts]` therefore uses `Ts` +covariantly, while accepting either `*args: *Ts` or a parameter annotated as `tuple[*Ts]` uses it +contravariantly. + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import Generic, TypeVarTuple + +Ts_co = TypeVarTuple("Ts_co", covariant=True) +Ts_contra = TypeVarTuple("Ts_contra", contravariant=True) + +class Covariant(Generic[*Ts_co]): + def returns(self) -> tuple[*Ts_co]: + raise NotImplementedError + + # snapshot: invalid-generic-class + def accepts(self, *args: *Ts_co) -> None: ... + +class Contravariant(Generic[*Ts_contra]): + def accepts(self, *args: *Ts_contra) -> None: ... + + # error: [invalid-generic-class] "Variance of type variable `Ts_contra` is incompatible with method `returns`" + def returns(self) -> tuple[*Ts_contra]: + raise NotImplementedError +``` + +```snapshot +error[invalid-generic-class]: Variance of type variable `Ts_co` is incompatible with method `accepts` + --> src/mdtest_snippet.py:11:30 + | +11 | def accepts(self, *args: *Ts_co) -> None: ... + | ^^^^^^ +info: Type variable `Ts_co` is declared as covariant, but this method requires it to be contravariant +``` + +Passing the tuple as a single argument has the same variance as unpacking it into variadic +arguments. + +```py +class CovariantTuple(Generic[*Ts_co]): + # error: [invalid-generic-class] + def accepts(self, value: tuple[*Ts_co]) -> None: ... + +class ContravariantTuple(Generic[*Ts_contra]): + def accepts(self, value: tuple[*Ts_contra]) -> None: ... +``` + +A callable reverses the variance of its argument types. Returning a callable that consumes +`Ts_contra` is valid, while accepting the same callable is not. + +```py +from typing import Callable + +class Callbacks(Generic[*Ts_contra]): + def returns(self) -> Callable[[*Ts_contra], None]: + raise NotImplementedError + + # error: [invalid-generic-class] + def accepts(self, callback: Callable[[*Ts_contra], None]) -> None: ... +``` + +Constructors and function-scoped type variables do not constrain the class's variance. + +```py +class Constructed(Generic[*Ts_co]): + def __init__(self, *args: *Ts_co) -> None: ... + def __new__(cls, *args: *Ts_co) -> "Constructed[*Ts_co]": + raise NotImplementedError + +def accepts(*args: *Ts_co) -> None: ... + +class NotGeneric: + def accepts(self, *args: *Ts_co) -> None: ... +``` + +### Variance in overloaded methods + +TODO: Variance validation is deferred for overloaded methods until it accounts for the complete +overload set. We miss the invalid use of a covariant `TypeVarTuple` in the first overload's +parameter. + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import Generic, TypeVarTuple, overload + +Ts_co = TypeVarTuple("Ts_co", covariant=True) + +class Overloaded(Generic[*Ts_co]): + @overload + # TODO: Emit `invalid-generic-class`; this use of `Ts_co` requires contravariance. + def method(self, value: tuple[*Ts_co]) -> int: ... + @overload + def method(self, value: int) -> str: ... + def method(self, value: tuple[*Ts_co] | int) -> int | str: + return 0 +``` + +### Variance in generic methods + +A method's independent type variable can accept any argument. The `tuple[*Ts_co]` arm in the +parameter annotation is redundant because `T` already accepts that argument, and the result includes +both types. This signature respects the class's covariance. + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import Generic, TypeVar, TypeVarTuple + +Ts_co = TypeVarTuple("Ts_co", covariant=True) +T = TypeVar("T") + +class Covariant(Generic[*Ts_co]): + def identity(self, value: tuple[*Ts_co] | T) -> tuple[*Ts_co] | T: + return value +``` + +TODO: Variance validation is deferred for methods with independent type parameters. The +contravariant `TypeVarTuple` in the return annotations below would otherwise be rejected. + +```py +Ts_contra = TypeVarTuple("Ts_contra", contravariant=True) + +class GenericMethods(Generic[*Ts_contra]): + # TODO: Emit `invalid-generic-class`; this use of `Ts_contra` requires covariance. + def legacy(self, value: T) -> tuple[*Ts_contra]: + raise NotImplementedError + + # TODO: Emit `invalid-generic-class`; this use of `Ts_contra` requires covariance. + def pep695[U](self, value: U) -> tuple[*Ts_contra]: + raise NotImplementedError +``` + +### Variance with explicit receivers + +`Self` and the class's own `TypeVarTuple` do not restrict the receiver. Consuming the covariant +tuple still violates covariance. + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import Generic, Self, TypeVarTuple + +Ts_co = TypeVarTuple("Ts_co", covariant=True) + +class Unrestricted(Generic[*Ts_co]): + # error: [invalid-generic-class] + def method(self: "Unrestricted[*Ts_co]", value: tuple[*Ts_co]) -> None: ... + @classmethod + # error: [invalid-generic-class] + def class_method(cls: type[Self], value: tuple[*Ts_co]) -> None: ... + def returns(self: Self) -> tuple[*Ts_co]: + raise NotImplementedError +``` + +A specialized receiver does not make this contravariant use of `Ts_co` valid. + +```py +class Restricted(Generic[*Ts_co]): + # error: [invalid-generic-class] + def method(self: "Restricted[int]", value: tuple[*Ts_co]) -> None: ... +``` + ## Generic Classes ### Multiple `TypeVarTuple`s @@ -327,6 +505,50 @@ reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Un reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` +### Inherited specializations containing `Never` + +A `Never` argument in a variadic generic must retain its position when a subclass forwards its type +arguments to a generic base. + +```py +from typing import Any, Generic, Never, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +class Kind(Generic[*Ts]): ... +class SupportsKind(Kind[*Ts]): ... +class Container(SupportsKind[int, Never]): ... + +def _(value: Container) -> None: + expected: Kind[int, Any] = value +``` + +### Callbacks returning containers with `Never` arguments + +A callback can return a concrete container whose variadic base contains `Never` when the expected +container type uses `Any` in that position. + +```py +from collections.abc import Callable +from typing import Any, Generic, Never, TypeVar, TypeVarTuple + +T = TypeVar("T") +U = TypeVar("U") +Ts = TypeVarTuple("Ts") + +class Kind(Generic[T, *Ts]): ... + +class Result(Kind[T, Never]): + def bind(self, callback: Callable[[T], Kind[U, Any]]) -> "Result[U]": + raise NotImplementedError + +def parse(value: str) -> Result[int]: + raise NotImplementedError + +def _(result: Result[str]) -> None: + reveal_type(result.bind(parse)) # revealed: Result[int] +``` + ### `TypeVarTuple` with `ParamSpec` ```py @@ -370,13 +592,12 @@ class Variadic(Generic[*Ts]): reveal_type(Positional(())) # revealed: Positional[()] reveal_type(Positional((1, "a"))) # revealed: Positional[int, str] -# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. -reveal_type(Variadic()) # revealed: Variadic[*tuple[Unknown, ...]] -reveal_type(Variadic(1, "a")) # revealed: Variadic[*tuple[Unknown, ...]] +reveal_type(Variadic()) # revealed: Variadic[()] +reveal_type(Variadic(1, "a")) # revealed: Variadic[int, str] def _(i: int, s: str) -> None: reveal_type(Positional((i, s))) # revealed: Positional[int, str] - reveal_type(Variadic(i, s)) # revealed: Variadic[*tuple[Unknown, ...]] + reveal_type(Variadic(i, s)) # revealed: Variadic[int, str] ``` ### Unspecified type arguments @@ -486,6 +707,25 @@ def _( reveal_type(a10) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` +### Legacy aliases containing `Never` + +A legacy alias must retain free type variables that appear alongside a `Never` argument in a +variadic specialization. + +```py +from typing import Generic, Never, TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts = TypeVarTuple("Ts") + +class Container(Generic[*Ts]): ... + +Padded = Container[T, Never] + +def _(value: Padded[int]) -> None: + reveal_type(value) # revealed: Container[int, Never] +``` + ### Variadic arguments require variadic aliases An unpacked type variable tuple or arbitrary-length tuple cannot be used to specialize a @@ -618,3 +858,74 @@ reveal_type(test(fn0)) # revealed: tuple[()] reveal_type(test(fn1)) # revealed: tuple[str] reveal_type(test(fn2)) # revealed: tuple[str, bytes] ``` + +## Missing unpack + +A legacy type variable tuple must also be unpacked. In a tuple annotation, it recovers as +`*tuple[Unknown, ...]`, so `tuple[Ts]` becomes `tuple[Unknown, ...]`, rather than the single-element +`tuple[Unknown]`. This avoids a cascading assignment error when the value is assigned to a correctly +unpacked tuple annotation. + +```py +from typing import Generic, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +# error: [invalid-generic-class] "`TypeVarTuple` must be unpacked with `*` or `Unpack[]` when used as an argument to `Generic`" +class Container(Generic[Ts]): + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + def __init__(self, values: tuple[Ts]) -> None: + reveal_type(values) # revealed: tuple[Unknown, ...] + self.values: tuple[*Ts] = values +``` + +`typing.Tuple` uses the same recovery as the built-in `tuple`. + +```py +from typing import Tuple + +# error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +def legacy_tuple(values: Tuple[Ts]) -> None: + reveal_type(values) # revealed: tuple[Unknown, ...] +``` + +## Missing unpack in implicit tuple aliases + +Tuple specializations used to define implicit type aliases also recover bare type variable tuples as +`*tuple[Unknown, ...]`. This applies to both `tuple` and `typing.Tuple`. + +```py +from typing import Tuple, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +BuiltinAlias = tuple[Ts] # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +LegacyAlias = Tuple[Ts] # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + +reveal_type(BuiltinAlias) # revealed: +reveal_type(LegacyAlias) # revealed: + +def aliases(builtin: BuiltinAlias, legacy: LegacyAlias) -> None: + reveal_type(builtin) # revealed: tuple[Unknown, ...] + reveal_type(legacy) # revealed: tuple[Unknown, ...] +``` + +## Missing unpack in a union-valued tuple element + +In a homogeneous tuple annotation, a name that may refer to a bare type variable tuple or a valid +element type preserves the valid alternative and recovers the bare pack to `Unknown`. + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple("Ts") + +def condition() -> bool: + return True + +Element = Ts if condition() else int + +# error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +def homogeneous_union(values: tuple[Element, ...]) -> None: + reveal_type(values) # revealed: tuple[Unknown | int, ...] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md index 435ca08fe7..95335ce8df 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md @@ -40,9 +40,20 @@ def collect(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: reveal_type(args) # revealed: tuple[*Ts@collect] raise NotImplementedError -# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. -reveal_type(collect()) # revealed: tuple[Unknown, ...] -reveal_type(collect(1, "a")) # revealed: tuple[Unknown, ...] +reveal_type(collect()) # revealed: tuple[()] +reveal_type(collect(1, "a")) # revealed: tuple[Literal[1], Literal["a"]] +``` + +The legacy spelling must also preserve argument-derived types when a surrounding assignment expects +an incompatible return type. + +```py +inferred = collect(1) +reveal_type(inferred) # revealed: tuple[Literal[1]] +# error: [invalid-assignment] +indirect: tuple[str] = inferred +# error: [invalid-assignment] +direct: tuple[str] = collect(1) ``` ## Callable parameters @@ -71,6 +82,24 @@ reveal_type(invoke(format_value, 1, "value")) # revealed: str reveal_type(invoke(format_value, 1)) # revealed: str ``` +## Forwarding a `ParamSpec` through an unpacked type variable tuple + +A callable that forwards a parameter specification can itself be passed, with its arguments, to a +callable whose positional parameters are described by an unpacked type variable tuple. + +```py +from typing import Callable, ParamSpec, TypeVarTuple, Unpack + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") + +def invoke(callback: Callable[[Unpack[Ts]], None], *args: Unpack[Ts]) -> None: ... +def forward(callback: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... +def one_arg(value: int) -> None: ... + +invoke(forward, one_arg, 1) +``` + ## Type aliases A legacy alias can use `Unpack[Ts]` and accept either individual types or an unpacked tuple type. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md index 1b5a77b414..961ce52893 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md @@ -402,6 +402,74 @@ reveal_type(Valid[int, str, None]()) # revealed: Valid[int, str, None] class Invalid(Generic[U]): ... ``` +### Defaults containing bounded type variables + +```toml +[environment] +python-version = "3.13" +``` + +A default can specialize a bounded generic with another type variable whose upper bound is +compatible. Applying the default substitutes the actual type argument, without replacing it with its +upper bound. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T", bound=int) + +class Box(Generic[T]): ... + +B = TypeVar("B", default=Box[T]) + +class Holder(Generic[T, B]): ... + +reveal_type(Holder[bool]()) # revealed: Holder[bool, Box[bool]] +``` + +We reject a nested type argument whose upper bound is incompatible with the generic's bound: + +```py +U = TypeVar("U", bound=str) + +# error: [invalid-type-arguments] +Invalid = TypeVar("Invalid", default=Box[U]) +``` + +### Defaults containing constrained type variables + +```toml +[environment] +python-version = "3.13" +``` + +A constrained type variable can appear inside a default when each of its constraints is allowed by +the nested generic. The selected type argument is preserved in the default. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T", int, str) + +class Box(Generic[T]): ... + +B = TypeVar("B", default=Box[T]) + +class Holder(Generic[T, B]): ... + +reveal_type(Holder[str]()) # revealed: Holder[str, Box[str]] +``` + +We reject a nested type argument if one of its constraints is incompatible with the generic's +constraints: + +```py +U = TypeVar("U", int, bytes) + +# error: [invalid-type-arguments] +Invalid = TypeVar("Invalid", default=Box[U]) +``` + ### Invalid defaults A TypeVar default must be compatible with its bound or constraints. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md index 72005757ad..94f0755704 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md @@ -319,12 +319,12 @@ class GoodContravariant(Generic[T_contra]): class GoodInvariant(Generic[T]): value: T -# snapshot: invalid-generic-class class BadCovariantParameter(Generic[T_co]): + # snapshot: invalid-generic-class def set(self, value: T_co) -> None: ... -# error: [invalid-generic-class] "Variance of type variable `T_contra` is incompatible with its usage in `BadContravariantReturn`" class BadContravariantReturn(Generic[T_contra]): + # error: [invalid-generic-class] "Variance of type variable `T_contra` is incompatible with method `get`" def get(self) -> T_contra: raise ValueError @@ -334,12 +334,938 @@ class BadCovariantAttribute(Generic[T_co]): ``` ```snapshot -error[invalid-generic-class]: Variance of type variable `T_co` is incompatible with its usage in `BadCovariantParameter` - --> src/mdtest_snippet.py:18:7 +error[invalid-generic-class]: Variance of type variable `T_co` is incompatible with method `set` + --> src/mdtest_snippet.py:19:26 | -18 | class BadCovariantParameter(Generic[T_co]): - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -help: Type variable `T_co` is declared as covariant, but `BadCovariantParameter` uses it contravariantly +19 | def set(self, value: T_co) -> None: ... + | ^^^^ +info: Type variable `T_co` is declared as covariant, but this method requires it to be contravariant +``` + +## Variance in method signatures + +Methods must respect the declared variance of the class's type variables. Covariant variables can be +returned but cannot be consumed, while contravariant variables can be consumed but not returned. + +```py +from typing import Callable, Generic, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Covariant(Generic[T_co]): + def returns(self) -> T_co: + raise NotImplementedError + + # snapshot: invalid-generic-class + def accepts(self, value: T_co) -> None: ... + def accepts_callback(self, callback: Callable[[T_co], None]) -> None: ... + +class Contravariant(Generic[T_contra]): + def accepts(self, value: T_contra) -> None: ... + + # error: [invalid-generic-class] + def returns(self) -> T_contra: + raise NotImplementedError +``` + +```snapshot +error[invalid-generic-class]: Variance of type variable `T_co` is incompatible with method `accepts` + --> src/mdtest_snippet.py:11:30 + | +11 | def accepts(self, value: T_co) -> None: ... + | ^^^^ +info: Type variable `T_co` is declared as covariant, but this method requires it to be contravariant +``` + +Returning a mutable `list[T_co]` requires invariance, as does using `T_co` in both parameter and +return positions. In `identity`, the callback parameter and return annotation respect covariance; +only the `value` parameter violates it. + +```py +class InvariantMethods(Generic[T_co]): + # snapshot: invalid-generic-class + def values(self) -> list[T_co]: + raise NotImplementedError + + # snapshot: invalid-generic-class + def identity(self, callback: Callable[[T_co], None], value: T_co) -> T_co: + return value +``` + +```snapshot +error[invalid-generic-class]: Variance of type variable `T_co` is incompatible with method `values` + --> src/mdtest_snippet.py:22:25 + | +22 | def values(self) -> list[T_co]: + | ^^^^^^^^^^ +info: Type variable `T_co` is declared as covariant, but this method requires it to be invariant + + +error[invalid-generic-class]: Variance of type variable `T_co` is incompatible with method `identity` + --> src/mdtest_snippet.py:26:65 + | +26 | def identity(self, callback: Callable[[T_co], None], value: T_co) -> T_co: + | ^^^^ +info: Type variable `T_co` is declared as covariant, but this method requires it to be invariant +``` + +The same variable can be bound independently to a generic method. Its declared variance does not +apply to that method binding. A nested function is not part of the class's interface either. + +```py +class GenericMethod(Generic[T_contra]): + def identity(self, value: T_co) -> T_co: + return value + +class NestedFunction(Generic[T_co]): + def method(self) -> None: + def accepts(value: T_co) -> None: ... +``` + +Class methods also respect the declared variance. A static method has no receiver, so its first +parameter still contributes to its variance. + +```py +class ClassMethods(Generic[T_co]): + @classmethod + def returns(cls) -> T_co: + raise NotImplementedError + + @classmethod + # error: [invalid-generic-class] + def accepts(cls, value: T_co) -> None: ... + @staticmethod + # error: [invalid-generic-class] + def static_accepts(value: T_co) -> None: ... +``` + +## Variance in generic methods + +A method's independent type variable can accept arguments outside the class's covariant value type. +The `V_co` arm in the parameter annotation is redundant: `T` already accepts any argument, and the +result includes both types. This signature does not require the class to be invariant. + +```py +from typing import Generic, TypeVar + +V_co = TypeVar("V_co", covariant=True) +T = TypeVar("T") + +class Covariant(Generic[V_co]): + def identity(self, value: V_co | T) -> V_co | T: + return value +``` + +Reusing `T` in another parameter can constrain which arguments the method accepts, so these generic +methods do not always respect covariance. TODO: We defer variance checking for independently generic +methods until we can account for these relationships, and miss this invalid use of `V_co`. + +```py +class Correlated(Generic[V_co]): + # TODO: Emit `invalid-generic-class`; this use of `V_co` requires contravariance. + def get(self, value: V_co | T, other: T) -> T: + raise NotImplementedError +``` + +## Overloads with generic fallbacks + +An overload that consumes a covariant type variable can be covered by a generic fallback. The second +overload below accepts the first overload's arguments with the same result when `T` is `T_co`. The +complete method therefore respects covariance. + +```py +from typing import Generic, TypeVar, overload + +T_co = TypeVar("T_co", covariant=True) +T = TypeVar("T") + +class Sequence(Generic[T_co]): + @overload + def __add__(self, value: tuple[T_co, ...]) -> tuple[T_co, ...]: ... + @overload + def __add__(self, value: tuple[T, ...]) -> tuple[T_co | T, ...]: ... + def __add__(self, value: tuple[object, ...]) -> tuple[object, ...]: + return value +``` + +## Overloaded mapping defaults + +A mapping can similarly accept its covariant value type as a default when another overload accepts +arbitrary defaults. The generic overload covers the specialized default without losing its result +type. The key parameter is invariant and does not affect this value-variance relationship. + +`mapping.pyi`: + +```pyi +from typing import Generic, TypeVar, overload + +K = TypeVar("K") +V_co = TypeVar("V_co", covariant=True) +T = TypeVar("T") + +class Mapping(Generic[K, V_co]): + @overload + def get(self, key: K) -> V_co | None: ... + @overload + def get(self, key: K, default: V_co) -> V_co: ... + @overload + def get(self, key: K, default: T) -> V_co | T: ... +``` + +## Variance in overloaded methods + +TODO: We defer variance checking for overloaded methods until we can account for the complete +overload set. This also means we miss invalid uses of covariant variables that no other overload +covers. + +```py +from typing import Generic, TypeVar, overload + +T_co = TypeVar("T_co", covariant=True) + +class Overloaded(Generic[T_co]): + @overload + # TODO: Emit `invalid-generic-class`; this use of `T_co` requires contravariance. + def method(self, value: T_co) -> int: ... + @overload + def method(self, value: int, other: int) -> int: ... + def method(self, value: object, other: int = 0) -> int: + return 0 +``` + +## Variance with explicit receivers + +Annotating the receiver with `Self` or the class's own type parameters does not restrict which +specializations can call the method. These annotations do not affect variance checking, for either +instance methods or class methods. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import Generic, Self, TypeVar + +T_co = TypeVar("T_co", covariant=True) + +class Unrestricted(Generic[T_co]): + # error: [invalid-generic-class] + def accepts_self(self: Self, value: T_co) -> None: ... + # error: [invalid-generic-class] + def accepts_identity(self: "Unrestricted[T_co]", value: T_co) -> None: ... + def returns(self: "Unrestricted[T_co]") -> T_co: + raise NotImplementedError + + @classmethod + # error: [invalid-generic-class] + def class_accepts_self(cls: type[Self], value: T_co) -> None: ... + @classmethod + # error: [invalid-generic-class] + def class_accepts_identity(cls: type["Unrestricted[T_co]"], value: T_co) -> None: ... + @classmethod + def class_returns(cls: type[Self]) -> T_co: + raise NotImplementedError +``` + +A specialized receiver does not in general make an incompatible use of a covariant type variable +valid. These methods still consume the class's type variable. + +```py +class Restricted(Generic[T_co]): + # error: [invalid-generic-class] + def accepts(self: "Restricted[int]", value: T_co) -> None: ... + @classmethod + # error: [invalid-generic-class] + def class_accepts(cls: type["Restricted[int]"], value: T_co) -> None: ... +``` + +The receiver can sometimes make a use of the type variable redundant. Here, `T_co` must be a subtype +of `int`, so `T_co | int` accepts exactly the same arguments as `int`. This method does not +constrain the class's variance. + +```py +class Redundant(Generic[T_co]): + # TODO: Do not report an error; the receiver makes the `T_co` arm redundant. + # error: [invalid-generic-class] + def accepts(self: "Redundant[int]", value: T_co | int) -> None: ... +``` + +## Variance in decorated methods + +A decorator can replace a method with a value that does not consume the class's type variable. +Variance checking should account for the exposed attribute, rather than the original signature. + +```py +from typing import Generic, TypeVar + +T_co = TypeVar("T_co", covariant=True) + +def replace(func: object) -> int: + return 1 + +class Decorated(Generic[T_co]): + @replace + # TODO: Do not report an error; the decorator replaces the method with an `int`. + # error: [invalid-generic-class] + def method(self, value: T_co) -> None: ... + +reveal_type(Decorated[int].method) # revealed: int +``` + +## Variance in deleted methods + +A method deleted in the class body is not part of the class's interface and does not constrain its +variance. + +```py +from typing import Generic, TypeVar + +T_co = TypeVar("T_co", covariant=True) + +class Deleted(Generic[T_co]): + # TODO: Do not report an error; the method is absent from the final class interface. + # error: [invalid-generic-class] + def method(self, value: T_co) -> None: ... + + del method +``` + +## Generic protocol variance + +A protocol's declared variance must match whether its members consume or produce that type variable. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +# error: [invalid-protocol] "Type variable `T` in protocol `InvariantSource` should be covariant, but is invariant" +class InvariantSource(Protocol[T]): + def read(self) -> T: ... + +# error: [invalid-protocol] "Type variable `T` in protocol `InvariantSink` should be contravariant, but is invariant" +class InvariantSink(Protocol[T]): + def write(self, value: T) -> None: ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantSink` should be contravariant, but is covariant" +class CovariantSink(Protocol[T_co]): + def write(self, value: T_co) -> None: ... + +# error: [invalid-protocol] "Type variable `T_contra` in protocol `ContravariantSource` should be covariant, but is contravariant" +class ContravariantSource(Protocol[T_contra]): + def read(self) -> T_contra: ... + +class CovariantSource(Protocol[T_co]): + def read(self) -> T_co: ... + +class ContravariantSink(Protocol[T_contra]): + def write(self, value: T_contra) -> None: ... + +class InvariantReadWrite(Protocol[T]): + def read(self) -> T: ... + def write(self, value: T) -> None: ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantReadWrite` should be invariant, but is covariant" +class CovariantReadWrite(Protocol[T_co]): + def read(self) -> T_co: ... + def write(self, value: T_co) -> None: ... + +# error: [invalid-protocol] "Type variable `T_contra` in protocol `ContravariantReadWrite` should be invariant, but is contravariant" +class ContravariantReadWrite(Protocol[T_contra]): + def read(self) -> T_contra: ... + def write(self, value: T_contra) -> None: ... +``` + +A type variable used in an invariant return type makes the protocol invariant, even though it only +appears in a return position. + +```py +class InvariantReturn(Protocol[T]): + def read(self) -> list[T]: ... +``` + +## Protocol properties and writable attributes + +Read-only properties are covariant. Writable properties and attributes are invariant, including +underscore-prefixed attributes and annotated special-method attributes. + +```py +from typing import Callable, Protocol, TypeVar + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + +class ReadOnlyProperty(Protocol[T_co]): + @property + def value(self) -> T_co: ... + +class WritableProperty(Protocol[T]): + @property + def value(self) -> T: ... + @value.setter + def value(self, value: T) -> None: ... + +class WritableAttribute(Protocol[T]): + _value: T + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantAttribute` should be invariant, but is covariant" +class CovariantAttribute(Protocol[T_co]): + _value: T_co + +class CallableAttribute(Protocol[T]): + __call__: Callable[..., T] + +class CallableMethod(Protocol[T_co]): + def __call__(self) -> T_co: ... +``` + +## Protocol attributes containing class types + +Although `type[T]` is covariant, a writable protocol attribute containing `type[T]` must make the +protocol invariant. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + +class WritableClassAttribute(Protocol[T]): + value: type[T] + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantClassAttribute` should be invariant, but is covariant" +class CovariantClassAttribute(Protocol[T_co]): + value: type[T_co] + +class InferredClassAttribute[T](Protocol): + value: type[T] + +class Wrapper[T]: + def value(self) -> InferredClassAttribute[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_assignable_to(Wrapper[int], Wrapper[object])) +``` + +## Descriptor-decorated protocol variance + +A descriptor with a known setter domain contributes its actual read and write types to protocol +variance. A descriptor that returns `T` but accepts any `object` for writes is covariant in `T`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Callable, Generic, Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + +class Descriptor(Generic[T_co]): + def __init__(self, getter: Callable[..., T_co]) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> T_co: + raise NotImplementedError + def __set__(self, instance: object, value: object) -> None: ... + +# error: [invalid-protocol] "Type variable `T` in protocol `InvariantDescriptor` should be covariant, but is invariant" +class InvariantDescriptor(Protocol[T]): + @Descriptor + def value(self) -> T: ... + +class CovariantDescriptor(Protocol[T_co]): + @Descriptor + def value(self) -> T_co: ... + +class InferredDescriptor[T](Protocol): + @Descriptor + def value(self) -> T: ... + +class Wrapper[T]: + def value(self) -> InferredDescriptor[T]: + raise NotImplementedError + +static_assert(is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(is_assignable_to(Wrapper[int], Wrapper[object])) +``` + +## Protocol constructors + +Constructors are not protocol members, so their parameters do not constrain protocol variance. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + +# error: [invalid-protocol] "Type variable `T` in protocol `ConstructorOnly` should be covariant, but is invariant" +class ConstructorOnly(Protocol[T]): + def __init__(self, value: T) -> None: ... +``` + +## Protocol method receivers + +Explicit receiver annotations do not add an input or output position to a bound method. Both +protocols consume their type parameter through `send`, so only the contravariant declaration is +valid. + +```py +from typing import Protocol, TypeVar + +T_contra = TypeVar("T_contra", contravariant=True) +T_co = TypeVar("T_co", covariant=True) + +class ExplicitReceivers(Protocol[T_contra]): + def send(self: "ExplicitReceivers[T_contra]", value: T_contra) -> None: ... + @classmethod + def configure(cls: "type[ExplicitReceivers[T_contra]]") -> None: ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantExplicitReceivers` should be contravariant, but is covariant" +class CovariantExplicitReceivers(Protocol[T_co]): + def send(self: "CovariantExplicitReceivers[T_co]", value: T_co) -> None: ... + @classmethod + def configure(cls: "type[CovariantExplicitReceivers[T_co]]") -> None: ... +``` + +## Inferred legacy protocol variance + +Inferred legacy type variables use the same structural interface as explicitly declared protocol +parameters. An underscore-prefixed protocol attribute remains writable and therefore invariant. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import ParamSpec, Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +P = ParamSpec("P") +R_co = TypeVar("R_co", covariant=True) +T = TypeVar("T", infer_variance=True) + +class Callback(Protocol[P, R_co]): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ... + +class WritableProtocol(Protocol[T]): + _value: T + +static_assert(not is_subtype_of(WritableProtocol[int], WritableProtocol[object])) +static_assert(not is_assignable_to(WritableProtocol[int], WritableProtocol[object])) +``` + +## Protocol members referencing other protocols + +An unrelated protocol in a member type does not prevent declared-variance validation. `Source` is +covariant because only `read` uses `T`, in a return position. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + +class Marker(Protocol): + def ready(self) -> bool: ... + +# error: [invalid-protocol] "Type variable `T` in protocol `Source` should be covariant, but is invariant" +class Source(Protocol[T]): + def read(self) -> T: ... + def marker(self) -> Marker: ... +``` + +## Nested protocol variance + +Variance composes through nonrecursive generic protocols. Returning a covariant protocol produces +its type parameter, while accepting it as an argument consumes that parameter. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Reader(Protocol[T_co]): + def read(self) -> T_co: ... + +class NestedReader(Protocol[T_co]): + def reader(self) -> Reader[T_co]: ... + +# error: [invalid-protocol] "Type variable `T` in protocol `Source` should be covariant, but is invariant" +class Source(Protocol[T]): + def reader(self) -> NestedReader[T]: ... + +class Sink(Protocol[T_contra]): + def write(self, reader: NestedReader[T_contra]) -> None: ... +``` + +## Unused parameters of independent protocols + +`Marker`'s unused type parameter is inferred as bivariant, which falls back to covariance. Accepting +`Marker[T]` therefore makes `Sink` contravariant in `T`, even though `Marker`'s members never use +that parameter. + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Marker(Protocol[T_co]): + def ready(self) -> bool: ... + +class Sink(Protocol[T_contra]): + def accept(self, value: Marker[T_contra]) -> None: ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantSink` should be contravariant, but is covariant" +class CovariantSink(Protocol[T_co]): + def accept(self, value: Marker[T_co]) -> None: ... +``` + +## Recursive protocol variance + +Recursive protocol references use the variance inferred from their interfaces, so an incorrect +declaration cannot justify itself. A protocol that only produces its type parameter remains +covariant when it returns another instance of itself; a protocol that only consumes the parameter +remains contravariant. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Source(Protocol[T_co]): + def read(self) -> T_co: ... + def next(self) -> "Source[T_co]": ... + +class Sink(Protocol[T_contra]): + def write(self, value: T_contra) -> None: ... + def next(self) -> "Sink[T_contra]": ... + +# error: [invalid-protocol] "Type variable `T` in protocol `InvariantSource` should be covariant, but is invariant" +class InvariantSource(Protocol[T]): + def read(self) -> T: ... + def next(self) -> "InvariantSource[T]": ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Recursive` should be contravariant, but is covariant" +class Recursive(Protocol[T_co]): + def write(self, value: T_co) -> None: ... + def next(self) -> "Recursive[T_co]": ... +``` + +An expanding recursive reference composes variance with its type arguments. `list[T_co]` makes +`Expanding` invariant even though its only direct use of `T_co` is a method parameter. + +```py +# error: [invalid-protocol] "Type variable `T_co` in protocol `Expanding` should be invariant, but is covariant" +class Expanding(Protocol[T_co]): + def write(self, value: T_co) -> None: ... + def next(self) -> "Expanding[list[T_co]]": ... +``` + +Passing the recursive protocol as an argument introduces the opposite variance as well. Together +with the direct return of `T_co`, this makes the protocol invariant. + +```py +# error: [invalid-protocol] "Type variable `T_co` in protocol `RecursiveArgument` should be invariant, but is covariant" +class RecursiveArgument(Protocol[T_co]): + def combine(self, other: "RecursiveArgument[T_co]") -> T_co: ... +``` + +The input position in `Left.write` also makes `Right` contravariant through its return type. Both +covariant declarations are rejected. + +```py +# error: [invalid-protocol] "Type variable `T_co` in protocol `Left` should be contravariant, but is covariant" +class Left(Protocol[T_co]): + def write(self, value: T_co) -> None: ... + def right(self) -> "Right[T_co]": ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Right` should be contravariant, but is covariant" +class Right(Protocol[T_co]): + def left(self) -> Left[T_co]: ... +``` + +## Recursive protocols with independent dependencies + +Mutually recursive protocols infer their variance together, but still honor the declared variance of +independent protocols. `Left` consumes the covariant `Marker[T]`, which also makes `Right` +contravariant through its return type. + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Marker(Protocol[T_co]): + def ready(self) -> bool: ... + +class Left(Protocol[T_contra]): + def accept(self, value: Marker[T_contra]) -> None: ... + def right(self) -> "Right[T_contra]": ... + +class Right(Protocol[T_contra]): + def left(self) -> Left[T_contra]: ... +``` + +## Recursive protocols without observable type parameters + +A parameter used only in recursive references has no observable input or output position. We accept +a covariant declaration, just as for an unused parameter, even when the recursive reference is a +method argument. Consumers still use that declared covariance when inferring their own variance. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +T_co = TypeVar("T_co", covariant=True) + +class Recursive(Protocol[T_co]): + def accept(self, other: "Recursive[T_co]") -> None: ... + +class Source[T]: + def read(self) -> Recursive[T]: + raise NotImplementedError + +static_assert(is_subtype_of(Source[int], Source[object])) +static_assert(not is_subtype_of(Source[object], Source[int])) + +class Sink[T]: + def write(self, value: Recursive[T]) -> None: ... + +static_assert(is_subtype_of(Sink[object], Sink[int])) +static_assert(not is_subtype_of(Sink[int], Sink[object])) +``` + +An independent protocol consumer also uses `Recursive`'s declared covariance. The recursive +references within `Recursive` do not make it mutually recursive with `ProtocolSink`. + +```py +T_contra = TypeVar("T_contra", contravariant=True) + +class ProtocolSink(Protocol[T_contra]): + def write(self, value: Recursive[T_contra]) -> None: ... +``` + +## Recursive protocol variance through aliases and nominal classes + +Variance validation follows mutually recursive references through a type alias and an inferred +nominal class. The list in the alias makes both protocols invariant, even though `Recursive` only +directly produces its type parameter. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) + +type Next[T] = Recursive[list[T]] + +class Wrapper[T]: + def value(self) -> Next[T]: + raise NotImplementedError + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Forward` should be invariant, but is covariant" +class Forward(Protocol[T_co]): + def value(self) -> Wrapper[T_co]: ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Recursive` should be invariant, but is covariant" +class Recursive(Protocol[T_co]): + def read(self) -> T_co: ... + def next(self) -> Forward[T_co]: ... +``` + +## Recursive protocol references with fixed arguments + +The definitions refer to each other, but `Right`'s variance does not depend on `Left`: its reference +to `Left[int]` does not use its type parameter. `Left` therefore uses `Right`'s declared covariance +and is contravariant in the parameter it consumes. + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +class Left(Protocol[T_contra]): + def accept(self, value: "Right[T_contra]") -> None: ... + +class Right(Protocol[T_co]): + def left(self) -> Left[int]: ... +``` + +## Recursive dependencies after invariant members + +A mutable list makes `Left` invariant. Its other method makes `Left` mutually recursive with +`Right`, so `Right` is also invariant. Finding an invariant member does not stop dependency +discovery in the remaining members. + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Left` should be invariant, but is covariant" +class Left(Protocol[T_co]): + def items(self) -> list[T_co]: ... + def next(self) -> "Right[T_co]": ... + +# error: [invalid-protocol] "Type variable `T_co` in protocol `Right` should be invariant, but is covariant" +class Right(Protocol[T_co]): + def left(self) -> Left[T_co]: ... +``` + +## Recursive protocol references in unused alias arguments + +An alias that ignores a type argument also removes its variance dependencies. `Ignore[Sink[T]]` is +just `int`, so `Marker` is independent of `Sink`. Consuming the covariant `Marker[T]` makes `Sink` +contravariant. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + +type Ignore[T] = int + +class Marker(Protocol[T_co]): + def marker(self) -> Ignore["Sink[T_co]"]: ... + +class Sink(Protocol[T_contra]): + def accept(self, value: Marker[T_contra]) -> None: ... +``` + +## Recursive protocols with unsupported member types + +Declared-variance validation still skips recursive type aliases. This also applies when the alias +appears in another protocol in a recursive cycle, so both covariant declarations below remain +undiagnosed even though `Left.write` consumes the type parameter. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +T_co = TypeVar("T_co", covariant=True) +type Nested = int | list[Nested] + +# TODO: Reject these covariant declarations once recursive type aliases are supported. +class Left(Protocol[T_co]): + def write(self, value: T_co) -> None: ... + def right(self) -> "Right[T_co]": ... + +class Right(Protocol[T_co]): + def left(self) -> Left[T_co]: ... + def nested(self) -> Nested: ... +``` + +## Declared variance of variadic protocol parameters + +Parameter specifications and type variable tuples retain their declared variance when used in a +protocol specialization. They do not participate in the validation of ordinary protocol type +variables. These invariant parameters also make the enclosing nominal classes invariant. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import ParamSpec, Protocol, TypeVar, TypeVarTuple, Unpack +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") +T = TypeVar("T") + +class Callback(Protocol[P]): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +class CallbackProtocol(Protocol[T]): + def callback(self) -> Callback[[T]]: ... + +class CallbackWrapper[T]: + def callback(self) -> Callback[[T]]: + raise NotImplementedError + +static_assert(not is_subtype_of(CallbackWrapper[int], CallbackWrapper[object])) +static_assert(not is_subtype_of(CallbackWrapper[object], CallbackWrapper[int])) +``` + +The same applies when a type variable is used as one element of a type variable tuple. + +```py +class TupleProtocol(Protocol[Unpack[Ts]]): + def values(self) -> tuple[Unpack[Ts]]: ... + +class TupleMemberProtocol(Protocol[T]): + def value(self) -> TupleProtocol[T]: ... + +class TupleWrapper[T]: + def value(self) -> TupleProtocol[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(TupleWrapper[int], TupleWrapper[object])) +static_assert(not is_subtype_of(TupleWrapper[object], TupleWrapper[int])) +``` + +## Inherited protocol variance + +A protocol's variance also depends on its inherited members. `Child` only produces `T` through +`Base.read`, so it should be covariant. Declared-variance validation currently skips protocols with +additional bases, leaving this mismatch undiagnosed. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + +class Base(Protocol[T_co]): + def read(self) -> T_co: ... + +# TODO: Reject the invariant declaration; the inherited interface is covariant. +class Child(Base[T], Protocol[T]): ... ``` ## Inheriting from generic classes with explicit variance diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md index 22e205f270..429307914a 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md @@ -128,8 +128,9 @@ def _(doubly_specialized: Tuple[int]): reveal_type(doubly_specialized) # revealed: Unknown T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) -class LegacyProto(Protocol[T]): +class LegacyProto(Protocol[T_co]): pass type LegacyProtoInt = LegacyProto[int] @@ -152,7 +153,7 @@ class LegacyDict(TypedDict[T]): # error: [unbound-type-variable] x: T -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `LegacyDict` cannot be specialized in a type expression" type LegacyDictInt = LegacyDict[int] # error: [not-subscriptable] "Cannot specialize non-generic type alias `LegacyDictInt`" diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md index 96af903d55..e6ac34aa35 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md @@ -136,6 +136,33 @@ reveal_type(generic_context(into_regular_callable(C))) reveal_type(into_regular_callable(C)(1)) ``` +## Generic `__iter__` methods with explicit receivers + +Binding `__iter__` to an `Unpacker[Iterable[int]]` infers `S` as `int` from the explicit +`self: Unpacker[Iterable[S]]` annotation. Calls to `list()` and `iter()` preserve this element type, +just as `tuple()` and `for` loops do. + +Regression test for . + +```py +from collections.abc import Iterable, Iterator + +class Unpacker[T: Iterable[object]]: + def __init__(self, it: T, /) -> None: + self._it = it + def __iter__[S](self: "Unpacker[Iterable[S]]") -> Iterator[S]: + return iter(self._it) + +def integers() -> Unpacker[Iterable[int]]: + return Unpacker([1, 2, 3]) + +reveal_type(tuple(integers())) # revealed: tuple[int, ...] +for x in integers(): + reveal_type(x) # revealed: int +reveal_type(list(integers())) # revealed: list[int] +reveal_type(iter(integers())) # revealed: Iterator[int] +``` + ## Naming a generic `Callable`: type aliases The easiest way to refer to a generic `Callable` type directly is via a type alias: @@ -666,6 +693,25 @@ def f(val: str | bytes) -> None: reveal_type(accepts_callable(f)) # revealed: str | bytes ``` +When overloads exchange their input and output types, the inferred return tuple currently contains a +union for each type variable. + +```py +def infer_pair[T, U](converter: Callable[[T], U]) -> tuple[T, U]: + raise NotImplementedError + +@overload +def swap(value: int) -> str: ... +@overload +def swap(value: str) -> int: ... +def swap(value: int | str) -> int | str: + raise NotImplementedError + +# TODO: Infer the intersection of `tuple[int, str]` and `tuple[str, int]`. +# Both specializations validate the same call, so its result satisfies both return types. +reveal_type(infer_pair(swap)) # revealed: tuple[int | str, str | int] +``` + When `T` is constrained to a union by other arguments, the overloaded callable must still be treated as a whole to satisfy `Callable[[T], T]`. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md index 29c73ba71e..f04da82a35 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md @@ -521,6 +521,39 @@ reveal_type(generic_context(into_regular_callable(D))) reveal_type(D(1)) # revealed: C[Never, int] ``` +### Explicit access to constructors of bare generic classes + +Bare `__new__` and `__init__` members retain the class type variables in their generic contexts. +Each call can infer an owner specialization, while an explicit class specialization remains +authoritative. + +```py +from typing import Self +from ty_extensions._internal import generic_context + +class C[T = int]: + def __new__(cls, value: T) -> Self: + return super().__new__(cls) + + def __init__(self, value: T) -> None: ... + +# revealed: ty_extensions._internal.GenericContext[Self@__new__, T@C] +reveal_type(generic_context(C.__new__)) +# revealed: ty_extensions._internal.GenericContext[Self@__init__, T@C] +reveal_type(generic_context(C.__init__)) + +reveal_type(C.__new__(C[str], "value")) # revealed: C[str] + +def calls(c_str: C[str], c_int: C[int]) -> None: + C.__init__(c_str, "value") + C.__init__(c_int, 1) + + C[int].__init__(c_int, 1) + + # error: [invalid-argument-type] + C[int].__init__(c_str, 1) +``` + ### Generic class inherits `__init__` from generic base class ```py @@ -730,6 +763,10 @@ propagate through: class Parent[T]: x: T + @staticmethod + def static(value: T) -> T: + return value + class Child[U](Parent[U]): ... class Grandchild[V](Child[V]): ... class Greatgrandchild[W](Child[W]): ... @@ -740,6 +777,82 @@ reveal_type(Grandchild[int]().x) # revealed: int reveal_type(Greatgrandchild[int]().x) # revealed: int ``` +Attributes and static methods inherited by an unspecialized generic subclass use its default type +arguments instead of exposing its class-scoped type variables. Class access to generic instance +attributes is invalid, but the recovery types still use those defaults. + +```py +# error: [invalid-attribute-access] +reveal_type(Parent.x) # revealed: Unknown +# error: [invalid-attribute-access] +reveal_type(Child.x) # revealed: Unknown +# error: [invalid-attribute-access] +reveal_type(Grandchild.x) # revealed: Unknown + +# revealed: def static(value: Unknown) -> Unknown +reveal_type(Child.static) +Child.static(1) +reveal_type(Child[int].static(1)) # revealed: int +``` + +Declared defaults must be preserved, and concrete arguments in partially specialized bases must not +be replaced with `Unknown`. + +```py +class DefaultChild[T = int](Parent[T]): ... + +class PairParent[T, U]: + fixed: T + unresolved: U + +class PartiallyFixed[T](PairParent[int, T]): ... + +# error: [invalid-attribute-access] +reveal_type(DefaultChild.x) # revealed: int +# error: [invalid-attribute-access] +reveal_type(DefaultChild[str].x) # revealed: str +reveal_type(PartiallyFixed.fixed) # revealed: int +# error: [invalid-attribute-access] +reveal_type(PartiallyFixed.unresolved) # revealed: Unknown +``` + +## Unbound inherited methods + +An inherited method can be called through the subclass, passing the instance explicitly. Without +type arguments, `Child.get` uses `Unknown` for `U`; it does not infer `U` from the instance. + +```py +class Parent[T]: + def get(self) -> T: + raise NotImplementedError + +class Child[U](Parent[U]): ... + +def _(child: Child[int]): + reveal_type(Child.get(child)) # revealed: Unknown +``` + +An explicit default also determines which instances the method accepts. `DefaultChild.get` uses +`int` for `U`, so it accepts a `DefaultChild[int]` but rejects a `DefaultChild[str]`. + +```py +class DefaultChild[U = int](Parent[U]): ... + +def _(int_child: DefaultChild[int], str_child: DefaultChild[str]): + reveal_type(DefaultChild.get(int_child)) # revealed: int + DefaultChild.get(str_child) # error: [invalid-argument-type] +``` + +Defaults also apply when a type parameter appears inside a base class's type argument. Only `U` +becomes `Unknown` below; the `int` in `tuple[int, U]` is unchanged. + +```py +class NestedChild[U](Parent[tuple[int, U]]): ... + +def _(child: NestedChild[str]): + reveal_type(NestedChild.get(child)) # revealed: tuple[int, Unknown] +``` + ## Generic methods Generic classes can contain methods that are themselves generic. The generic methods can refer to @@ -784,6 +897,126 @@ reveal_type(generic_context(c.method)) reveal_type(generic_context(c.generic_method)) ``` +A class TypeVar remains fixed when a method is called from an enclosing generic function. The call +cannot specialize that enclosing occurrence merely because it also appears in synthetic `Self`'s +upper bound. + +```py +class Container[T]: + def replace(self, value: T) -> T: + return value + +def preserve[T](container: Container[T], value: T) -> T: + return container.replace(value) + +def cannot_choose_outer[T](container: Container[T]) -> T: + # error: [invalid-argument-type] + return container.replace(1) +``` + +## Generic instance attributes accessed through classes + +Class access cannot select a specialization of an instance attribute. This restriction applies to +native type parameters just as it does to legacy `TypeVar` declarations. + +```py +class Box[T]: + value: T + +# error: [invalid-attribute-access] +Box[int].value = 1 +# error: [invalid-attribute-access] +Box[int].value +# error: [invalid-attribute-access] +Box.value = 1 +# error: [invalid-attribute-access] +Box.value + +box = Box[int]() +box.value = 1 +reveal_type(box.value) # revealed: Literal[1] +``` + +## Generic attributes accessed through subclass methods + +The `cls` receiver in a classmethod, `__new__`, or `__init_subclass__` can refer to a concrete +subclass. We allow these methods to access generic attributes through their receiver. + +```py +from typing import Self + +class Box[T]: + value: T + + @classmethod + def get(cls) -> T: + return cls.value + + def __new__(cls) -> Self: + cls.value + return super().__new__(cls) + + def __init_subclass__(cls, *, value: T) -> None: + cls.value = value + reveal_type(cls.value) # revealed: T@Box + +class Concrete(Box[int], value=1): ... + +reveal_type(Concrete.get()) # revealed: int +``` + +## Generic attributes using type aliases + +An alias can hide a dependency on a class type parameter, including inside a recursive alias. An +unused alias argument does not make the attribute depend on that type parameter. + +```py +type Identity[T] = T +type Discard[T] = int +type Recursive[T] = T | list[Recursive[list[T]]] +type FixedRecursive = int | list[FixedRecursive] + +class Box[T]: + value: Identity[T] + recursive: Recursive[T] + constant: Discard[T] + fixed_recursive: FixedRecursive + +# error: [invalid-attribute-access] +Box[int].value +# error: [invalid-attribute-access] +Box[int].recursive +reveal_type(Box.constant) # revealed: int +Box.fixed_recursive +``` + +Aliases can also contain a union of descriptor and non-descriptor types. Only the non-descriptor +alternatives are subject to the restriction on generic instance attributes. + +```py +class Descriptor[T]: + def __get__(self, instance: object, owner: type) -> int: + return 0 + +type DescriptorOrList[T] = Descriptor[T] | list[T] +type Nested[T] = DescriptorOrList[T] | str +type DescriptorOrInt[T] = Descriptor[T] | int + +class Aliased[T]: + value: DescriptorOrList[T] + nested: Nested[T] + constant: DescriptorOrInt[T] + +# error: [invalid-attribute-access] +Aliased[int].value = [1] +# error: [invalid-attribute-access] +reveal_type(Aliased[str].value) # revealed: int | list[str] +# error: [invalid-attribute-access] +Aliased[int].nested +reveal_type(Aliased[str].constant) # revealed: int +Aliased[int].constant = 1 +``` + ## Specializations propagate In a specialized generic alias, the specialization is applied to the attributes and methods of the @@ -1007,7 +1240,7 @@ recovers to `Unknown`. ```py class NonGeneric: ... -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `NonGeneric` cannot be specialized in a type expression" def direct(value: NonGeneric[int]) -> None: reveal_type(value) # revealed: Unknown ``` @@ -1015,7 +1248,7 @@ def direct(value: NonGeneric[int]) -> None: The same diagnostic applies when the specialization is nested inside `type[...]`. ```py -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `NonGeneric` cannot be specialized in a type expression" def nested(value: type[NonGeneric[int]]) -> None: reveal_type(value) # revealed: Unknown ``` @@ -1028,15 +1261,91 @@ class Child(NonGeneric): ... class Generic[T, U = str]: ... class SpecializedChild(Generic[int]): ... -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `Child` cannot be specialized in a type expression" def child(value: Child[str]) -> None: reveal_type(value) # revealed: Unknown -# error: [not-subscriptable] "Cannot subscript non-generic type ``" +# error: [invalid-type-form] "Non-generic class `SpecializedChild` cannot be specialized in a type expression" def specialized_child(value: SpecializedChild[bytes]) -> None: reveal_type(value) # revealed: Unknown ``` +## Custom class subscriptions in type expressions + +Defining `__class_getitem__` makes a class subscriptable at runtime, but does not make it generic. +Its return type is used for value expressions, not for interpreting type expressions. + +```py +class U: + def __class_getitem__(cls, value: int) -> "type[U]": + return U + +reveal_type(U[0]) # revealed: type[U] +reveal_type(U.__class_getitem__(0)) # revealed: type[U] + +# snapshot: invalid-type-form +def direct(value: U[0]) -> None: + reveal_type(value) # revealed: Unknown + +# error: [invalid-type-form] "Non-generic class `U` cannot be specialized in a type expression" +def nested(value: type[U[0]]) -> None: + reveal_type(value) # revealed: Unknown +``` + +```snapshot +error[invalid-type-form]: Non-generic class `U` cannot be specialized in a type expression + --> src/mdtest_snippet.py:9:19 + | +9 | def direct(value: U[0]) -> None: + | ^^^^ +info: See the following page for a reference on valid type expressions: +info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions +``` + +## Custom class subscriptions with future annotations + +Postponing annotation evaluation does not make a non-generic class a valid generic type. The error +concerns the type expression, not runtime subscription. + +```py +from __future__ import annotations + +class U: + def __class_getitem__(cls, value: int) -> type[U]: + return U + +# error: [invalid-type-form] +def direct(value: U[0]) -> None: + reveal_type(value) # revealed: Unknown + +# error: [invalid-type-form] +def nested(value: type[U[0]]) -> None: + reveal_type(value) # revealed: Unknown +``` + +## Custom class subscriptions with Python 3.14 annotations + +The same type-expression error applies when Python defers annotation evaluation by default. + +```toml +[environment] +python-version = "3.14" +``` + +```py +class U: + def __class_getitem__(cls, value: int) -> type[U]: + return U + +# error: [invalid-type-form] +def direct(value: U[0]) -> None: + reveal_type(value) # revealed: Unknown + +# error: [invalid-type-form] +def nested(value: type[U[0]]) -> None: + reveal_type(value) # revealed: Unknown +``` + ## Tuple as a PEP-695 generic class Our special handling for `tuple` does not break if `tuple` is defined as a PEP-695 generic class in diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md index 1a68e8d03d..e9aaf620cf 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md @@ -38,7 +38,7 @@ def _(c: Callable[Concatenate[int, str, ...], bool]): reveal_type(c) # revealed: (int, str, /, *args: Any, **kwargs: Any) -> bool # revealed: (int, str, /, *args: Any, **kwargs: Any) -> None -reveal_type(Foo[Concatenate[int, str, ...]].attr) +reveal_type(Foo[Concatenate[int, str, ...]]().attr) ``` ### Complex types inside `Concatenate` @@ -53,7 +53,7 @@ def _(c: Callable[Concatenate[int | str, list[int], type[str], ...], None]): reveal_type(c) # revealed: (int | str, list[int], type[str], /, *args: Any, **kwargs: Any) -> None # revealed: (int | str, list[int], type[str], /, *args: Any, **kwargs: Any) -> None -reveal_type(Foo[Concatenate[int | str, list[int], type[str], ...]].attr) +reveal_type(Foo[Concatenate[int | str, list[int], type[str], ...]]().attr) ``` ### Nested @@ -68,7 +68,7 @@ def _(c: Callable[Concatenate[int, Callable[Concatenate[str, ...], None], ...], reveal_type(c) # revealed: (int, (str, /, *args: Any, **kwargs: Any) -> None, /, *args: Any, **kwargs: Any) -> None # revealed: (int, (str, /, *args: Any, **kwargs: Any) -> None, /, *args: Any, **kwargs: Any) -> None -reveal_type(Foo[Concatenate[int, Callable[Concatenate[str, ...], None], ...]].attr) +reveal_type(Foo[Concatenate[int, Callable[Concatenate[str, ...], None], ...]]().attr) ``` ### Both `*args` and `**kwargs` are required @@ -271,24 +271,24 @@ def _( reveal_type(c) # revealed: (...) -> int # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 0)" -reveal_type(Foo[Concatenate[()]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[()]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1)" -reveal_type(Foo[Concatenate[int]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1)" -reveal_type(Foo[Concatenate[(int,)]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[(int,)]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" -reveal_type(Foo[Concatenate].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[[Concatenate]].attr) # revealed: (Unknown, /) -> None +reveal_type(Foo[[Concatenate]]().attr) # revealed: (Unknown, /) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[[Concatenate, int]].attr) # revealed: (Unknown, int, /) -> None +reveal_type(Foo[[Concatenate, int]]().attr) # revealed: (Unknown, int, /) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[[Concatenate[int], str]].attr) # revealed: (Unknown, str, /) -> None +reveal_type(Foo[[Concatenate[int], str]]().attr) # revealed: (Unknown, str, /) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[[Concatenate[int, str], str]].attr) # revealed: (Unknown, str, /) -> None +reveal_type(Foo[[Concatenate[int, str], str]]().attr) # revealed: (Unknown, str, /) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[[Concatenate[()], str]].attr) # revealed: (Unknown, str, /) -> None +reveal_type(Foo[[Concatenate[()], str]]().attr) # revealed: (Unknown, str, /) -> None # Subscripting a class that does not have "exactly one paramspec" takes a different code path; # these tests exercise that code path @@ -298,10 +298,10 @@ class Bar[**P1, **P2]: # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" -reveal_type(Bar[Concatenate, Concatenate].a) # revealed: (...) -> int +reveal_type(Bar[Concatenate, Concatenate]().a) # revealed: (...) -> int # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Bar[[Concatenate], [Concatenate]].a) # revealed: (Unknown, /) -> int +reveal_type(Bar[[Concatenate], [Concatenate]]().a) # revealed: (Unknown, /) -> int ``` ### Last argument must be `ParamSpec` or `...` @@ -320,19 +320,19 @@ class Foo[**P]: def _(c: Callable[Concatenate[int, str], bool]): ... # error: [invalid-type-arguments] "The last argument to `typing.Concatenate` must be either `...` or a `ParamSpec` type variable: Got `str`" -reveal_type(Foo[Concatenate[int, str]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int, str]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[Concatenate[int, Concatenate]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int, Concatenate]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[Concatenate[int, Concatenate[()]]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int, Concatenate[()]]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[Concatenate[int, Concatenate[int]]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int, Concatenate[int]]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -reveal_type(Foo[Concatenate[int, Concatenate[int, str]]].attr) # revealed: (...) -> None +reveal_type(Foo[Concatenate[int, Concatenate[int, str]]]().attr) # revealed: (...) -> None ``` ### `ParamSpec` must be last @@ -351,7 +351,7 @@ def invalid1[**P2](c: Callable[Concatenate[P2, int], bool]): reveal_type(c) # revealed: (...) -> bool # error: [invalid-type-form] "Bare ParamSpec `P2` is not valid in this context" # error: [invalid-type-arguments] "The last argument to `typing.Concatenate` must be either `...` or a `ParamSpec` type variable: Got `int`" - reveal_type(Foo[Concatenate[P2, int]].attr) # revealed: (...) -> None + reveal_type(Foo[Concatenate[P2, int]]().attr) # revealed: (...) -> None # error: [invalid-type-form] "Bare ParamSpec `P2` is not valid in this context" def invalid2[**P2](c: Callable[Concatenate[P2, ...], bool]): @@ -361,13 +361,13 @@ def invalid2[**P2](c: Callable[Concatenate[P2, ...], bool]): # error: [invalid-type-form] "Bare ParamSpec `P2` is not valid in this context" # revealed: (Unknown, /, *args: Any, **kwargs: Any) -> None - reveal_type(Foo[Concatenate[P2, ...]].attr) + reveal_type(Foo[Concatenate[P2, ...]]().attr) def valid[**P2](c: Callable[Concatenate[int, P2], bool]): reveal_type(c) # revealed: (int, /, *args: P2@valid.args, **kwargs: P2@valid.kwargs) -> bool # revealed: (int, /, *args: P2@valid.args, **kwargs: P2@valid.kwargs) -> None - reveal_type(Foo[Concatenate[int, P2]].attr) + reveal_type(Foo[Concatenate[int, P2]]().attr) type Alias[**P1] = int @@ -570,6 +570,24 @@ def unpack_variadic(*args: *tuple[int, *tuple[str, ...]], **kwargs: int) -> None reveal_type(unpack_variadic) # revealed: (*args: str, **kwargs: int) -> None ``` +### Function with a named prefix and required unpacked suffix + +`Concatenate` can remove a named positional prefix without discarding the required suffix of an +unpacked variadic parameter. + +```py +from typing import Callable, Concatenate + +def remove_first[**P](callback: Callable[Concatenate[int, P], None]) -> Callable[P, None]: + raise NotImplementedError + +def named_prefix_and_suffix(name: int, *args: *tuple[*tuple[int, ...], int]) -> None: ... + +# TODO: Preserve the unpacked tuple instead of exposing synthetic comparison parameters. +# Should reveal `(*args: *tuple[*tuple[int, ...], int]) -> None`. +reveal_type(remove_first(named_prefix_and_suffix)) # revealed: (*args: int, int, /) -> None +``` + ## `Concatenate` with `ParamSpec` in generic function calls ### Basic call with inferred `ParamSpec` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md index 5c781b7b89..71caa612d5 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md @@ -82,6 +82,44 @@ reveal_type(f(True)) # revealed: Literal[True] reveal_type(f("string")) # revealed: Literal["string"] ``` +An inferred specialization preserves a PEP 695 type alias when it is the only inferred lower bound. +This keeps diagnostics expressed in terms of the alias instead of expanding it to the underlying +type. + +```py +type Scalar = int + +def takes_str(value: str) -> None: + pass + +def check_alias(value: Scalar) -> None: + # error: [invalid-argument-type] "Argument to function `takes_str` is incorrect: Expected `str`, found `Scalar`" + takes_str(f(value)) +``` + +A PEP 695 type alias is also preserved when relating one generic function to a generic callback. +This lets us infer the callback's return type from the members of the alias. + +```py +from collections.abc import Callable + +type Items = tuple[int] | tuple[str] + +def identity[T](value: T) -> T: + return value + +def extract[T](callback: Callable[[Items], tuple[T]]) -> T: + raise NotImplementedError + +result = extract(identity) + +# revealed: str | int +reveal_type(result) + +# error: [unresolved-attribute] "Object of type `str | int` has no attribute `nonexistent`" +result.nonexistent() +``` + ## Inferring “deep” generic parameter types The matching up of call arguments and discovery of constraints on typevars can be a recursive @@ -93,7 +131,7 @@ argument _explicitly_ implements the protocol by listing it as a base class. ```py from typing import Protocol, TypeVar -S = TypeVar("S") +S = TypeVar("S", covariant=True) class CanIndex(Protocol[S]): def __getitem__(self, index: int, /) -> S: ... @@ -186,6 +224,477 @@ def _(a: A, b: B, x: A | B): reveal_type(takes_in_supports_foo(x)) # revealed: A | B ``` +## Inferring through nested nominal generic classes + +When a nominal generic class is nested inside another, the outer class determines whether the inner +specialization contributes a lower bound, an upper bound, or both. + +```py +class Covariant[T]: + def get(self) -> T: + raise NotImplementedError + +class Contravariant[T]: + def put(self, value: T) -> None: ... + +class Invariant[T]: + value: T + +class Producer[T]: + def get(self) -> T: + raise NotImplementedError + +def covariant[T](container: Covariant[Producer[T]], value: T) -> T: + return value + +def contravariant[T](container: Contravariant[Producer[T]], value: T) -> T: + return value + +def invariant[T](container: Invariant[Producer[T]], value: T) -> T: + return value +``` + +Covariance permits the broader `Base`, contravariance accepts the narrower `Derived`, and invariance +preserves `Middle`. + +```py +class Base: ... +class Middle(Base): ... +class Derived(Middle): ... + +reveal_type(covariant(Covariant[Producer[Middle]](), Base())) # revealed: Base +reveal_type(contravariant(Contravariant[Producer[Middle]](), Derived())) # revealed: Derived +reveal_type(invariant(Invariant[Producer[Middle]](), Middle())) # revealed: Middle +``` + +## Inferring through nested generic protocols + +Generic protocols must preserve the variance of the outer class when constructing their structural +constraints, just as nominal generic classes do. + +```py +from typing import Protocol + +class Covariant[T]: + def get(self) -> T: + raise NotImplementedError + +class Contravariant[T]: + def put(self, value: T) -> None: ... + +class Invariant[T]: + value: T + +class Producer[T](Protocol): + def get(self) -> T: ... + +def covariant[T](container: Covariant[Producer[T]], value: T) -> T: + return value + +def contravariant[T](container: Contravariant[Producer[T]], value: T) -> T: + return value + +def invariant[T](container: Invariant[Producer[T]], value: T) -> T: + return value +``` + +A covariant outer class permits the broader `Base`, a contravariant class accepts the narrower +`Derived`, and an invariant class requires exactly `Middle`. + +```py +class Base: ... +class Middle(Base): ... +class Derived(Middle): ... + +reveal_type(covariant(Covariant[Producer[Middle]](), Base())) # revealed: Base +reveal_type(contravariant(Contravariant[Producer[Middle]](), Derived())) # revealed: Derived +reveal_type(invariant(Invariant[Producer[Middle]](), Middle())) # revealed: Middle +invariant(Invariant[Producer[Middle]](), Base()) # error: [invalid-argument-type] +``` + +When the same protocol specialization appears first contravariantly and then covariantly, both +relationships contribute their constraints even though the formal and actual types are identical. + +```py +class MixedVariance[First, Second]: + def put(self, value: First) -> None: ... + def get(self) -> Second: + raise NotImplementedError + +def repeated_polarity[T](container: MixedVariance[Producer[T], Producer[T]], witness: T) -> T: + return witness + +reveal_type(repeated_polarity(MixedVariance[Producer[Middle], Producer[Middle]](), Derived())) # revealed: Middle +``` + +A consuming protocol reverses the relationship once more. Nesting that protocol inside a +contravariant class therefore turns its upper bound back into a lower bound. + +```py +class Consumer[T](Protocol): + def put(self, value: T) -> None: ... + +def consumer[T](container: Covariant[Consumer[T]], value: T) -> T: + return value + +def double_contravariant[T](container: Contravariant[Consumer[T]], value: T) -> T: + return value + +reveal_type(consumer(Covariant[Consumer[Middle]](), Derived())) # revealed: Derived +reveal_type(double_contravariant(Contravariant[Consumer[Middle]](), Derived())) # revealed: Middle +``` + +A structural protocol method must also preserve its inferred parameters under either outer variance, +even when its nominal implementation is rejected by the wrapper. + +```py +from typing import Callable + +class Runner[**P](Protocol): + def run(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +class StringRunner: + def run(self, value: str) -> None: ... + +def invariant_runner[**P](container: Invariant[Runner[P]]) -> Callable[P, None]: + raise NotImplementedError + +def contravariant_runner[**P](container: Contravariant[Runner[P]]) -> Callable[P, None]: + raise NotImplementedError + +invariant_run = invariant_runner(Invariant[StringRunner]()) # error: [invalid-argument-type] +reveal_type(invariant_run) # revealed: (value: str) -> None +invariant_run(1) # error: [invalid-argument-type] + +contravariant_run = contravariant_runner(Contravariant[StringRunner]()) # error: [invalid-argument-type] +reveal_type(contravariant_run) # revealed: (value: str) -> None +contravariant_run(1) # error: [invalid-argument-type] +``` + +## Inferring through nested generic callables + +Callable return types are covariant, but nesting a callable inside a contravariant or invariant +generic class changes which constraints its return type supplies. + +```py +from typing import Callable, overload + +class Covariant[T]: + def __init__(self, *values: T) -> None: ... + def get(self) -> T: + raise NotImplementedError + +class Contravariant[T]: + def __init__(self, *values: T) -> None: ... + def put(self, value: T) -> None: ... + +class Invariant[T]: + def __init__(self, *values: T) -> None: ... + value: T + +def covariant[T](container: Covariant[Callable[[], T]], value: T) -> T: + return value + +def contravariant[T](container: Contravariant[Callable[[], T]], value: T) -> T: + return value + +def invariant[T](container: Invariant[Callable[[], T]], value: T) -> T: + return value + +class Base: ... +class Middle(Base): ... +class Derived(Middle): ... + +reveal_type(covariant(Covariant[Callable[[], Middle]](), Base())) # revealed: Base +reveal_type(contravariant(Contravariant[Callable[[], Middle]](), Derived())) # revealed: Derived +reveal_type(invariant(Invariant[Callable[[], Middle]](), Middle())) # revealed: Middle +invariant(Invariant[Callable[[], Middle]](), Base()) # error: [invalid-argument-type] +``` + +The same callable specialization can contribute both an upper and a lower bound when it appears +first in a contravariant position and then in a covariant position. + +```py +class MixedVariance[First, Second]: + def put(self, value: First) -> None: ... + def get(self) -> Second: + raise NotImplementedError + +def repeated_polarity[T](container: MixedVariance[Callable[[], T], Callable[[], T]], witness: T) -> T: + return witness + +reveal_type(repeated_polarity(MixedVariance[Callable[[], Middle], Callable[[], Middle]](), Derived())) # revealed: Middle +``` + +An unrelated variadic type parameter currently sends the entire inference context through the legacy +solver, so the ordinary callable loses the contravariant bound shown above. + +```py +def with_paramspec[T, **P](container: Contravariant[Callable[[], T]], value: T, unrelated: Callable[P, None]) -> T: + return value + +def with_typevartuple[T, *Ts](container: Contravariant[Callable[[], T]], value: T, unrelated: tuple[*Ts]) -> T: + return value + +def unrelated(value: str) -> None: ... + +# TODO: Should reveal `Derived` when an unrelated ParamSpec no longer disables contravariance. +reveal_type(with_paramspec(Contravariant[Callable[[], Middle]](), Derived(), unrelated)) # revealed: Middle +# TODO: Should reveal `Derived` when an unrelated TypeVarTuple no longer disables contravariance. +reveal_type(with_typevartuple(Contravariant[Callable[[], Middle]](), Derived(), ("value",))) # revealed: Middle +``` + +A union of callable return types offers alternative upper bounds; a compatible arm must not be +rejected just because another arm is incompatible. + +```py +reveal_type(contravariant(Contravariant[Callable[[], Middle] | Callable[[], str]](), Derived())) # revealed: Derived +``` + +Covariance accepts an overloaded callable when one overload matches. Contravariance and invariance +additionally require the formal callable to cover every overload, so an extra `str` overload is +incompatible with a callable accepting only `int`. + +```py +@overload +def overloaded(value: int, /) -> Middle: ... +@overload +def overloaded(value: str, /) -> Middle: ... +def overloaded(value: int | str, /) -> Middle: + raise NotImplementedError + +def covariant_overload[T](container: Covariant[Callable[[int], T]]) -> T: + raise NotImplementedError + +def contravariant_overload[T](container: Contravariant[Callable[[int], T]]) -> T: + raise NotImplementedError + +def invariant_overload[T](container: Invariant[Callable[[int], T]]) -> T: + raise NotImplementedError + +reveal_type(covariant_overload(Covariant(overloaded))) # revealed: Middle +contravariant_overload(Contravariant(overloaded)) # error: [invalid-argument-type] +invariant_overload(Invariant(overloaded)) # error: [invalid-argument-type] +``` + +When every overload is covered by the formal `int` parameter, all three wrapper variances accept it. + +```py +@overload +def covered(value: bool, /) -> Middle: ... +@overload +def covered(value: int, /) -> Middle: ... +def covered(value: int, /) -> Middle: + raise NotImplementedError + +reveal_type(covariant_overload(Covariant(covered))) # revealed: Middle +reveal_type(contravariant_overload(Contravariant(covered))) # revealed: Middle +reveal_type(invariant_overload(Invariant(covered))) # revealed: Middle +``` + +Callable parameter types are already contravariant. An outer contravariant class reverses their +relationship a second time, producing the same lower bound as a covariant callable return type. + +```py +def consumer[T](container: Covariant[Callable[[T], None]], value: T) -> T: + return value + +def double_contravariant[T](container: Contravariant[Callable[[T], None]], value: T) -> T: + return value + +reveal_type(consumer(Covariant[Callable[[Middle], None]](), Derived())) # revealed: Derived +reveal_type(double_contravariant(Contravariant[Callable[[Middle], None]](), Derived())) # revealed: Middle +``` + +A `Concatenate` prefix is positional-only, so these wrapped callbacks do not match the formal +parameter exactly. Their remaining parameters and ordinary return type variable must still be +inferred precisely under either outer polarity. + +```py +from typing import Concatenate + +class InvariantCallback[T]: + def __init__(self, callback: T) -> None: ... + callback: T + +class ContravariantCallback[T]: + def __init__(self, callback: T) -> None: ... + def put(self, callback: T) -> None: ... + +def invariant_tail[**P, R]( + container: InvariantCallback[Callable[Concatenate[object, P], R]], +) -> Callable[P, R]: + raise NotImplementedError + +def contravariant_tail[**P, R]( + container: ContravariantCallback[Callable[Concatenate[object, P], R]], +) -> Callable[P, R]: + raise NotImplementedError + +def original(first: object, value: str) -> int: + return len(value) + +invariant_callback = InvariantCallback(original) +invariant_remaining = invariant_tail(invariant_callback) # error: [invalid-argument-type] +reveal_type(invariant_remaining) # revealed: (value: str) -> int +invariant_remaining(1) # error: [invalid-argument-type] +invariant_remaining("valid").missing_attribute # error: [unresolved-attribute] + +contravariant_callback = ContravariantCallback(original) +contravariant_remaining = contravariant_tail(contravariant_callback) # error: [invalid-argument-type] +reveal_type(contravariant_remaining) # revealed: (value: str) -> int +contravariant_remaining(1) # error: [invalid-argument-type] +contravariant_remaining("valid").missing_attribute # error: [unresolved-attribute] +``` + +A higher-order callback must retain its inferred parameter list under both outer variances, even +when assigning the result causes the outer argument to be rejected. Its callback parameter can +accept either the declared prefix or a narrower derived prefix. + +```py +def accepts_exact(callback: Callable[[Base, str], None]) -> None: ... +def accepts_narrower(callback: Callable[[Derived, str], None]) -> None: ... +def invariant_higher_order[**P]( + container: InvariantCallback[Callable[[Callable[Concatenate[Base, P], None]], None]], +) -> Callable[P, None]: + raise NotImplementedError + +def contravariant_higher_order[**P]( + container: ContravariantCallback[Callable[[Callable[Concatenate[Base, P], None]], None]], +) -> Callable[P, None]: + raise NotImplementedError + +invariant_exact_callback = InvariantCallback(accepts_exact) +invariant_exact = invariant_higher_order(invariant_exact_callback) # error: [invalid-argument-type] +reveal_type(invariant_exact) # revealed: (str, /) -> None +invariant_exact(1) # error: [invalid-argument-type] + +invariant_narrower_callback = InvariantCallback(accepts_narrower) +invariant_narrower = invariant_higher_order(invariant_narrower_callback) # error: [invalid-argument-type] +reveal_type(invariant_narrower) # revealed: (str, /) -> None + +contravariant_exact_callback = ContravariantCallback(accepts_exact) +contravariant_exact = contravariant_higher_order(contravariant_exact_callback) # error: [invalid-argument-type] +reveal_type(contravariant_exact) # revealed: (str, /) -> None +contravariant_exact(1) # error: [invalid-argument-type] + +contravariant_narrower_callback = ContravariantCallback(accepts_narrower) +contravariant_narrower = contravariant_higher_order(contravariant_narrower_callback) # error: [invalid-argument-type] +reveal_type(contravariant_narrower) # revealed: (str, /) -> None +``` + +When constructed inline, the wrappers infer the positional-only prefix based on the outer type +context: + +```py +reveal_type(invariant_tail(InvariantCallback(original))) # revealed: (value: str) -> int +reveal_type(contravariant_tail(ContravariantCallback(original))) # revealed: (value: str) -> int + +reveal_type(invariant_higher_order(InvariantCallback(accepts_exact))) # revealed: (str, /) -> None +reveal_type(invariant_higher_order(InvariantCallback(accepts_narrower))) # revealed: (str, /) -> None +reveal_type(contravariant_higher_order(ContravariantCallback(accepts_exact))) # revealed: (str, /) -> None +reveal_type(contravariant_higher_order(ContravariantCallback(accepts_narrower))) # revealed: (str, /) -> None +``` + +## Inferring through nested callable protocols + +A callable assigned to a callback protocol contributes the same return-type constraints through its +signature, including when an outer generic class reverses their direction. + +```py +from typing import Callable, Protocol + +class Covariant[T]: + def get(self) -> T: + raise NotImplementedError + +class Contravariant[T]: + def put(self, value: T) -> None: ... + +class Callback[T](Protocol): + def __call__(self) -> T: ... + +def covariant[T](container: Covariant[Callback[T]], value: T) -> T: + return value + +def contravariant[T](container: Contravariant[Callback[T]], value: T) -> T: + return value + +class Base: ... +class Middle(Base): ... +class Derived(Middle): ... + +reveal_type(covariant(Covariant[Callable[[], Middle]](), Base())) # revealed: Base +reveal_type(contravariant(Contravariant[Callable[[], Middle]](), Derived())) # revealed: Derived +``` + +A callback protocol with a positional-only prefix must likewise preserve its inferred parameter +tail, even when the wrapped callable is rejected. + +```py +class InvariantCallback[T]: + def __init__(self, callback: T) -> None: ... + callback: T + +class ContravariantCallback[T]: + def __init__(self, callback: T) -> None: ... + def put(self, callback: T) -> None: ... + +class VariadicCallback[**P](Protocol): + def __call__(self, first: object, /, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def invariant_tail[**P](container: InvariantCallback[VariadicCallback[P]]) -> Callable[P, None]: + raise NotImplementedError + +def contravariant_tail[**P](container: ContravariantCallback[VariadicCallback[P]]) -> Callable[P, None]: + raise NotImplementedError + +def original(first: object, value: str) -> None: ... + +invariant_callback = InvariantCallback(original) +invariant_remaining = invariant_tail(invariant_callback) # error: [invalid-argument-type] +reveal_type(invariant_remaining) # revealed: (value: str) -> None +invariant_remaining(1) # error: [invalid-argument-type] + +contravariant_callback = ContravariantCallback(original) +contravariant_remaining = contravariant_tail(contravariant_callback) # error: [invalid-argument-type] +reveal_type(contravariant_remaining) # revealed: (value: str) -> None +contravariant_remaining(1) # error: [invalid-argument-type] +``` + +When constructed inline, the wrappers infer the positional-only prefix based on the outer type +context: + +```py +reveal_type(invariant_tail(InvariantCallback(original))) # revealed: (value: str) -> None +reveal_type(contravariant_tail(ContravariantCallback(original))) # revealed: (value: str) -> None +``` + +A nominal callable object's `__call__` method must likewise preserve the callback protocol's +inferred parameters under both wrapper variances. + +```py +class CallableObject: + def __call__(self, first: object, value: str) -> None: ... + +invariant_callback = InvariantCallback(CallableObject()) +invariant_object = invariant_tail(invariant_callback) # error: [invalid-argument-type] +reveal_type(invariant_object) # revealed: (value: str) -> None +invariant_object(1) # error: [invalid-argument-type] + +contravariant_callback = ContravariantCallback(CallableObject()) +contravariant_object = contravariant_tail(contravariant_callback) # error: [invalid-argument-type] +reveal_type(contravariant_object) # revealed: (value: str) -> None +contravariant_object(1) # error: [invalid-argument-type] +``` + +Similarly, constructing the wrappers inline lets them use the `VariadicCallback` type from context: + +```py +reveal_type(invariant_tail(InvariantCallback(CallableObject()))) # revealed: (value: str) -> None +reveal_type(contravariant_tail(ContravariantCallback(CallableObject()))) # revealed: (value: str) -> None +``` + ## Bound violations inferred through protocols If matching a protocol argument infers a type that violates a type variable's bound, the call should @@ -251,6 +760,47 @@ reveal_type(takes_homogeneous_tuple((42,))) # revealed: Literal[42] reveal_type(takes_homogeneous_tuple((42, 43))) # revealed: Literal[42, 43] ``` +## Inferring tuple parameter types from unions + +Every member of a union argument contributes to the inferred element type of a homogeneous tuple +parameter. Different tuple lengths do not prevent inference, and an empty tuple contributes no +element types. + +```py +class A: ... +class B: ... +class C: ... +class D: ... + +def elements[T](values: tuple[T, ...]) -> tuple[T, ...]: + return values + +def _( + same: tuple[A, A] | tuple[A, A, A], + mixed: tuple[A] | tuple[B, B], + possibly_empty: tuple[()] | tuple[A, A], +): + reveal_type(elements(same)) # revealed: tuple[A, ...] + reveal_type(elements(mixed)) # revealed: tuple[A | B, ...] + reveal_type(elements(possibly_empty)) # revealed: tuple[A, ...] +``` + +Fixed-length and mixed tuples infer type parameters from their corresponding element positions. + +```py +def swap[T, U](values: tuple[U, T]) -> tuple[T, U]: + return values[1], values[0] + +def _(pairs: tuple[A, B] | tuple[C, D]): + reveal_type(swap(pairs)) # revealed: tuple[B | D, A | C] + +def tail[T](values: tuple[A, *tuple[T, ...]]) -> tuple[T, ...]: + return values[1:] + +def _(tails: tuple[A, B] | tuple[A, C, C]): + reveal_type(tail(tails)) # revealed: tuple[B | C, ...] +``` + ## Inferring a bound typevar ```py @@ -742,8 +1292,8 @@ reveal_type(invoke(lift_invariant, 1)) ## Passing unbound generic methods to generic functions -An unbound method of a generic class can be passed to a generic higher-order function. The class -type parameter must still be inferred from the concrete receiver expected by that function. +An unbound method accessed through a bare generic class uses the class's default specialization. The +higher-order function can still infer its own type parameter from its other arguments. ```py from __future__ import annotations @@ -754,6 +1304,9 @@ class Box[T]: def merge(self, other: Box[T]) -> Box[T]: return self +reveal_type(Box.merge) # revealed: def merge(self, other: Box[Unknown]) -> Box[Unknown] +reveal_type(Box[str].merge) # revealed: def merge(self, other: Box[str]) -> Box[str] + def fold[T](function: Callable[[T, T], T], values: list[T]) -> T: return values[0] @@ -761,7 +1314,8 @@ def merge_boxes(values: list[Box[str]]) -> Box[str]: return fold(Box.merge, values) ``` -The same applies to the standard-library `set.union` method passed to `functools.reduce`. +The same applies to the standard-library `set.union` method passed to `functools.reduce`: `reduce` +infers its result from the iterable rather than reopening `set`'s default specialization. ```py from functools import reduce @@ -841,6 +1395,7 @@ def opaque_decorator(f: Any) -> Any: def transparent_decorator[F: Callable[..., Any]](f: F) -> F: return f +# error: [dynamic-function-decorator-return] @opaque_decorator def decorated[T](t: T) -> None: # error: [redundant-cast] @@ -913,6 +1468,277 @@ def g[T: A](b: B[T]): return f(b.x) # Fine ``` +## Inferred upper bounds restrict the range of gradual solutions + +Gradual lower bounds are intersected with their inferred upper bounds. + +```py +from collections.abc import Iterable +from typing import Any, Callable, TypeAlias +from ty_extensions._internal import Unknown + +def infer[T](lower: T, upper: Callable[[T], None]) -> T: + return lower + +def _(any_value: Any, unknown_value: Unknown, upper: Callable[[int], None]): + reveal_type(infer(any_value, upper)) # revealed: int & Any + reveal_type(infer(unknown_value, upper)) # revealed: int & Unknown +``` + +All inferred upper bounds contribute to the intersection, whether they are static or gradual: + +```py +def infer_multiple[T]( + value: T, + first: Callable[[T], None], + second: Callable[[T], None], +) -> T: + return value + +def _( + any_value: Any, + unknown_value: Unknown, + static: Callable[[int], None], + first: Callable[[int | list[Any]], None], + second: Callable[[int | dict[str, Any]], None], +): + reveal_type(infer_multiple(any_value, static, first)) # revealed: int & Any + reveal_type(infer_multiple(any_value, first, second)) # revealed: int & Any + reveal_type(infer_multiple(unknown_value, first, second)) # revealed: int & Unknown +``` + +An unsatisfiable gradual range falls back to unioning the inferred bounds for diagnostic recovery: + +```py +def _( + unknown_value: Unknown, + static: Callable[[int], None], + incompatible: Callable[[list[Any]], None], +): + result = infer_multiple( + unknown_value, + static, # error: [invalid-argument-type] + incompatible, # error: [invalid-argument-type] + ) + reveal_type(result) # revealed: Unknown | int | list[Any] +``` + +A gradual upper bound contributes its top materialization without replacing the gradual lower bound: + +```py +def _( + any_value: Any, + unknown_value: Unknown, + list_upper: Callable[[list[Any]], None], + tuple_upper: Callable[[tuple[Any, ...]], None], + callable_upper: Callable[[Callable[[Any], int]], None], +): + reveal_type(infer(any_value, list_upper)) # revealed: Top[list[Any]] & Any + reveal_type(infer(unknown_value, list_upper)) # revealed: Top[list[Any]] & Unknown + reveal_type(infer(any_value, tuple_upper)) # revealed: tuple[object, ...] & Any + reveal_type(infer(any_value, callable_upper)) # revealed: ((Never, /) -> int) & Any +``` + +The inferred upper bound is also retained when an invariant return type triggers promotion: + +```py +def infer_list[T](lower: T, upper: Callable[[T], None]) -> list[T]: + return [lower] + +def _(any_value: Any, upper: Callable[[int], None]): + reveal_type(infer_list(any_value, upper)) # revealed: list[int & Any] +``` + +Promotion must also preserve the upper bound when a gradual solution contains promotable literals: + +```py +def infer_promoted[T](static: T, gradual: T, upper: Callable[[T], None]) -> list[T]: + return [static, gradual] + +def _(any_value: Any, unknown_value: Unknown, upper: Callable[[int | str], None]): + reveal_type(infer_promoted(1, any_value, upper)) # revealed: list[int | (str & Any)] + reveal_type(infer_promoted(1, unknown_value, upper)) # revealed: list[int | (str & Unknown)] +``` + +The same restriction applies when a type variable occurs in a callable's parameter and return types: + +```py +class Base: ... +class Derived(Base): ... + +def predicate(value: Derived) -> bool: + return True + +def gradual_rule(value: Derived) -> Unknown: + raise NotImplementedError + +def condition[T](predicate: Callable[[T], bool], rule: Callable[[T], T]) -> Callable[[T], T]: + raise NotImplementedError + +reveal_type(condition(predicate, gradual_rule)) # revealed: (Derived & Unknown, /) -> Derived & Unknown +``` + +If the upper bound is a union, it is distributed across the gradual lower bound: + +```py +class A: ... +class B: ... +class Result(A): ... + +def reduce[T](function: Callable[[T, T], T], values: Iterable[T]) -> T: + raise NotImplementedError + +def combine(left: A | B, right: A | B) -> Result: + raise NotImplementedError + +def _(values: Iterable[Any]): + # revealed: Result | (A & Any) | (B & Any) + reveal_type(reduce(combine, values)) +``` + +Declared upper bounds validate a gradual solution but do not restrict its range on their own: + +```py +def bounded[T: A | B](value: T) -> T: + return value + +def bounded_with_upper[T: A | B](value: T, upper: Callable[[T], None]) -> T: + return value + +def _(any_value: Any, upper: Callable[[object], None]): + reveal_type(bounded(any_value)) # revealed: Any + reveal_type(bounded_with_upper(any_value, upper)) # revealed: Any +``` + +An inferred upper bound cannot introduce materializations outside the declared upper bound: + +```py +def bounded_range[T: int | str](value: T, upper: Callable[[T], None]) -> T: + return value + +def _(any_value: Any, unknown_value: Unknown, upper: Callable[[int | bytes], None]): + reveal_type(bounded_range(any_value, upper)) # revealed: int & Any + reveal_type(bounded_range(unknown_value, upper)) # revealed: int & Unknown + +def _(any_value: Any, upper: Callable[[bytes], None]): + reveal_type(bounded_range(any_value, upper)) # revealed: Any +``` + +Declared gradual bounds preserve the gradual type inferred from the lower bound: + +```py +def bounded_any[T: Any](value: T, upper: Callable[[T], None]) -> T: + return value + +def bounded_gradual[T: list[Any]](value: T, upper: Callable[[T], None]) -> T: + return value + +def _( + unknown_value: Unknown, + int_upper: Callable[[int], None], + list_upper: Callable[[list[int]], None], +): + reveal_type(bounded_any(unknown_value, int_upper)) # revealed: int & Unknown + reveal_type(bounded_gradual(unknown_value, list_upper)) # revealed: list[int] & Unknown +``` + +Recursive declared bounds do not introduce `Divergent` into a concrete solution: + +```py +Recursive: TypeAlias = int | list["Recursive"] + +def bounded_recursive[T: Recursive](value: T, upper: Callable[[T], None]) -> T: + return value + +def _(any_value: Any, unknown_value: Unknown, upper: Callable[[list[int]], None]): + any_result = bounded_recursive(any_value, upper) + unknown_result = bounded_recursive(unknown_value, upper) + + reveal_type(any_result) # revealed: list[int] & Any + reveal_type(any_result[0]) # revealed: int & Any + reveal_type(unknown_result) # revealed: list[int] & Unknown + reveal_type(unknown_result[0]) # revealed: int & Unknown +``` + +The same restriction applies when `Unknown` is inferred from a lambda call: + +```py +identity = lambda value: value + +def _(value: Unknown, upper: Callable[[int], None]): + reveal_type(infer(identity(value), upper)) # revealed: int & Unknown +``` + +## Redundant upper bounds preserve large gradual unions + +The invariant list fixes `T` to the entire union, while the callback adds the redundant upper bound +`object`. Restricting the inferred gradual type by these bounds must preserve all five union +members. + +```py +from collections.abc import Callable, Mapping, Sequence +from typing import Any + +Bound = None | int | set[int] | Sequence[Any] | Mapping[str, Any] + +def first[T: Bound](values: list[T], sink: Callable[[T], None]) -> T: + return values[0] + +def _(values: list[Bound], sink: Callable[[object], None]) -> None: + # revealed: None | int | set[int] | Sequence[Any] | Mapping[str, Any] + reveal_type(first(values, sink)) +``` + +The same holds for recursive aliases, whose recursive positions currently fall back to `Divergent`. +This is a reduced regression test for [ty#4335](https://github.com/astral-sh/ty/issues/4335). + +```py +Recursive = None | int | set[int] | Sequence["Recursive"] | Mapping[str, "Recursive"] + +def first_recursive[T: Recursive](values: list[T], sink: Callable[[T], None]) -> T: + return values[0] + +def _(values: list[Recursive], sink: Callable[[object], None]) -> None: + # revealed: None | int | set[int] | Sequence[Divergent] | Mapping[str, Divergent] + reveal_type(first_recursive(values, sink)) +``` + +## Inferring from multiple intersection arguments + +Each argument below satisfies `Source[T]` in two ways. Combining independent alternatives must +remain bounded, and the merged inference result retains evidence from all four arguments. Reordering +the arguments does not change that result. + +```py +from typing import assert_type +from ty_extensions import Intersection + +class Source[T]: + def get(self) -> T: + raise NotImplementedError + +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... +class F: ... +class G: ... +class H: ... + +def first[T](a: Source[T], b: Source[T], c: Source[T], d: Source[T]) -> T: + return a.get() + +def _( + a: Intersection[Source[A], Source[B]], + b: Intersection[Source[C], Source[D]], + c: Intersection[Source[E], Source[F]], + d: Intersection[Source[G], Source[H]], +) -> None: + assert_type(first(a, b, c, d), A | B | C | D | E | F | G | H) + assert_type(first(d, c, b, a), A | B | C | D | E | F | G | H) +``` + ## Typevars in a union ```py @@ -953,6 +1779,91 @@ def _(x: list[int], y: dict[int, int]): reveal_type(h(y)) # revealed: int | None ``` +A bounded type variable should still be enforced when it appears in multiple union members and the +argument is itself a union. This currently exposes : + +```py +class Box[T]: ... + +def unbox[T: bytes](value: Box[T] | T) -> T: + raise NotImplementedError + +def invalid_union(value: int | str) -> None: + # TODO: This should report [invalid-argument-type]: neither `int` nor `str` satisfies `T: bytes`. + reveal_type(unbox(value)) # revealed: Unknown +``` + +The same missing constraint lets an incompatible generic overload win over a matching overload: + +```py +from typing import assert_type, overload + +@overload +def select[T: bytes](value: Box[T] | T) -> T: ... +@overload +def select(value: int | str) -> bool: ... +def select(value: object) -> object: + raise NotImplementedError + +def selects_invalid_overload(value: int | str) -> None: + # TODO: This should select the second overload and infer `bool`. + # error: [type-assertion-failure] "Type `Unknown` does not match asserted type `bool`" + assert_type(select(value), bool) +``` + +## Gradual bounds in generic union members + +A gradual bound does not prevent inference from an invariant union member: `str` satisfies `Any`, +and `list[str]` satisfies `list[Any]`. + +```py +from typing import Any + +class Other: ... + +def infer_any_bound[T: Any](value: list[T] | Other) -> T: + raise NotImplementedError + +def infer_list_bound[T: list[Any]](value: list[T] | Other) -> T: + raise NotImplementedError + +reveal_type(infer_any_bound(list[str]())) # revealed: str +reveal_type(infer_list_bound(list[list[str]]())) # revealed: list[str] +``` + +## Invalid bounds in generic union members + +An argument that violates a type variable's bound is rejected even when another union member is not +disjoint from the argument. `list[object]` and `Other` can have a common subclass, but +`list[object]` is not assignable to `Other`, and `object` does not satisfy the bound of `T`. + +```py +class Other: ... + +def accept[T: str](value: list[T] | Other) -> None: + pass + +accept([]) +accept(["valid"]) +accept(Other()) + +accept([object()]) # error: [invalid-argument-type] "does not satisfy upper bound `str`" +accept([1]) # error: [invalid-argument-type] "does not satisfy upper bound `str`" +``` + +## Disjoint generic union members + +The `list[T]` member cannot match a string or `None`. Inference through the remaining `T` member +rejects `None`, which satisfies neither of its constraints. + +```py +def accept[T: (str, bytes)](value: T | list[T]) -> None: + pass + +def _(value: str | None): + accept(value) # error: [invalid-argument-type] "does not satisfy constraints" +``` + ## Bounded typevar call context through a union Regression test for an `invalid-assignment` false positive: `list(items)` should be assignable to @@ -1056,6 +1967,27 @@ def _(x: int): reveal_type(C().implicit_self(x)) # revealed: tuple[C, int] ``` +## Generic method errors account for the implicit receiver + +An implicit `self` participates in generic inference but is absent from the call-site argument list. +Bound violations must still identify the correct positional or keyword argument. + +```py +class Box: + def accept[T: int](self, value: T, *, other: T) -> T: + return value + +box = Box() + +reveal_type(box.accept(1, other=2)) # revealed: Literal[1, 2] + +# error: 12 [invalid-argument-type] "does not satisfy upper bound `int`" +box.accept("invalid", other=1) + +# error: 15 [invalid-argument-type] "does not satisfy upper bound `int`" +box.accept(1, other="invalid") +``` + ## `~T` is never assignable to `T` ```py @@ -1126,6 +2058,27 @@ reveal_type(invoke(accepts_int)) # revealed: int reveal_type(invoke(needs_str)) # revealed: int ``` +### Inferring uninhabited keyword types + +Inferring a keyword type as `Never` can eliminate a collision with an occupied positional parameter. + +The callback's return type must still contribute its own inference constraint. + +```py +from typing import Protocol + +class Callback[T, R](Protocol): + def __call__(self, x: int, /, *args: *tuple[*tuple[int, ...], int], **kwargs: T) -> R: ... + +def infer[T, R](callback: Callback[T, R]) -> tuple[list[T], R]: + raise NotImplementedError + +def source(a: int, *args: *tuple[*tuple[int, ...], int], **kwargs: int) -> str: + return "" + +reveal_type(infer(source)) # revealed: tuple[list[Never], str] +``` + ### Class constructors We can recurse into the parameters and return values of `Callable` parameters to infer diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index 0dee7b22b2..b5d6e0012c 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -308,7 +308,7 @@ def func[**P2](c: Callable[P2, None]): P2 = ParamSpec("P2") -# error: [invalid-type-arguments] "ParamSpec `P2` is unbound" +# error: [unbound-type-variable] "Type variable `P2` is not bound to any outer generic context" reveal_type(OnlyParamSpec[P2]().attr) # revealed: (...) -> None # error: [invalid-type-arguments] "No type argument provided for required type variable `P1` of class `OnlyParamSpec`" @@ -368,7 +368,7 @@ reveal_type(TypeVarAndParamSpec[int, [str]]().attr) # revealed: (str, /) -> int reveal_type(TypeVarAndParamSpec[int, ...]().attr) # revealed: (...) -> int reveal_type(ParamSpecAndTypeVar[[int, str], str]().attr) # revealed: (int, str, /) -> str -# error: [invalid-type-arguments] "ParamSpec `P2` is unbound" +# error: [unbound-type-variable] "Type variable `P2` is not bound to any outer generic context" reveal_type(TypeVarAndParamSpec[int, P2]().attr) # revealed: (...) -> int # error: [invalid-type-arguments] "Type argument for `ParamSpec` must be" reveal_type(TypeVarAndParamSpec[int, int]().attr) # revealed: (...) -> int @@ -453,6 +453,25 @@ takes_int_job(defaulted_job) takes_int_job(wrong_job) # error: [invalid-argument-type] ``` +A fixed `ParamSpec` can contain required parameters. A wrapper around such a callback cannot be used +as a wrapper around a callback that accepts no arguments. + +```py +def erase_parameters[**P](job: Job[P]) -> Job[[]]: + return job # error: [invalid-return-type] +``` + +The same restriction applies in the other direction when a class consumes callbacks. A consumer of +callbacks with no parameters cannot accept a callback with arbitrary required parameters. + +```py +class CallbackConsumer[**P]: + def consume(self, callback: Callable[P, None]) -> None: ... + +def broaden_parameters[**P](consumer: CallbackConsumer[[]]) -> CallbackConsumer[P]: + return consumer # error: [invalid-return-type] +``` + ## `ParamSpec` cannot specialize a `TypeVar`, and vice versa @@ -610,6 +629,96 @@ f3(1) f3("a", "b") ``` +### Prefer the declared parameter list + +We prefer the declared parameter list of a `ParamSpec` when it is compatible with the callback's +inferred parameter list: + +```py +from typing import Callable + +class Callback[**P]: + def __init__(self, callback: Callable[P, None]) -> None: ... + +def accepts_object(value: object, /) -> None: ... + +x1 = Callback(accepts_object) +reveal_type(x1) # revealed: Callback[(value: object, /)] + +x2: Callback[[int]] = Callback(accepts_object) +reveal_type(x2) # revealed: Callback[(int, /)] +``` + +If the parameter lists are incompatible, we ignore the declared type in the invalid assignment +diagnostic: + +```py +def no_args() -> None: ... + +# error: [invalid-assignment] "Object of type `Callback[()]` is not assignable to `Callback[(int, /)]`" +x3: Callback[[int]] = Callback(no_args) +reveal_type(x3) # revealed: Callback[(int, /)] +``` + +When no argument constrains the `ParamSpec`, the declared type supplies its parameter list: + +```py +def make[**P]() -> Callback[P]: + raise NotImplementedError + +reveal_type(make()) # revealed: Callback[(...)] + +x4: Callback[[int, str]] = make() +reveal_type(x4) # revealed: Callback[(int, str, /)] +``` + +### Preserve callback parameters in nested calls + +The outer call checks forwarded arguments against the inferred parameter list of the wrapped +callback: + +```py +from typing import Callable + +def wrap[**P](callback: Callable[P, None]) -> Callable[P, None]: + return callback + +def accept[**P](callback: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... +def no_args() -> None: ... + +reveal_type(wrap(no_args)) # revealed: () -> None + +accept(wrap(no_args)) # ok +accept(wrap(no_args), 1) # error: [too-many-positional-arguments] +``` + +Keyword-only parameters are also preserved: + +```py +def keyword_only(*, value: int) -> None: ... + +accept(wrap(keyword_only), value=1) +accept(wrap(keyword_only), value="incorrect") # error: [invalid-argument-type] +``` + +### Preserve an unpacked required suffix + +A `ParamSpec` preserves a named positional prefix and the required suffix of an unpacked variadic +parameter when inferring a callback signature. + +```py +from typing import Callable + +def preserve[**P](callback: Callable[P, None]) -> Callable[P, None]: + return callback + +def named_prefix_and_suffix(name: int, *args: *tuple[*tuple[int, ...], int]) -> None: ... + +# TODO: Preserve the unpacked tuple instead of exposing synthetic comparison parameters. +# Should reveal `(name: int, *args: *tuple[*tuple[int, ...], int]) -> None`. +reveal_type(preserve(named_prefix_and_suffix)) # revealed: (name: int, *args: int, int, /) -> None +``` + ### Return type change using the same `ParamSpec` multiple times ```py @@ -851,6 +960,37 @@ to_thread_like( ) ``` +This also applies when the parameter type is a bare type variable: + +```py +from ty_extensions._internal import Unknown + +class Payload(TypedDict): + x: int + +def forward[**P](function: Callable[P, None], /, *args: P.args, **kwargs: P.kwargs) -> None: + function(*args, **kwargs) + +def pair[T](first: T, second: T) -> None: ... +def _(payload: Payload): + forward(pair, reveal_type({"x": 1}), payload) # revealed: Payload + forward(pair, payload, reveal_type({"x": 1})) # revealed: Payload + +def triple[T](first: T, second: T, third: T) -> None: ... +def _(payload: Payload, unknown: Unknown): + # TODO: This should reveal `Payload`. + forward(triple, reveal_type({"x": 1}), payload, unknown) # revealed: dict[str, int] +``` + +We use a type-variable default as type context when the forwarded arguments do not otherwise +constrain it: + +```py +def default[T = Callable[[int], int]](callback: T) -> None: ... + +forward(default, lambda x: reveal_type(x)) # revealed: int +``` + ### Specializing `ParamSpec` with another `ParamSpec` ```py @@ -891,6 +1031,107 @@ def with_final[**P](foo: FooWithFinal[P]) -> None: reveal_type(foo.kwargs) # revealed: P@with_final.kwargs ``` +### `ParamSpec` inference from unions + +A `ParamSpec` inferred from a union of protocols can have more than one parameter list. Calling a +specialized method requires arguments to be accepted by every member of that union: + +```py +from typing import Protocol + +class Callback[**P](Protocol): + def call(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def identity[**P](callback: Callback[P]) -> Callback[P]: + return callback + +def _(callback: Callback[[object, int]] | Callback[[str, object]]) -> None: + f = identity(callback) + # revealed: (bound method Callback[((object, int, /)) | ((str, object, /))].call(object, int, /)) | (bound method Callback[((object, int, /)) | ((str, object, /))].call(str, object, /)) + reveal_type(f.call) + + f.call("value", 1) + f.call(1, 1) # error: [invalid-argument-type] + f.call("value", "value") # error: [invalid-argument-type] +``` + +This also applies when returning a `Callable` type: + +```py +from typing import Callable + +def as_callable[**P](callback: Callback[P]) -> Callable[P, None]: + return callback.call + +def _(callback: Callback[[object, int]] | Callback[[str, object]]) -> None: + f = as_callable(callback) + reveal_type(f) # revealed: ((object, int, /) -> None) | ((str, object, /) -> None) + + f("value", 1) + f(1, 1) # error: [invalid-argument-type] + f("value", "value") # error: [invalid-argument-type] +``` + +A union inferred for `P` is preserved in return position as well: + +```py +type Inner[**P, R] = Callable[P, R] + +def nested[**P, R](callback: Callback[P], value: R) -> Callable[P, Inner[P, R]]: + raise NotImplementedError + +def _(callback: Callback[[object, int]] | Callback[[str, object]], value: int) -> None: + outer = nested(callback, value) + # revealed: ((object, int, /) -> Inner[(object, int, /), int]) | ((str, object, /) -> Inner[(str, object, /), int]) + reveal_type(outer) + + inner = outer("value", 1) + reveal_type(inner) # revealed: ((object, int, /) -> int) | ((str, object, /) -> int) + + inner("value", 1) + inner(1, 1) # error: [invalid-argument-type] + inner("value", "value") # error: [invalid-argument-type] +``` + +### Bounded expansion of union-valued `ParamSpec`s + +Specializing an overloaded method with several union-valued `ParamSpec`s leads to exponential +blowup, so we bound the expansion to 64 callable types, otherwise falling back to `Unknown`. + +```py +from typing import Literal, Protocol, overload + +class Callback[**P](Protocol): + def call(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +class Combined[**P, **Q, **R](Protocol): + @overload + def call(self) -> int: ... + @overload + def call(self, tag: Literal[0], /, *args: P.args, **kwargs: P.kwargs) -> None: ... + @overload + def call(self, tag: Literal[1], /, *args: Q.args, **kwargs: Q.kwargs) -> None: ... + @overload + def call(self, tag: Literal[2], /, *args: R.args, **kwargs: R.kwargs) -> None: ... + @overload + def call(self, tag: Literal[3], /, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def combine[**P, **Q, **R](p: Callback[P], q: Callback[Q], r: Callback[R]) -> Combined[P, Q, R]: + raise NotImplementedError + +type FourCallbacks = Callback[[int]] | Callback[[str]] | Callback[[bytes]] | Callback[[None]] + +def _(x: FourCallbacks) -> None: + # The cartesian product produces a union of 64 elements. + f = combine(x, x, x).call + reveal_type(f()) # revealed: int + +def _(x: FourCallbacks, y: FourCallbacks | Callback[[list[int]]]) -> None: + # The cartesian product would have produced a union of 80 elements. + f = combine(x, x, y).call + reveal_type(f) # revealed: Unknown +``` + ### Specializing `Self` when `ParamSpec` is involved ```py @@ -905,6 +1146,212 @@ reveal_type(foo.method) # revealed: bound method Foo[(int, str, /)].method(int, reveal_type(foo.method(1, "a")) # revealed: str ``` +### Specializing explicit instance receivers with `ParamSpec` + +Specializing a `ParamSpec` preserves inference from an explicit receiver annotation. The writable +`value` attribute makes `Box` invariant in `T`, so binding `get` to a `Box[int, [str]]` instance +fixes `U` to `int` before the method is called. + +```py +class Box[T, **P]: + value: T + + def get[U](self: "Box[U, P]", *args: P.args, **kwargs: P.kwargs) -> U: + return self.value + +def check(box: Box[int, [str]]) -> None: + reveal_type(box.get) # revealed: bound method Box[int, (str, /)].get(str, /) -> int +``` + +### Specializing explicit class method receivers with `ParamSpec` + +A class method's explicit `cls` annotation also determines a method-scoped type variable when the +method is bound. Specializing `Factory` with a concrete parameter list preserves that binding, so +`make` returns the specialized `Factory` type. + +```py +class Factory[**P]: + @classmethod + def make[T](cls: type[T], *args: P.args, **kwargs: P.kwargs) -> T: + return cls() + +# revealed: bound method .make(int, /) -> Factory[(int, /)] +reveal_type(Factory[[int]].make) +``` + +### `ParamSpec` inferred from classmethod receivers + +The class object bound to `cls` determines the constructor parameters represented by `P`. The bound +method accepts those parameters, preserving their names, kinds, and defaults. + +```py +from typing import Callable + +class Factory: + def __init__(self, value: int, *, label: str = "") -> None: ... + @classmethod + def make[**P](cls: Callable[P, "Factory"], *args: P.args, **kwargs: P.kwargs) -> "Factory": + return cls(*args, **kwargs) + +# revealed: bound method .make(value: int, *, label: str = "") -> Factory +reveal_type(Factory.make) +reveal_type(Factory.make(1)) # revealed: Factory +Factory.make(value=1, label="label") + +make: Callable[[int], Factory] = Factory.make +``` + +Calls and callback assignments are checked against the constructor signature. In particular, the +required parameter cannot be omitted, and the optional keyword-only parameter cannot be positional. + +```py +Factory.make() # error: [missing-argument] "No argument provided for required parameter `value`" +Factory.make("wrong") # error: [invalid-argument-type] "Expected `int`" +Factory.make(1, label=2) # error: [invalid-argument-type] "Expected `str`" +Factory.make(1, "label") # error: [too-many-positional-arguments] +Factory.make(1, unexpected=True) # error: [unknown-argument] + +wrong_factory: Callable[[str], Factory] = Factory.make # error: [invalid-assignment] +``` + +### `ParamSpec` inferred from callable instance receivers + +An instance method can infer `P` from the bound instance's `__call__` signature. If the callable is +generic, its type parameters remain available for inference from the forwarded arguments. + +```py +from typing import Callable + +class Callback: + def __call__[T](self, value: T) -> T: + return value + + def call[**P, R](self: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: + return self(*args, **kwargs) + +callback = Callback() + +# revealed: bound method Callback.call[T](value: T) -> T +reveal_type(callback.call) +reveal_type(callback.call(value=1)) # revealed: Literal[1] +reveal_type(callback.call("value")) # revealed: Literal["value"] +callback.call() # error: [missing-argument] "No argument provided for required parameter `value`" +callback.call(1, 2) # error: [too-many-positional-arguments] +``` + +### Overloaded methods with generic receivers + +The mutable callback attribute makes `Wrapper` invariant in `P`, so its receiver determines each +overload's `Q` exactly. The prepended flag determines the return type. + +```py +from typing import Callable, Literal, overload + +class Wrapper[**P]: + def __init__(self, callback: Callable[P, int]) -> None: + self.callback = callback + + @overload + def call[**Q](self: "Wrapper[Q]", as_str: Literal[False], /, *args: Q.args, **kwargs: Q.kwargs) -> int: ... + @overload + def call[**Q](self: "Wrapper[Q]", as_str: Literal[True], /, *args: Q.args, **kwargs: Q.kwargs) -> str: ... + def call(self, as_str: bool, /, *args: P.args, **kwargs: P.kwargs) -> int | str: + result = self.callback(*args, **kwargs) + return str(result) if as_str else result + +def callback(value: int) -> int: + return value + +wrapper = Wrapper(callback) + +# revealed: Overload[(as_str: Literal[False], /, value: int) -> int, (as_str: Literal[True], /, value: int) -> str] +reveal_type(wrapper.call) +reveal_type(wrapper.call(False, 1)) # revealed: int +reveal_type(wrapper.call(True, value=1)) # revealed: str +wrapper.call(False) # error: [no-matching-overload] +wrapper.call(True, "wrong") # error: [no-matching-overload] +``` + +When the callback is overloaded, each method overload expands into multiple signatures. A failed +call lists each method overload declaration only once. + +```py +@overload +def overloaded_callback(value: int) -> int: ... +@overload +def overloaded_callback(*, label: str) -> int: ... +def overloaded_callback(value: int = 0, *, label: str = "") -> int: + return value + +overloaded_wrapper = Wrapper(overloaded_callback) +overloaded_wrapper.call(False, b"wrong") # snapshot: no-matching-overload +``` + +```snapshot +error[no-matching-overload]: No overload of bound method `Wrapper.call` matches arguments + --> src/mdtest_snippet.py:34:1 + | +34 | overloaded_wrapper.call(False, b"wrong") # snapshot: no-matching-overload + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +info: First overload defined here + --> src/mdtest_snippet.py:7:5 + | +7 | / @overload +8 | | def call[**Q](self: "Wrapper[Q]", as_str: Literal[False], /, *args: Q.args, **kwargs: Q.kwargs) -> int: ... + | |_______________________________________________________________________________________________________________^ First overload defined here +info: Possible overloads for bound method `call`: +info: [**Q](self: Wrapper[Q], as_str: Literal[False], /, *args: Q.args, **kwargs: Q.kwargs) -> int +info: [**Q](self: Wrapper[Q], as_str: Literal[True], /, *args: Q.args, **kwargs: Q.kwargs) -> str +info: Overload implementation defined here + --> src/mdtest_snippet.py:11:9 + | +11 | def call(self, as_str: bool, /, *args: P.args, **kwargs: P.kwargs) -> int | str: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +``` + +### Classmethod receivers with overloaded constructors + +A classmethod forwards each constructor overload independently. A call supplies either an integer +value or a keyword-only string label, not both. + +```py +from typing import Callable, overload + +class Factory: + @overload + def __init__(self, value: int) -> None: ... + @overload + def __init__(self, *, label: str) -> None: ... + def __init__(self, value: int = 0, *, label: str = "") -> None: ... + @classmethod + def make[**P](cls: Callable[P, "Factory"], *args: P.args, **kwargs: P.kwargs) -> "Factory": + return cls(*args, **kwargs) + +# revealed: Overload[(value: int) -> Factory, (*, label: str) -> Factory] +reveal_type(Factory.make) +reveal_type(Factory.make(1)) # revealed: Factory +reveal_type(Factory.make(label="label")) # revealed: Factory + +Factory.make() # snapshot: no-matching-overload +Factory.make(1, label="label") # error: [no-matching-overload] +``` + +```snapshot +error[no-matching-overload]: No overload of bound method `Factory.make` matches arguments + --> src/mdtest_snippet.py:18:1 + | +18 | Factory.make() # snapshot: no-matching-overload + | ^^^^^^^^^^^^^^ +info: Possible overloads for bound method `make`: +info: (value: int) -> Factory +info: (*, label: str) -> Factory +info: Overload implementation defined here + --> src/mdtest_snippet.py:10:9 + | +10 | def make[**P](cls: Callable[P, "Factory"], *args: P.args, **kwargs: P.kwargs) -> "Factory": + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +``` + ### Gradual types propagate through `ParamSpec` inference ```py @@ -1494,6 +1941,89 @@ reveal_type(c.generic_method(100)) # revealed: Literal[100] reveal_type(c.generic_method([1, 2, 3])) # revealed: list[int] ``` +### Callables inferred against gradual return types + +A decorator accepting `Callable[P, Any]` preserves any type variables scoped to the callable, +instead of eagerly specializing them to `Any`: + +```py +from collections.abc import Callable +from typing import Any, overload + +class Wrapper[**P]: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Any: + raise NotImplementedError + +def decorate[**P](callback: Callable[P, Any]) -> Wrapper[P]: + raise NotImplementedError + +@decorate +def identity[T](value: T) -> T: + return value + +reveal_type(identity) # revealed: Wrapper[(value: T@identity)] +reveal_type(identity(1)) # revealed: Any +``` + +This also applies to type variables from an enclosing scope: + +```py +def _[T](callback: Callable[[T], T], value: T) -> None: + f = decorate(callback) + reveal_type(f) # revealed: Wrapper[(T@_, /)] + reveal_type(f(value)) # revealed: Any +``` + +The same applies when the return type is an alias for `Any`: + +```py +type Anything = Any + +def decorate_alias[**P](callback: Callable[P, Anything]) -> Wrapper[P]: + raise NotImplementedError + +def _[T](callback: Callable[[T], T]) -> None: + reveal_type(decorate_alias(callback)) # revealed: Wrapper[(T@_, /)] +``` + +Type variables shared by multiple overloads are preserved as well: + +```py +def _[T](value: T) -> None: + @overload + def callback(value: T) -> T: ... + @overload + def callback(value: T, count: int) -> T: ... + def callback(value: T, count: int = 1) -> T: + return value + + # revealed: Wrapper[Overload[(value: T@_) -> Unknown, (value: T@_, count: int) -> Unknown]] + reveal_type(decorate(callback)) +``` + +A local return type variable is inferred from the argument, even when it appears in a nested +callable with a `ParamSpec`: + +```py +def make[**P, R](consume: Callable[[Callable[P, R]], None]) -> Callable[P, R]: + raise NotImplementedError + +def _[**P](consume: Callable[[Callable[P, Any]], None]) -> None: + reveal_type(make(consume)) # revealed: (**P@_) -> Any +``` + +The inferred return type also takes precedence over a type parameter default: + +```py +def make_with_default[**P, R = bytes](consume: Callable[[Callable[P, R]], None]) -> Callable[P, R]: + raise NotImplementedError + +def _[**P](consume: Callable[[Callable[P, Any]], None], *args: P.args, **kwargs: P.kwargs) -> str: + callback = make_with_default(consume) + reveal_type(callback) # revealed: (**P@_) -> Any + return callback(*args, **kwargs) +``` + ## Callable protocols with `ParamSpec` and class constructors When a class is passed to a function expecting a callable protocol with `ParamSpec`, the `ParamSpec` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md index ce587b01e2..be4dbee134 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md @@ -99,6 +99,22 @@ reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Un reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` +### Inherited specializations containing `Never` + +A `Never` argument in a variadic generic must retain its position when a subclass forwards its type +arguments to a generic base. + +```py +from typing import Any, Never + +class Kind[*Ts]: ... +class SupportsKind[*Ts](Kind[*Ts]): ... +class Container(SupportsKind[int, Never]): ... + +def _(value: Container) -> None: + expected: Kind[int, Any] = value +``` + ### `TypeVarTuple` with `ParamSpec` ```py @@ -135,13 +151,119 @@ class Variadic[*Ts]: reveal_type(Positional(())) # revealed: Positional[()] reveal_type(Positional((1, "a"))) # revealed: Positional[int, str] -# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. -reveal_type(Variadic()) # revealed: Variadic[*tuple[Unknown, ...]] -reveal_type(Variadic(1, "a")) # revealed: Variadic[*tuple[Unknown, ...]] +reveal_type(Variadic()) # revealed: Variadic[()] +reveal_type(Variadic(1, "a")) # revealed: Variadic[int, str] def _(i: int, s: str) -> None: reveal_type(Positional((i, s))) # revealed: Positional[int, str] - reveal_type(Variadic(i, s)) # revealed: Variadic[*tuple[Unknown, ...]] + reveal_type(Variadic(i, s)) # revealed: Variadic[int, str] +``` + +Constructor arguments determine the class specialization even when the assignment expects a +different specialization. + +```py +valid: Variadic[int] = Variadic(1) + +inferred = Variadic(1) +reveal_type(inferred) # revealed: Variadic[int] +# error: [invalid-assignment] +indirect: Variadic[str] = inferred +# error: [invalid-assignment] +direct: Variadic[str] = Variadic(1) +``` + +Concrete contexts do not supply missing arguments or discard extra ones. + +```py +# error: [invalid-assignment] +missing_argument: Variadic[int] = reveal_type(Variadic()) # revealed: Variadic[()] + +# error: [invalid-assignment] +extra_argument: Variadic[int] = reveal_type(Variadic(1, "a")) # revealed: Variadic[int, str] +``` + +A contextual specialization cannot supply missing constructor arguments. Empty calls infer an empty +pack, and one argument cannot satisfy an arbitrary outer pack, even when it matches a required +suffix. This applies with or without a fixed suffix. + +```py +def empty_with_context[*Us](shape: tuple[*Us]) -> Variadic[*Us, int]: + # error: [invalid-return-type] + return reveal_type(Variadic()) # revealed: Variadic[()] + +def nonempty_with_context[*Us](shape: tuple[*Us]) -> Variadic[*Us, int]: + # error: [invalid-return-type] + return reveal_type(Variadic(1)) # revealed: Variadic[int] + +def empty_without_suffix[*Us](shape: tuple[*Us]) -> Variadic[*Us]: + # error: [invalid-return-type] + return reveal_type(Variadic()) # revealed: Variadic[()] +``` + +Forwarding the outer pack supplies the required arguments. A compatible context can still widen +their element types without changing the pack's shape. + +```py +widened: Variadic[object] = Variadic(1) + +def forward_without_suffix[*Us](shape: tuple[*Us]) -> Variadic[*Us]: + return Variadic(*shape) + +def forward_with_suffix[*Us](shape: tuple[*Us]) -> Variadic[*Us, int]: + return Variadic(*shape, 1) + +def widen_suffix[*Us](shape: tuple[*Us]) -> Variadic[*Us, object]: + return Variadic(*shape, 1) +``` + +An unpacked `tuple[Any, ...]` can match any length, so a compatible context can specialize it. The +same gradual behavior applies when a tuple is passed as one element of the pack. + +```py +from typing import Any + +def gradual_arguments(values: tuple[Any, ...]) -> None: + concrete: Variadic[int, str] = reveal_type(Variadic(*values)) # revealed: Variadic[int, str] + nested: Variadic[object, tuple[int]] = reveal_type(Variadic(1, values)) # revealed: Variadic[object, tuple[int]] + +def gradual_with_context[*Us](shape: tuple[*Us], values: tuple[Any, ...]) -> Variadic[*Us, int]: + return Variadic(*values) + +def gradual_boundaries[*Us]( + shape: tuple[*Us], + prefix: tuple[int, *tuple[Any, ...]], + suffix: tuple[*tuple[Any, ...], str], +) -> None: + first: Variadic[int, *Us, str] = Variadic(*prefix) + last: Variadic[int, *Us, str] = Variadic(*suffix) +``` + +Aliases of `Any` preserve gradual length when the context supplies a concrete specialization. + +```py +type Dynamic = Any + +def gradual_alias_arguments(values: tuple[Dynamic, ...]) -> None: + concrete: Variadic[int, str] = reveal_type(Variadic(*values)) # revealed: Variadic[int, str] +``` + +Fixed elements still constrain the pack's length and types, even when other elements are gradual. + +```py +def fixed_any_with_context[*Us](shape: tuple[*Us], values: tuple[Any]) -> Variadic[*Us, int]: + fixed_length: Variadic[int] = reveal_type(Variadic(*values)) # revealed: Variadic[int] + # error: [invalid-return-type] + return reveal_type(Variadic(*values)) # revealed: Variadic[Any] + +def incompatible_gradual_prefix[*Us](shape: tuple[*Us], values: tuple[int, *tuple[Any, ...]]) -> Variadic[*Us, int]: + return Variadic(*values) # error: [invalid-return-type] + +def incompatible_gradual_element(values: tuple[bytes, *tuple[Any, ...]]) -> Variadic[int, str]: + return Variadic(*values) # error: [invalid-return-type] + +def too_many_gradual_boundaries(values: tuple[int, *tuple[Any, ...], str]) -> Variadic[int]: + return Variadic(*values) # error: [invalid-return-type] ``` ### Unspecified type arguments @@ -190,15 +312,64 @@ class Array[*Ts]: return self ``` +### Constrained inference from synthetic `Self` + +A fixed synthetic `Self` domain provides evidence for inferring a fresh constrained type variable, +without making the owner's type variables inference targets. A constraint with gradual tuple +arguments can accept a `TypeVarTuple` specialization. + +```py +from typing import Any, Generic, TypeVar + +class Other: ... + +class Container[T, *Ts]: + values: tuple[T, *Ts] + + def interface(self) -> "Interface[Container[Any, *tuple[Any, ...]]]": + return Interface(self) + +C = TypeVar( + "C", + Container[Any, *tuple[Any, ...]], + Other, + covariant=True, +) + +class Interface(Generic[C]): + def __init__(self, value: C) -> None: ... +``` + ## Functions ### Multiple type variable tuples Generic functions can declare multiple type variable tuples because their type parameters are -inferred from arguments; functions cannot be explicitly specialized. +inferred from arguments; functions cannot be explicitly specialized. Separate tuple arguments infer +their type variable tuples independently. ```py -def pair[*Ts1, *Ts2](first: tuple[*Ts1], second: tuple[*Ts2]) -> None: ... +def pair[*Ts, *Us]( + first: tuple[*Ts], + second: tuple[*Us], +) -> tuple[tuple[*Ts], tuple[*Us]]: + return first, second + +def check_pair(first: int, second: str, third: bool, fourth: bytes) -> None: + reveal_type(pair((first, second), (third, fourth))) # revealed: tuple[tuple[int, str], tuple[bool, bytes]] +``` + +A variadic parameter can also infer one type variable tuple from a fixed nested tuple and another +from its remaining arguments. + +```py +def nested[*Ts, *Us]( + *args: *tuple[tuple[*Us], *Ts], +) -> tuple[tuple[*Us], tuple[*Ts]]: + raise NotImplementedError + +def check_nested(first: int, second: str, third: bool, fourth: bytes) -> None: + reveal_type(nested((first, second), third, fourth)) # revealed: tuple[tuple[int, str], tuple[bool, bytes]] ``` ### Tuple arguments and returns @@ -242,6 +413,18 @@ def f(i: int, s: str, b: bool, t: tuple[int, str], vt: tuple[int, ...]) -> None: reveal_type(simple(*t)) # revealed: tuple[Unknown, ...] ``` +A gradual tuple also infers a gradual pack when the parameter allows `None`. + +```py +from typing import Any + +def optional[*Ts](value: tuple[*Ts] | None) -> tuple[*Ts] | None: + return value + +def check_optional(value: tuple[Any, ...]) -> None: + reveal_type(optional(value)) # revealed: tuple[Any, ...] | None +``` + ### Assignability to fixed-length tuples An unspecialized type variable tuple can contain any number of elements, so a tuple containing one @@ -257,9 +440,9 @@ def middle_pack[*Ts](value: tuple[int, *Ts, str]) -> tuple[int, str]: ### Assignability involving type variable tuples -A symbolic type variable tuple can be erased to a homogeneous `object` tuple, but a homogeneous -tuple cannot be used to construct an arbitrary symbolic pack. Two independently bound packs are also -not interchangeable. +A symbolic type variable tuple can be erased to a homogeneous `object` tuple, but a fully static +homogeneous tuple cannot be used to construct an arbitrary symbolic pack. Two independently bound +packs are also not interchangeable. ```py def erase_pack[*Ts](values: tuple[*Ts]) -> tuple[object, ...]: @@ -282,6 +465,32 @@ class Outer[*Ts]: return values # error: [invalid-return-type] ``` +A fixed-length tuple cannot replace an arbitrary type variable tuple either. The caller determines +the pack's length and element types, so even an empty tuple is not a valid return for every pack. +The same restriction applies to annotated assignments inside the function. + +```py +def reject_empty[*Ts](values: tuple[*Ts]) -> tuple[*Ts]: + return () # error: [invalid-return-type] + +def reject_fixed[*Ts](values: tuple[*Ts]) -> tuple[*Ts]: + return (1, "a") # error: [invalid-return-type] + +def reject_fixed_assignment[*Ts]() -> None: + fixed: tuple[*Ts] = (1,) # error: [invalid-assignment] +``` + +Matching fixed elements before or after a pack do not establish what the pack contains. The +remaining elements still cannot replace an arbitrary type variable tuple. + +```py +def reject_empty_middle[*Ts](values: tuple[*Ts]) -> tuple[int, *Ts, str]: + return (1, "a") # error: [invalid-return-type] + +def reject_fixed_middle[*Ts](values: tuple[*Ts]) -> tuple[int, *Ts, str]: + return (1, True, "a") # error: [invalid-return-type] +``` + Materializing a type variable tuple can change its default without changing the identity of the bound type variable occurrence. @@ -299,10 +508,293 @@ def materialized_default[*Ts = *tuple[Any, ...]]() -> None: static_assert(is_assignable_to(tuple[*Ts], Top[tuple[*Ts]])) ``` +Fixed-length tuples are not subtypes of an arbitrary pack either. An `Any` or `Never` element does +not change a fixed tuple's length. + +```py +from typing import Never +from ty_extensions._internal import is_subtype_of + +def fixed_tuple_relations[*Ts]() -> None: + static_assert(not is_subtype_of(tuple[()], tuple[*Ts])) + static_assert(not is_subtype_of(tuple[int], tuple[*Ts])) + static_assert(not is_assignable_to(tuple[Any], tuple[*Ts])) + static_assert(not is_assignable_to(tuple[Never], tuple[*Ts])) +``` + +### Gradual tuple assignability to symbolic packs + +A fully gradual tuple can materialize to any specialization of a type variable tuple, including +fixed elements around the pack. This permits assignment, but does not make it a subtype of the +symbolic tuple. + +```py +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import Unknown, is_subtype_of + +def gradual_packs[*Ts](dynamic: tuple[Any, ...], unknown: tuple[Unknown, ...]) -> None: + plain: tuple[*Ts] = dynamic + plain = unknown + bounded: tuple[int, *Ts, str] = unknown + static_assert(not is_subtype_of(tuple[Unknown, ...], tuple[*Ts])) +``` + +A gradual tuple with a fixed prefix or suffix cannot be assigned to a bare symbolic pack, which may +be empty. This remains true when the required element is `Any`. + +```py +def fixed_boundaries[*Ts]( + prefix: tuple[Any, *tuple[Any, ...]], + suffix: tuple[*tuple[Any, ...], Any], +) -> None: + plain: tuple[*Ts] = prefix # error: [invalid-assignment] + plain = suffix # error: [invalid-assignment] +``` + +### Mixed gradual tuple assignability to symbolic packs + +Fixed prefixes and suffixes are compared covariantly. The gradual segment can supply additional +required target elements as well as the symbolic pack, but fixed source elements cannot disappear. + +```py +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import Unknown, is_subtype_of + +def mixed_sources[*Ts]( + prefix: tuple[bool, *tuple[Any, ...]], + suffix: tuple[*tuple[Unknown, ...], str], + both: tuple[bool, *tuple[Any, ...], str], +) -> None: + prefixed: tuple[int, *Ts] = prefix + suffixed: tuple[*Ts, object] = suffix + bounded: tuple[int, *Ts, object] = both + longer: tuple[int, bytes, *Ts, float, object] = both + + too_short: tuple[int, *Ts] = both # error: [invalid-assignment] + wrong_prefix: tuple[str, *Ts, object] = both # error: [invalid-assignment] + wrong_suffix: tuple[int, *Ts, int] = both # error: [invalid-assignment] + + static_assert(not is_subtype_of(tuple[bool, *tuple[Any, ...], str], tuple[int, *Ts, object])) +``` + +### Fixed source elements crossing symbolic packs + +A fixed source element that falls inside the symbolic pack must be assignable to every possible +element type. `Any`, `Unknown`, and `Never` allow this; `int` and `Any | int` do not. Even a `Never` +element remains a required tuple position when the pack is empty. + +```py +from typing import Any, Never +from ty_extensions._internal import Unknown + +def crossing_sources[*Ts]( + any_prefix: tuple[Any, *tuple[Any, ...]], + unknown_suffix: tuple[*tuple[Any, ...], Unknown], + never_prefix: tuple[Never, *tuple[Any, ...]], + int_prefix: tuple[int, *tuple[Any, ...]], + union_suffix: tuple[*tuple[Any, ...], Any | int], +) -> None: + moved_prefix: tuple[*Ts, int] = any_prefix + moved_suffix: tuple[int, *Ts] = unknown_suffix + bottom_prefix: tuple[*Ts, int] = never_prefix + + too_short: tuple[*Ts] = never_prefix # error: [invalid-assignment] + restricted_prefix: tuple[*Ts, int] = int_prefix # error: [invalid-assignment] + restricted_suffix: tuple[int, *Ts] = union_suffix # error: [invalid-assignment] +``` + +A scalar type variable can match itself at an aligned position, but it does not constrain the +unrelated elements of the symbolic pack. An aligned `T` also cannot satisfy a fixed `int` target, +since `T` is not necessarily a subtype of `int`. + +```py +def scalar_source[T, *Ts](source: tuple[T, *tuple[Any, ...]]) -> None: + aligned: tuple[T, *Ts] = source + no_specialization: tuple[int, *Ts] = source # error: [invalid-assignment] + crossing: tuple[*Ts, int] = source # error: [invalid-assignment] +``` + +### Symbolic pack assignability to mixed gradual tuples + +Fixed source endpoints remain usable when the symbolic pack is erased to a gradual segment. The +target boundaries must also fit when the symbolic pack is empty. + +```py +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import Unknown, is_subtype_of + +def mixed_targets[*Ts](source: tuple[bool, *Ts, str]) -> None: + bounded: tuple[int, *tuple[Any, ...], object] = source + prefixed: tuple[int, *tuple[Unknown, ...]] = source + suffixed: tuple[*tuple[Any, ...], object] = source + + too_long: tuple[int, bytes, *tuple[Any, ...], str] = source # error: [invalid-assignment] + wrong_prefix: tuple[str, *tuple[Any, ...], object] = source # error: [invalid-assignment] + wrong_suffix: tuple[int, *tuple[Any, ...], int] = source # error: [invalid-assignment] + + static_assert(not is_subtype_of(tuple[bool, *Ts, str], tuple[int, *tuple[Any, ...], object])) +``` + +### Fixed target elements crossing symbolic packs + +A fixed target element that falls inside the symbolic pack must accept every possible element type. +`object` and `Any` allow this, but `int` and a separate scalar type variable do not. Unlike a source +element of type `Any | int`, a target element of that type can accept any pack element by +materializing its `Any` appropriately. + +```py +from typing import Any +from ty_extensions._internal import Unknown + +def crossing_targets[*Ts]( + prefix: tuple[int, *Ts], + suffix: tuple[*Ts, int], + long_suffix: tuple[*Ts, int, str], + plain: tuple[*Ts], +) -> None: + moved_prefix: tuple[object, *tuple[Any, ...]] = suffix + moved_long_suffix: tuple[object, *tuple[Any, ...], str] = long_suffix + moved_suffix: tuple[*tuple[Any, ...], Any] = prefix + unknown_prefix: tuple[Unknown, *tuple[Any, ...]] = suffix + union_suffix: tuple[*tuple[Any, ...], Any | int] = prefix + + restricted_prefix: tuple[int, *tuple[Any, ...]] = suffix # error: [invalid-assignment] + restricted_suffix: tuple[*tuple[Any, ...], int] = prefix # error: [invalid-assignment] + too_short: tuple[object, *tuple[Any, ...]] = plain # error: [invalid-assignment] + +def scalar_target[T, *Ts](source: tuple[*Ts, int]) -> None: + target: tuple[T, *tuple[Any, ...]] = source # error: [invalid-assignment] +``` + +### Protocol target elements crossing symbolic packs + +A protocol used as a fixed target element must accept every possible pack element. All objects +support `__str__`, but a pack can contain an unhashable value such as a list. A protocol that +requires `__hash__` therefore cannot accept an arbitrary pack element at a fixed endpoint. + +```py +from typing import Any, Protocol + +class SupportsStr(Protocol): + def __str__(self) -> str: ... + +class SupportsHash(Protocol): + def __hash__(self) -> int: ... + +def protocol_targets[*Ts](prefix: tuple[int, *Ts], suffix: tuple[*Ts, int]) -> None: + universal_prefix: tuple[SupportsStr, *tuple[Any, ...]] = suffix + universal_suffix: tuple[*tuple[Any, ...], SupportsStr] = prefix + + hash_prefix: tuple[SupportsHash, *tuple[Any, ...]] = suffix # error: [invalid-assignment] + hash_suffix: tuple[*tuple[Any, ...], SupportsHash] = prefix # error: [invalid-assignment] +``` + +The standard-library `Hashable` protocol follows the same rule, including through an alias: + +```py +from collections.abc import Hashable + +type HashableAlias = Hashable + +def hashable_targets[*Ts](prefix: tuple[int, *Ts], suffix: tuple[*Ts, int]) -> None: + hash_prefix: tuple[Hashable, *tuple[Any, ...]] = suffix # snapshot: invalid-assignment + hash_suffix: tuple[*tuple[Any, ...], HashableAlias] = prefix # error: [invalid-assignment] +``` + +```snapshot +error[invalid-assignment]: Object of type `tuple[*Ts@hashable_targets, int]` is not assignable to `tuple[Hashable, *tuple[Any, ...]]` + --> src/mdtest_snippet.py:20:54 + | +20 | hash_prefix: tuple[Hashable, *tuple[Any, ...]] = suffix # snapshot: invalid-assignment + | --------------------------------- ^^^^^^ Incompatible value of type `tuple[*Ts@hashable_targets, int]` + | | + | Declared type +``` + +Fixed `object` endpoints retain their ordinary assignability to `Hashable`, which permits uses such +as `object()` sentinels. This does not imply that arbitrary pack elements are hashable: + +```py +def fixed_objects[*Ts](source: tuple[object, *Ts, object]) -> None: + prefix: tuple[Hashable, *tuple[Any, ...]] = source + suffix: tuple[*tuple[Any, ...], Hashable] = source +``` + +When a fixed source endpoint is unhashable, the diagnostic identifies that endpoint's type: + +```py +def fixed_unhashable[*Ts](source: tuple[list[int], *Ts]) -> None: + target: tuple[Hashable, *tuple[Any, ...]] = source # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `tuple[list[int], *Ts@fixed_unhashable]` is not assignable to `tuple[Hashable, *tuple[Any, ...]]` + --> src/mdtest_snippet.py:26:49 + | +26 | target: tuple[Hashable, *tuple[Any, ...]] = source # snapshot: invalid-assignment + | --------------------------------- ^^^^^^ Incompatible value of type `tuple[list[int], *Ts@fixed_unhashable]` + | | + | Declared type +info: type `list[int]` is not assignable to protocol `Hashable` +info: └── protocol member `__hash__` is incompatible +``` + +### Inferring scalar target elements beside symbolic packs + +When assigning a generic function to a callable type, its scalar type parameter can be inferred from +a tuple containing a symbolic pack. The inferred element type must accept every possible pack +element. A type variable without an explicit bound can match, but one bounded by `Hashable` cannot. + +```py +from collections.abc import Hashable +from typing import Any, Callable + +def accept[T](value: tuple[T, *tuple[Any, ...]]) -> None: ... +def accept_hashable[T: Hashable](value: tuple[T, *tuple[Any, ...]]) -> None: ... +def callbacks[*Ts]() -> None: + unbounded: Callable[[tuple[*Ts, int]], None] = accept + hashable: Callable[[tuple[*Ts, int]], None] = accept_hashable # error: [invalid-assignment] +``` + +### Aliases of gradual tuple elements + +An alias of `Any` also makes a variadic segment gradual in length. Aliases of `list[Any]` or +`Any | int` do not have that effect, nor does a recursive container alias. + +```py +from typing import Any + +type Dynamic = Any +type IndirectDynamic = Dynamic +type AnyList = list[Any] +type PartlyDynamic = Any | int +type Recursive = list[Recursive] + +def gradual_aliases[*Ts]( + source: tuple[int, *tuple[IndirectDynamic, ...], str], + symbolic: tuple[int, *Ts, str], +) -> None: + packed: tuple[int, *Ts, str] = source + erased: tuple[int, *tuple[IndirectDynamic, ...], str] = symbolic + +def non_gradual_aliases[*Ts]( + containers: tuple[int, *tuple[AnyList, ...]], + union: tuple[int, *tuple[PartlyDynamic, ...]], + recursive: tuple[int, *tuple[Recursive, ...]], +) -> None: + pack: tuple[int, *Ts] = containers # error: [invalid-assignment] + pack = union # error: [invalid-assignment] + pack = recursive # error: [invalid-assignment] +``` + ### Starred variadic parameters -An unpacked `TypeVarTuple` can annotate `*args`. Inferring the `TypeVarTuple` from arguments matched -to the variadic parameter is not yet supported, so these calls use a gradual specialization. +An unpacked `TypeVarTuple` can annotate `*args`. Call binding infers the pack from direct arguments +and from the residual tuple shape of splatted arguments, while generic function bodies retain the +symbolic pack declared by the function. ```py def simple[*Ts](*args: *Ts) -> tuple[*Ts]: @@ -312,34 +804,232 @@ def simple[*Ts](*args: *Ts) -> tuple[*Ts]: def with_prefix[T, *Ts](prefix: T, *args: *Ts) -> tuple[T, *Ts]: raise NotImplementedError +def bounded[*Ts](head: int, *rest: *tuple[*Ts, str]) -> tuple[*Ts]: + raise NotImplementedError + def with_kw_only[T, *Ts](*args: *Ts, kw: T) -> tuple[*Ts, T]: raise NotImplementedError -def f(i: int, s: str, b: bool, t: tuple[int, str], vt: tuple[int, ...]) -> None: - reveal_type(simple()) # revealed: tuple[Unknown, ...] - reveal_type(simple(i, s)) # revealed: tuple[Unknown, ...] - reveal_type(simple(*(i, s))) # revealed: tuple[Unknown, ...] - reveal_type(simple(t)) # revealed: tuple[Unknown, ...] - reveal_type(simple(*t)) # revealed: tuple[Unknown, ...] - reveal_type(simple(*vt)) # revealed: tuple[Unknown, ...] - - reveal_type(with_prefix(i)) # revealed: tuple[int, *tuple[Unknown, ...]] - reveal_type(with_prefix(i, s, b)) # revealed: tuple[int, *tuple[Unknown, ...]] - reveal_type(with_prefix(*t)) # revealed: tuple[int, *tuple[Unknown, ...]] - reveal_type(with_prefix(i, *t)) # revealed: tuple[int, *tuple[Unknown, ...]] - # error: [refutable-unpacking] - reveal_type(with_prefix(*vt)) # revealed: tuple[int, *tuple[Unknown, ...]] - reveal_type(with_prefix(i, *vt)) # revealed: tuple[int, *tuple[Unknown, ...]] - - reveal_type(with_kw_only(kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] - reveal_type(with_kw_only(i, s, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] - reveal_type(with_kw_only(t, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] - reveal_type(with_kw_only(*t, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] - reveal_type(with_kw_only(vt, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] - reveal_type(with_kw_only(*vt, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] +def forward[*Us](*args: *Us) -> tuple[*Us]: + reveal_type(simple(*args)) # revealed: tuple[*Us@forward] + return simple(*args) + +def f( + i: int, + s: str, + b: bool, + empty: tuple[()], + one: tuple[int], + fixed: tuple[int, str], + suffix: tuple[bool, str], + unbounded: tuple[int, ...], + mixed: tuple[int, *tuple[str, ...], bytes], + xs: list[int], +) -> None: + reveal_type(simple()) # revealed: tuple[()] + reveal_type(simple(i)) # revealed: tuple[int] + reveal_type(simple(i, s)) # revealed: tuple[int, str] + reveal_type(simple(*(i, s))) # revealed: tuple[int, str] + reveal_type(simple(i, s, b)) # revealed: tuple[int, str, bool] + reveal_type(simple(fixed)) # revealed: tuple[tuple[int, str]] + reveal_type(simple(*empty)) # revealed: tuple[()] + reveal_type(simple(*one)) # revealed: tuple[int] + reveal_type(simple(*fixed)) # revealed: tuple[int, str] + reveal_type(simple(*unbounded)) # revealed: tuple[int, ...] + reveal_type(simple(*mixed)) # revealed: tuple[int, *tuple[str, ...], bytes] + reveal_type(simple(*xs)) # revealed: tuple[int, ...] + + reveal_type(with_prefix(i)) # revealed: tuple[int] + reveal_type(with_prefix(i, s, b)) # revealed: tuple[int, str, bool] + reveal_type(with_prefix(*fixed)) # revealed: tuple[int, str] + reveal_type(with_prefix(i, *fixed)) # revealed: tuple[int, int, str] + # error: [refutable-unpacking] "`tuple[int, ...]` may not have at least 1 element, which would raise `TypeError` when unpacked into this call" + reveal_type(with_prefix(*unbounded)) # revealed: tuple[int, *tuple[int, ...]] + reveal_type(with_prefix(i, *unbounded)) # revealed: tuple[int, *tuple[int, ...]] + # error: [refutable-unpacking] "`list[int]` may not have at least 1 element, which would raise `TypeError` when unpacked into this call" + reveal_type(with_prefix(*xs)) # revealed: tuple[int, *tuple[int, ...]] + + reveal_type(bounded(i, *suffix)) # revealed: tuple[bool] + + reveal_type(with_kw_only(kw=b)) # revealed: tuple[bool] + reveal_type(with_kw_only(i, s, kw=b)) # revealed: tuple[int, str, bool] + reveal_type(with_kw_only(fixed, kw=b)) # revealed: tuple[tuple[int, str], bool] + reveal_type(with_kw_only(*fixed, kw=b)) # revealed: tuple[int, str, bool] + reveal_type(with_kw_only(unbounded, kw=b)) # revealed: tuple[tuple[int, ...], bool] + reveal_type(with_kw_only(*unbounded, kw=b)) # revealed: tuple[*tuple[int, ...], bool] + reveal_type(with_kw_only(*xs, kw=b)) # revealed: tuple[*tuple[int, ...], bool] # error: [missing-argument] "No argument provided for required parameter `kw` of function `with_kw_only`" - reveal_type(with_kw_only(i, s, b)) # revealed: tuple[*tuple[Unknown, ...], Unknown] + reveal_type(with_kw_only(i, s, b)) # revealed: tuple[int, str, bool, Unknown] +``` + +Variadic inference preserves contextual argument types, including an outer type variable. + +```py +from typing import TypedDict + +class Payload(TypedDict): + value: int + +def contextual[T](value: T) -> None: + concrete: tuple[Payload, list[int]] = simple({"value": 1}, []) + generic: tuple[Payload, T] = simple({"value": 1}, value) + # error: [invalid-assignment] + # error: [invalid-argument-type] + invalid: tuple[Payload] = simple({"value": "wrong"}) +``` + +Fixed values next to a type variable tuple keep their normal bound diagnostics. + +```py +def bounded_arguments[U: bytes, T: str, *Ts](first: U, *args: *tuple[*Ts, T]) -> tuple[*Ts, T]: + raise NotImplementedError + +bounded_arguments( + 1, # error: [invalid-argument-type] "upper bound `bytes`" + "ok", + 2, # error: [invalid-argument-type] "upper bound `str`" +) + +def check_splat_error(values: list[int]) -> None: + bounded_arguments( + b"valid", + *values, # snapshot: invalid-argument-type + ) +``` + +```snapshot +error[invalid-argument-type]: Argument to function `bounded_arguments` is incorrect + --> src/mdtest_snippet.py:88:9 + | +88 | *values, # snapshot: invalid-argument-type + | ^^^^^^^ Argument type `int` does not satisfy upper bound `str` of type variable `T` +info: Type variable defined here + --> src/mdtest_snippet.py:76:33 + | +76 | def bounded_arguments[U: bytes, T: str, *Ts](first: U, *args: *tuple[*Ts, T]) -> tuple[*Ts, T]: + | ^^^^^^ +``` + +### Union splatted arguments + +Equal-length tuple unions preserve their length and combine the types at each position. Different +lengths produce an open tuple, while direct arguments around the splat keep their known positions. + +```py +def collect[*Ts](*args: *Ts) -> tuple[*Ts]: + return args + +def check( + same_length: tuple[int] | tuple[str], + paired: tuple[int, str] | tuple[bytes, bool], + different_lengths: tuple[int] | tuple[str, bytes], + prefix: bool, + suffix: bytes, +) -> None: + reveal_type(collect(*same_length)) # revealed: tuple[int | str] + reveal_type(collect(*paired)) # revealed: tuple[int | bytes, str | bool] + reveal_type(collect(*different_lengths)) # revealed: tuple[int | str | bytes, ...] + reveal_type(collect(prefix, *same_length, suffix)) # revealed: tuple[bool, int | str, bytes] + + # error: [invalid-assignment] + wrong: tuple[bytes] = collect(*same_length) +``` + +### Starred variadic arguments without a variadic return + +A bounded or constrained element is checked even when the return type does not contain its pack. + +```py +def bounded_prefix[T: str, *Ts](*args: *tuple[T, *Ts]) -> None: ... +def constrained_suffix[T: (str, bytes), *Ts](*args: *tuple[*Ts, T]) -> None: ... +def check(values: list[int], valid: list[str]) -> None: + bounded_prefix(*valid) + constrained_suffix(*valid) + + # error: [invalid-argument-type] + bounded_prefix(*values) + # error: [invalid-argument-type] + constrained_suffix(*values) +``` + +### Argument types override incompatible contextual return types + +A contextual return type can guide compatible arguments, but it must not override the argument types +or the number of arguments in a call. + +```py +def collect[*Ts](*args: *Ts) -> tuple[*Ts]: + return args + +valid: tuple[int] = collect(1) + +inferred = collect(1) +reveal_type(inferred) # revealed: tuple[Literal[1]] +# error: [invalid-assignment] +indirect: tuple[str] = inferred +# error: [invalid-assignment] +direct: tuple[str] = collect(1) + +valid_empty: tuple[()] = collect() +# error: [invalid-assignment] +invalid_empty: tuple[str] = collect() +``` + +Return statements and arguments to other functions also provide contextual return types. + +```py +def invalid_return() -> tuple[str]: + # error: [invalid-return-type] + return collect(1) + +def accept_strings(values: tuple[str]) -> None: ... + +accept_strings(collect("valid")) +# error: [invalid-argument-type] +accept_strings(collect(1)) +``` + +### Fixed boundaries around variadic type variable tuples + +Fixed values before or after a type variable tuple do not become part of its inferred shape. Open +splats can provide those boundaries while preserving fixed values already present on the other side. + +```py +def prefixed[*Ts](*args: *tuple[int, *Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def suffixed[*Ts](*args: *tuple[*Ts, str]) -> tuple[*Ts]: + raise NotImplementedError + +def bounded[*Ts](*args: *tuple[int, *Ts, int]) -> tuple[*Ts]: + raise NotImplementedError + +def check( + ints: list[int], + strings: list[str], + extra_prefix: tuple[int, bool, *tuple[str, ...], bytes], + extra_suffix: tuple[bool, *tuple[int, ...], bytes, str], + extra_boundaries: tuple[int, bool, *tuple[str, ...], bytes, int], + missing_prefix: tuple[*tuple[int, ...], bytes], + missing_suffix: tuple[bool, *tuple[str, ...]], +) -> None: + reveal_type(prefixed(1)) # revealed: tuple[()] + reveal_type(prefixed(1, True)) # revealed: tuple[Literal[True]] + reveal_type(prefixed(*ints)) # revealed: tuple[int, ...] + reveal_type(prefixed(*extra_prefix)) # revealed: tuple[bool, *tuple[str, ...], bytes] + reveal_type(prefixed(*missing_prefix)) # revealed: tuple[*tuple[int, ...], bytes] + + reveal_type(suffixed("last")) # revealed: tuple[()] + reveal_type(suffixed(True, "last")) # revealed: tuple[Literal[True]] + reveal_type(suffixed(*strings)) # revealed: tuple[str, ...] + reveal_type(suffixed(*extra_suffix)) # revealed: tuple[bool, *tuple[int, ...], bytes] + reveal_type(suffixed(*missing_suffix)) # revealed: tuple[bool, *tuple[str, ...]] + + reveal_type(bounded(1, 1)) # revealed: tuple[()] + reveal_type(bounded(1, True, 1)) # revealed: tuple[Literal[True]] + reveal_type(bounded(*ints)) # revealed: tuple[int, ...] + reveal_type(bounded(*extra_boundaries)) # revealed: tuple[bool, *tuple[str, ...], bytes] ``` ### Callable inference @@ -393,6 +1083,105 @@ reveal_type(simple(variadic2)) # revealed: tuple[Unknown, ...] reveal_type(simple(keyword_only)) # revealed: tuple[Unknown, ...] ``` +### Callable inference through invariant and contravariant wrappers + +An unpacked `TypeVarTuple` keeps its precise inferred parameter types when a callable or callable +protocol is nested inside an invariant or contravariant wrapper. + +```py +from typing import Callable, Protocol + +class Invariant[T]: + def __init__(self, callback: T) -> None: ... + callback: T + +class Contravariant[T]: + def __init__(self, callback: T) -> None: ... + def put(self, callback: T) -> None: ... + +def invariant[*Ts](wrapper: Invariant[Callable[[*Ts], None]]) -> tuple[*Ts]: + raise NotImplementedError + +def contravariant[*Ts](wrapper: Contravariant[Callable[[*Ts], None]]) -> tuple[*Ts]: + raise NotImplementedError + +def callback(first: object, value: str) -> None: ... + +reveal_type(invariant(Invariant(callback))) # revealed: tuple[object, str] +reveal_type(contravariant(Contravariant(callback))) # revealed: tuple[object, str] +``` + +A callable protocol preserves the same inferred parameters through both wrapper variances. + +```py +class Callback[*Ts](Protocol): + def __call__(self, *args: *Ts) -> None: ... + +def invariant_protocol[*Ts](wrapper: Invariant[Callback[*Ts]]) -> tuple[*Ts]: + raise NotImplementedError + +def contravariant_protocol[*Ts](wrapper: Contravariant[Callback[*Ts]]) -> tuple[*Ts]: + raise NotImplementedError + +reveal_type(invariant_protocol(Invariant(callback))) # revealed: tuple[object, str] +reveal_type(contravariant_protocol(Contravariant(callback))) # revealed: tuple[object, str] +``` + +Separately declared protocols with equivalent variadic methods also preserve the exact inferred +tuple under both wrapper variances. + +```py +class Target[*Ts](Protocol): + def call(self, *args: *Ts) -> None: ... + +class Actual[*Ts](Protocol): + def call(self, *args: *Ts) -> None: ... + +def invariant_structural[*Ts](wrapper: Invariant[Target[*Ts]]) -> tuple[*Ts]: + raise NotImplementedError + +def contravariant_structural[*Ts](wrapper: Contravariant[Target[*Ts]]) -> tuple[*Ts]: + raise NotImplementedError + +def check_structural( + invariant_wrapper: Invariant[Actual[str]], + contravariant_wrapper: Contravariant[Actual[str]], +) -> None: + reveal_type(invariant_structural(invariant_wrapper)) # revealed: tuple[str] + reveal_type(contravariant_structural(contravariant_wrapper)) # revealed: tuple[str] +``` + +Unions of structurally compatible protocols retain the same tuple. Incompatible alternatives are +rejected without widening their inferred tuple to an unknown-length tuple. + +```py +class Other[*Ts](Protocol): + def call(self, *args: *Ts) -> None: ... + +def check_unions( + invariant_match: Invariant[Actual[str] | Other[str]], + contravariant_match: Contravariant[Actual[str] | Other[str]], + invariant_mismatch: Invariant[Actual[str] | Other[bytes]], + contravariant_mismatch: Contravariant[Actual[str] | Other[bytes]], +) -> None: + reveal_type(invariant_structural(invariant_match)) # revealed: tuple[str] + reveal_type(contravariant_structural(contravariant_match)) # revealed: tuple[str] + # error: [invalid-argument-type] + reveal_type(invariant_structural(invariant_mismatch)) # revealed: tuple[()] + # error: [invalid-argument-type] + reveal_type(contravariant_structural(contravariant_mismatch)) # revealed: tuple[()] +``` + +A nominal class implementing the same variadic protocol retains its precise method parameter. + +```py +class StringRunner: + def call(self, value: str) -> None: ... + +reveal_type(invariant_structural(Invariant(StringRunner()))) # revealed: tuple[str] +reveal_type(contravariant_structural(Contravariant(StringRunner()))) # revealed: tuple[str] +``` + ### Callable return inference An unpacked `TypeVarTuple` in a callable return type is inferred as one packed tuple, including @@ -483,12 +1272,55 @@ def forward_mixed[*Ts]( accept_mixed_forwarded(callback, args) ``` +### Callable inference through nested callable parameters + +Nested callable parameters make the pack covariant, but inference currently loses its fixed length. + +```py +from typing import Callable + +def nested[*Ts]( + callback: Callable[[Callable[[*Ts], None]], None], + *args: *Ts, +) -> tuple[*Ts]: + return args + +def accepts_int_callback(callback: Callable[[int], None]) -> None: ... +def check(value: int, other: str) -> None: + # TODO: Should reveal `tuple[int]`. + reveal_type(nested(accepts_int_callback, value)) # revealed: tuple[int, ...] + # TODO: Should reveal `tuple[int | str]`. + reveal_type(nested(accepts_int_callback, other)) # revealed: tuple[int, ...] + + # TODO: Should report an error because the callback accepts only one argument. + nested(accepts_int_callback, value, other) +``` + +### Starred variadic tuple normalization + +A fixed provided tuple containing `Never` keeps its shape during tuple-level constraint inference. +Its `Never` element must not be discarded or replaced by an unknown-length tuple. + +```py +from typing import Never + +def collect[*Ts](*args: *Ts) -> tuple[*Ts]: + raise NotImplementedError + +def collect_prefixed[*Ts](*args: *tuple[int, *Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def check_never(value: Never) -> None: + reveal_type(collect(value)) # revealed: tuple[Never] + reveal_type(collect_prefixed(1, value)) # revealed: tuple[Never] +``` + ### Unsupported callable checks are deferred -Until call binding can infer a `TypeVarTuple` from `*args`, a generic callback can leave the -expected callable with a gradual positional parameter list. Similarly, inferring each position from -an overload independently loses the correlation between overload branches. Avoid reporting these -cases until the missing inference is implemented. +A generic callback can leave the expected callable with a gradual positional parameter list until +callback constraints are combined with the inferred arguments. Similarly, inferring each position +from an overload independently loses the correlation between overload branches. Avoid reporting +these cases until callback forwarding is supported. ```py from collections.abc import Awaitable, Callable @@ -699,6 +1531,67 @@ def f(i: int, s: str, b: bool) -> None: reveal_type(foo((i,), (s, b))) # revealed: tuple[int] ``` +A positional tuple and `*args` using the same type variable tuple must have the same length. When +their lengths match, their element types are combined. + +```py +def repeat[*Ts](expected: tuple[*Ts], *args: *Ts) -> tuple[*Ts]: + return expected + +def check_repeated(i: int, s: str) -> None: + reveal_type(repeat(())) # revealed: tuple[()] + reveal_type(repeat((i, s), i, s)) # revealed: tuple[int, str] + reveal_type(repeat((i, s), i, i)) # revealed: tuple[int, str | int] + + # error: 5 [invalid-argument-type] "Argument to function `repeat` is incorrect: Expected `tuple[int]`, found `tuple[()]`" + repeat((i,)) + # error: 20 [invalid-argument-type] "Argument to function `repeat` is incorrect: Expected `tuple[int, str]`, found `tuple[int]`" + repeat((i, s), i) + # snapshot: invalid-argument-type + repeat((i,), i, s) +``` + +```snapshot +error[invalid-argument-type]: Argument to function `repeat` is incorrect + --> src/mdtest_snippet.py:21:18 + | +21 | repeat((i,), i, s) + | ^^^^ Expected `tuple[int]`, found `tuple[int, str]` +info: a tuple of length 2 is not assignable to a tuple of length 1 +info: Function defined here + --> src/mdtest_snippet.py:8:5 + | +8 | def repeat[*Ts](expected: tuple[*Ts], *args: *Ts) -> tuple[*Ts]: + | ^^^^^^ ---------- Parameter declared here +``` + +The same length and element-type rules apply when the tuple is passed as a keyword-only argument. + +```py +def repeat_keyword[*Ts](*args: *Ts, expected: tuple[*Ts]) -> tuple[*Ts]: + return expected + +def check_repeated_keyword(i: int, s: str) -> None: + reveal_type(repeat_keyword(expected=())) # revealed: tuple[()] + reveal_type(repeat_keyword(i, s, expected=(i, s))) # revealed: tuple[int, str] + reveal_type(repeat_keyword(i, i, expected=(i, s))) # revealed: tuple[int, str | int] + + # error: 20 [invalid-argument-type] "Argument to function `repeat_keyword` is incorrect: Expected `tuple[int, str]`, found `tuple[int]`" + repeat_keyword(i, expected=(i, s)) + # error: 20 [invalid-argument-type] "Argument to function `repeat_keyword` is incorrect: Expected `tuple[int]`, found `tuple[int, str]`" + repeat_keyword(i, s, expected=(i,)) +``` + +Matching lengths are also required when the return type does not contain the type variable tuple. + +```py +def repeat_without_return[*Ts](expected: tuple[*Ts], *args: *Ts) -> None: ... + +repeat_without_return((1, "value"), 1, "value") +# error: [invalid-argument-type] +repeat_without_return((1, "value"), 1) +``` + ## Type concatenation A type variable tuple can be combined with fixed leading or trailing types. @@ -794,8 +1687,7 @@ accept_str_in_between(True, "phase", "status", b"ok") accept_str_in_between(True, b"ok") accept_str_in_between(True, 1, b"bad") # error: [invalid-argument-type] -# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. -reveal_type(remove_bytes(1, "record", b"sum")) # revealed: tuple[Unknown, ...] +reveal_type(remove_bytes(1, "record", b"sum")) # revealed: tuple[Literal[1], Literal["record"]] ``` ## `@staticmethod` and `@classmethod` @@ -857,6 +1749,21 @@ def _( reveal_type(a10) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` +### Aliases containing `Never` + +A variadic alias retains each specialized argument even when a later argument is `Never`. + +```py +from typing import Never + +class Container[*Ts]: ... + +type Padded[T] = Container[T, Never] + +def _(value: Padded[int]) -> None: + reveal_type(value) # revealed: Container[int, Never] +``` + ### Unpacked tuple type arguments ```py @@ -1040,6 +1947,8 @@ type Alias[*Ts1, *Ts2] = tuple[*Ts1] | tuple[*Ts2] ### Must always be unpacked +A type variable tuple represents zero or more types, so it cannot be used as a single type. + ```py def invalid[*Ts](x: Ts) -> None: ... # error: [invalid-type-form] def invalid_args[*Ts](*args: Ts) -> None: ... # error: [invalid-type-form] @@ -1048,10 +1957,98 @@ class InvalidTupleElement[*Ts]: # error: [invalid-type-form] "Bare TypeVarTuple `Ts` is not valid in this context in a type expression" values: tuple[Ts] +reveal_type(InvalidTupleElement[int, str]().values) # revealed: tuple[Unknown, ...] + def valid[*Ts](x: tuple[*Ts]) -> tuple[*Ts]: return x ``` +A bare type variable tuple in a tuple annotation recovers as `*tuple[Unknown, ...]`, preserving any +fixed elements before and after it. Treating the bare pack as one `Unknown` element would +incorrectly impose a fixed length. + +```py +# error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +def mixed[*Ts](values: tuple[int, Ts, str]) -> None: + reveal_type(values) # revealed: tuple[int, *tuple[Unknown, ...], str] +``` + +### Missing unpack in a homogeneous tuple + +Adding an ellipsis does not make a bare type variable tuple a valid element type. The invalid +specialization recovers to `tuple[Unknown, ...]`. + +```py +# error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +def homogeneous[*Ts](values: tuple[Ts, ...]) -> None: + reveal_type(values) # revealed: tuple[Unknown, ...] +``` + +### Missing unpack inside another type + +Recovery only affects the bare pack's position in its tuple. An enclosing tuple or `type[]` +annotation keeps its structure. An ordinary tuple with an `Unknown` element keeps its fixed length. + +```py +from ty_extensions._internal import Unknown + +def nested[*Ts]( + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + values: tuple[tuple[Ts]], + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + cls: type[tuple[Ts]], + fixed: tuple[Unknown], +) -> None: + reveal_type(values) # revealed: tuple[tuple[Unknown, ...]] + reveal_type(cls) # revealed: type[tuple[Unknown, ...]] + reveal_type(fixed) # revealed: tuple[Unknown] +``` + +### Missing unpack in quoted annotations + +Quoting the whole tuple annotation or just the bare type variable tuple does not change the +diagnostic or the fallback type. + +```py +def quoted[*Ts]( + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + whole: "tuple[Ts]", + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + element: tuple["Ts"], +) -> None: + reveal_type(whole) # revealed: tuple[Unknown, ...] + reveal_type(element) # revealed: tuple[Unknown, ...] +``` + +### Other errors alongside a missing unpack + +Recovering from a missing unpack does not prevent us from reporting independent errors in the +remaining tuple elements. + +```py +# error: [invalid-type-form] "Bare TypeVarTuple `Ts`" +# error: [unresolved-reference] "Name `Missing` used when not defined" +def invalid_sibling[*Ts](values: tuple[Ts, Missing]) -> None: + reveal_type(values) # revealed: tuple[*tuple[Unknown, ...], Unknown] +``` + +### Missing unpack alongside other variadic elements + +A bare type variable tuple alongside a valid variadic unpack or another bare pack reports only the +missing-unpack errors, without a cascading multiple-unpack error. + +```py +def other_variadic[*Ts]( + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + before: tuple[Ts, *tuple[int, ...]], + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + after: tuple[*tuple[int, ...], Ts], + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + # error: [invalid-type-form] "Bare TypeVarTuple `Ts`" + repeated: tuple[Ts, Ts], +) -> None: ... +``` + ### Invalid unpack operand Only tuple types and type variable tuples can be unpacked in a type expression. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md index 05d49f09b2..1c9adcc2b1 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md @@ -81,6 +81,74 @@ def multiple_legacy_defaults[T = K, U = K](value: K) -> K: return value ``` +### Defaults containing bounded type variables + +A default can specialize a bounded generic with an earlier type variable whose upper bound is +compatible. Applying the default substitutes the actual type argument, without replacing it with its +upper bound. + +```py +class Box[T: int]: ... +class Holder[T: int, B = Box[T]]: ... + +reveal_type(Holder[bool]()) # revealed: Holder[bool, Box[bool]] +``` + +The same substitution applies to defaults on generic type aliases: + +```py +type Alias[T: int, B = Box[T]] = tuple[T, B] + +def alias(value: Alias[bool]): + reveal_type(value) # revealed: tuple[bool, Box[bool]] +``` + +The referenced type variable can also appear inside the nested generic's type argument: + +```py +class TupleBox[T: tuple[int, ...]]: ... +class NestedHolder[T: int, B = TupleBox[tuple[T, ...]]]: ... + +reveal_type(NestedHolder[bool]()) # revealed: NestedHolder[bool, TupleBox[tuple[bool, ...]]] +``` + +We reject a nested type argument whose upper bound is incompatible with the generic's bound: + +```py +# error: [invalid-type-arguments] +class Invalid[T: str, B = Box[T]]: ... +``` + +An upper bound of `int` does not make `list[T]` assignable to `list[int]`: `list` is invariant, and +`T` might be a proper subtype such as `bool`. + +```py +class ListBox[T: list[int]]: ... + +# error: [invalid-type-arguments] +class InvalidNested[T: int, B = ListBox[list[T]]]: ... +``` + +### Defaults containing constrained type variables + +A constrained type variable can appear inside a default when each of its constraints is allowed by +the nested generic. The selected type argument is preserved in the default. + +```py +class Box[T: (int, str)]: ... +class Holder[T: (int, str), B = Box[T]]: ... + +reveal_type(Holder[str]()) # revealed: Holder[str, Box[str]] +``` + +We reject a nested type argument if one of its constraints is incompatible with the generic's +constraints: + +```py +# error: [invalid-type-arguments] +class Invalid[T: (int, bytes), B = Box[T]]: ... +``` + ### Invalid defaults A TypeVar default must be compatible with its bound or constraints. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md index 5808327c72..52b56d605d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md @@ -437,6 +437,161 @@ static_assert(is_subtype_of(Covariant[int], Covariant[object])) static_assert(not is_subtype_of(Covariant[object], Covariant[int])) ``` +## Nested nonrecursive protocols + +Using a generic protocol inside another specialization of the same protocol is not a recursive +definition, including through a type alias. The nested `Reader` specializations do not prevent +structural variance inference for `Source`: its writable `_value` attribute makes it invariant. +Returning `Source[T]` from a nominal wrapper preserves that invariance. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Reader[T](Protocol): + def read(self) -> T: ... + +type NestedReader[T] = Reader[Reader[T]] + +class Source[T](Protocol): + _value: T + + def reader(self) -> NestedReader[T]: ... + +class Wrapper[T]: + def source(self) -> Source[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_subtype_of(Wrapper[object], Wrapper[int])) +``` + +## Recursive protocol variance + +A recursive protocol that only produces its type parameter is covariant. Returning that protocol +from a nominal class preserves covariance. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Reader[T](Protocol): + def read(self) -> T: ... + def next(self) -> "Reader[T]": ... + +class Source[T]: + def reader(self) -> Reader[T]: + raise NotImplementedError + +static_assert(is_subtype_of(Source[int], Source[object])) +static_assert(not is_subtype_of(Source[object], Source[int])) +``` + +A recursive protocol that consumes its type parameter is contravariant, even when it also returns +another instance of itself. + +```py +class Writer[T](Protocol): + def write(self, value: T) -> None: ... + def next(self) -> "Writer[T]": ... + +class Sink[T]: + def writer(self) -> Writer[T]: + raise NotImplementedError + +static_assert(is_subtype_of(Sink[object], Sink[int])) +static_assert(not is_subtype_of(Sink[int], Sink[object])) +``` + +Writable attributes make recursive protocols invariant, including underscore-prefixed attributes. + +```py +class Writable[T](Protocol): + _value: T + + def next(self) -> "Writable[T]": ... + +class Wrapper[T]: + def value(self) -> Writable[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_subtype_of(Wrapper[object], Wrapper[int])) +``` + +## Recursive protocol variance with annotated receivers + +An explicit receiver annotation does not make a bound method consume its type parameter. `Reader` +remains covariant when its interface also recurses through the return type of `next`, and returning +that protocol from `Source` preserves covariance. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Reader[T](Protocol): + def read(self: "Reader[T]") -> T: ... + def next(self) -> "Reader[T]": ... + +class Source[T]: + def reader(self) -> Reader[T]: + raise NotImplementedError + +static_assert(is_subtype_of(Source[int], Source[object])) +static_assert(not is_subtype_of(Source[object], Source[int])) +``` + +## Expanding recursive protocol variance + +Variance inference terminates when a recursive reference changes the specialization. The mutable +list in `next` makes `Node` invariant, even though `read` produces `T` directly. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Node[T](Protocol): + def read(self) -> T: ... + def next(self) -> "Node[list[T]]": ... + +class Wrapper[T]: + def node(self) -> Node[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_subtype_of(Wrapper[object], Wrapper[int])) +``` + +## Mutually recursive protocol variance + +A writable attribute constrains every protocol in a recursive cycle. Here, `Left` is invariant +because it returns a `Right` whose `_value` attribute can be mutated. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Left[T](Protocol): + def right(self) -> "Right[T]": ... + +class Right[T](Protocol): + _value: T + + def left(self) -> Left[T]: ... + +class Wrapper[T]: + def left(self) -> Left[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_subtype_of(Wrapper[object], Wrapper[int])) +``` + ## Mutual Recursion This example due to Martin Huschenbett's PyCon 2025 talk, @@ -531,6 +686,87 @@ static_assert(not is_subtype_of(C[A], C[B])) One might think that occurrences in the types of normal attributes are covariant, but they are mutable, and thus the occurrences are invariant. +### Slotted Attributes + +Slots store mutable instance attributes, so a slotted attribute also makes its type parameter +invariant. + +```py +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class A: ... +class B(A): ... + +class Slotted[T]: + __slots__ = ("value",) + value: T + +static_assert(not is_subtype_of(Slotted[B], Slotted[A])) +static_assert(not is_subtype_of(Slotted[A], Slotted[B])) +``` + +A slot descriptor also carries its mutable value type when stored directly on another generic class. +Its owner is therefore invariant even though the descriptor is assigned as a class member. + +```py +class DescriptorOwner[T]: + descriptor = Slotted[T].value + +static_assert(not is_subtype_of(DescriptorOwner[B], DescriptorOwner[A])) +static_assert(not is_subtype_of(DescriptorOwner[A], DescriptorOwner[B])) +``` + +### Mutable protocol attributes + +Underscore-prefixed protocol attributes remain writable through their structural interface, so their +inferred type parameters are invariant. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class WritableProtocol[T](Protocol): + _value: T + +static_assert(not is_subtype_of(WritableProtocol[int], WritableProtocol[object])) +static_assert(not is_assignable_to(WritableProtocol[int], WritableProtocol[object])) + +def overwrite(value: WritableProtocol[object]) -> None: + value._value = object() + +def unsound(value: WritableProtocol[int]) -> None: + overwrite(value) # error: [invalid-argument-type] +``` + +### Mutable protocol attributes with unrelated protocol members + +An unrelated protocol in a member type does not change the invariance of a writable attribute. A +class that returns this protocol is also invariant, preventing callers from mutating `_value` +through a wider specialization. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class Marker(Protocol): + def ready(self) -> bool: ... + +class WritableProtocol[T](Protocol): + _value: T + + def marker(self) -> Marker: ... + +class Wrapper[T]: + def value(self) -> WritableProtocol[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) +static_assert(not is_assignable_to(Wrapper[int], Wrapper[object])) +``` + ### Immutable Attributes Immutable attributes can't be written to, and thus constrain the typevar to covariance, not @@ -553,6 +789,44 @@ static_assert(is_subtype_of(C[B], C[A])) static_assert(not is_subtype_of(C[A], C[B])) ``` +#### Final attributes in stubs + +Stub attributes declared as `Final` are read-only, whether their declarations omit an initializer or +use an ellipsis placeholder. A type parameter used only in such an attribute is covariant, while one +used in an ordinary writable attribute is invariant. + +`box.pyi`: + +```pyi +from typing import Final + +class Box[T]: + value: Final[T] + +class BoxWithPlaceholder[T]: + value: Final[T] = ... + +class MutableBox[T]: + value: T +``` + +`main.py`: + +```py +from box import Box, BoxWithPlaceholder, MutableBox +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +static_assert(is_subtype_of(Box[int], Box[object])) +static_assert(not is_subtype_of(Box[object], Box[int])) + +static_assert(is_subtype_of(BoxWithPlaceholder[int], BoxWithPlaceholder[object])) +static_assert(not is_subtype_of(BoxWithPlaceholder[object], BoxWithPlaceholder[int])) + +static_assert(not is_subtype_of(MutableBox[int], MutableBox[object])) +static_assert(not is_subtype_of(MutableBox[object], MutableBox[int])) +``` + #### Underscore-prefixed attributes Underscore-prefixed instance attributes are considered private, and thus are assumed not externally @@ -897,6 +1171,54 @@ static_assert(not is_subtype_of(Contravariant[B], Contravariant[A])) static_assert(is_subtype_of(Contravariant[A], Contravariant[B])) ``` +#### A solve still reads the type argument back + +Bivariance says that no comparison against the parameter can fail. It does not say that the class +was given no argument — a private member holds one, and inference reads it back, however deeply the +parameter it is being matched against is nested. + +```py +from typing import Iterable + +class C[T]: + _x: T + +def whole[U](c: C[U]) -> U: + raise NotImplementedError + +def element[U](c: C[Iterable[U]]) -> U: + raise NotImplementedError + +def ints() -> C[Iterable[int]]: + raise NotImplementedError + +reveal_type(whole(ints())) # revealed: Iterable[int] +reveal_type(element(ints())) # revealed: int +``` + +Reading the argument does not make the comparison itself any stricter: an argument the parameter is +bivariant in is still accepted for any other. + +```py +def strings() -> C[Iterable[str]]: + raise NotImplementedError + +c: C[Iterable[int]] = strings() +``` + +That has to hold for a solve as well, which is where the reading actually happens. A variable +carrying a bound is where it could go wrong: recovering `str` for a `U: int` and then measuring it +against that bound would reject a call the assignment above says is fine. So a bounded or +constrained variable keeps the bivariant reading, and goes unsolved rather than wrong. + +```py +def bounded[U: int](c: C[U]) -> U: + raise NotImplementedError + +def f(c: C[str]): + reveal_type(bounded(c)) # revealed: Never +``` + #### Explicit variance still wins ```py @@ -973,6 +1295,35 @@ static_assert(not is_subtype_of(D[B], D[A])) static_assert(not is_subtype_of(D[A], D[B])) ``` +### Property subclasses + +A property subclass can carry mutable state in its own type parameters. That state makes the owning +class invariant even when the property's getter does not mention the type parameter. + +```py +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +def get_value(obj: object) -> int: + return 1 + +class CustomProperty[T](property): + metadata: T + +class Owner[T]: + value = CustomProperty[T](get_value) + +static_assert(not is_subtype_of(Owner[str], Owner[object])) +static_assert(not is_subtype_of(Owner[object], Owner[str])) + +def overwrite(owner: Owner[object]) -> None: + type(owner).value.metadata = object() + +def misuse(owner: Owner[str]) -> str: + overwrite(owner) # error: [invalid-argument-type] + return type(owner).value.metadata +``` + ### Implicit Attributes Implicit attributes work like normal ones @@ -1262,6 +1613,254 @@ static_assert(is_assignable_to(C[B], C[A])) static_assert(not is_assignable_to(C[A], C[B])) ``` +## Typed dictionaries + +### Mutable items + +A mutable `TypedDict` item can be read and written, so returning a `TypedDict` with an item of type +`T` makes the enclosing class invariant in `T`. + +```py +from typing import TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Item[T](TypedDict): + value: T + +class Producer[T]: + def get(self) -> Item[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Producer[bool], Producer[int])) +static_assert(not is_subtype_of(Producer[int], Producer[bool])) +``` + +Optional items are still mutable, including items whose names start with an underscore. + +```py +from typing import NotRequired + +class OptionalItem[T](TypedDict): + _value: NotRequired[T] + +class OptionalProducer[T]: + def get(self) -> OptionalItem[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(OptionalProducer[bool], OptionalProducer[int])) +static_assert(not is_subtype_of(OptionalProducer[int], OptionalProducer[bool])) +``` + +### Read-only items + +A read-only item is covariant in its value type. Returning this `TypedDict` makes a class covariant, +while accepting it as a method argument makes a class contravariant. An unrelated mutable item does +not affect the variance of `T`. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Item[T](TypedDict): + value: ReadOnly[T] + tag: str + +class Producer[T]: + def get(self) -> Item[T]: + raise NotImplementedError + +class Consumer[T]: + def put(self, item: Item[T]) -> None: ... + +static_assert(is_subtype_of(Producer[bool], Producer[int])) +static_assert(not is_subtype_of(Producer[int], Producer[bool])) +static_assert(is_subtype_of(Consumer[int], Consumer[bool])) +static_assert(not is_subtype_of(Consumer[bool], Consumer[int])) +``` + +### Nested item types + +Read-only items preserve the variance of their value types. A callable's argument and return types +contribute opposite variances; using the same type variable in both positions makes it invariant. + +```py +from typing import Callable +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Callback[P, R](TypedDict): + callback: ReadOnly[Callable[[P], R]] + +class Consumer[T]: + def get(self) -> Callback[T, None]: + raise NotImplementedError + +class Transformer[T]: + def get(self) -> Callback[T, T]: + raise NotImplementedError + +static_assert(is_subtype_of(Consumer[int], Consumer[bool])) +static_assert(not is_subtype_of(Consumer[bool], Consumer[int])) +static_assert(not is_subtype_of(Transformer[bool], Transformer[int])) +static_assert(not is_subtype_of(Transformer[int], Transformer[bool])) +``` + +### Inherited items + +Inherited items contribute variance after applying the base class's specialization. Although the +item itself is read-only, the list it contains is mutable, making the enclosing class invariant. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Base[T](TypedDict): + value: ReadOnly[T] + +class Derived[T](Base[list[T]]): ... + +class Producer[T]: + def get(self) -> Derived[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Producer[bool], Producer[int])) +static_assert(not is_subtype_of(Producer[int], Producer[bool])) +``` + +### Legacy type variables + +When a `TypedDict` appears in another generic class, its legacy type variables contribute their +declared variance to the enclosing class's inferred variance, just as they do for protocols. An +invariant legacy type variable makes the enclosing consumer invariant even when the item is +read-only; a covariant legacy type variable makes the consumer contravariant even when the item is +mutable. + +```py +from typing import Generic, TypeVar +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +T_co = TypeVar("T_co", covariant=True) +T = TypeVar("T") + +class InvariantItem(TypedDict, Generic[T]): + # TODO: The variance rules specified for Protocol would suggest an error here: T is + # declared invariant but used covariantly. The conformance suite does not specify this + # check for TypedDicts, and other type checkers do not implement it. + value: ReadOnly[T] + +class CovariantItem(TypedDict, Generic[T_co]): + # TODO: The variance rules specified for Protocol would suggest an error here: T_co is + # declared covariant but used invariantly. The conformance suite does not specify this + # check for TypedDicts, and other type checkers do not implement it. + value: T_co + +class InvariantConsumer[T]: + def put(self, item: InvariantItem[T]) -> None: ... + +class ContravariantConsumer[T]: + def put(self, item: CovariantItem[T]) -> None: ... + +static_assert(not is_subtype_of(InvariantConsumer[bool], InvariantConsumer[int])) +static_assert(not is_subtype_of(InvariantConsumer[int], InvariantConsumer[bool])) +static_assert(is_subtype_of(ContravariantConsumer[int], ContravariantConsumer[bool])) +static_assert(not is_subtype_of(ContravariantConsumer[bool], ContravariantConsumer[int])) +``` + +### Extra items + +Extra items contribute variance just like named items, including when inherited. Mutable extra items +are invariant in their value type, while read-only extra items are covariant. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class MutableExtras[T](TypedDict, extra_items=T): ... +class ReadOnlyExtras[T](TypedDict, extra_items=ReadOnly[T]): ... +class InheritedExtras[T](ReadOnlyExtras[T]): ... + +class Producer[T]: + def get(self) -> MutableExtras[T]: + raise NotImplementedError + +class Consumer[T]: + def put(self, item: InheritedExtras[T]) -> None: ... + +static_assert(not is_subtype_of(Producer[bool], Producer[int])) +static_assert(not is_subtype_of(Producer[int], Producer[bool])) +static_assert(is_subtype_of(Consumer[int], Consumer[bool])) +static_assert(not is_subtype_of(Consumer[bool], Consumer[int])) +``` + +### Functional syntax + +Items defined with functional syntax can refer to an enclosing class's type parameter. The item +schema determines variance, including when it contains a recursive reference. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Consumer[T]: + Item = TypedDict("Item", {"child": "ReadOnly[Item | None]", "value": ReadOnly[T]}) + + def put(self, item: Item) -> None: ... + +static_assert(is_subtype_of(Consumer[int], Consumer[bool])) +static_assert(not is_subtype_of(Consumer[bool], Consumer[int])) +``` + +### Recursive items + +A recursive read-only item preserves covariance when every occurrence of the type variable is +covariant. Accepting the recursive `TypedDict` as a method argument makes the class contravariant. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Node[T](TypedDict): + child: ReadOnly["Node[T] | None"] + value: ReadOnly[T] + +class Consumer[T]: + def put(self, item: Node[T]) -> None: ... + +static_assert(is_subtype_of(Consumer[int], Consumer[bool])) +static_assert(not is_subtype_of(Consumer[bool], Consumer[int])) +``` + +### Expanding recursive items + +Variance inference terminates even when a recursive item wraps the type argument in another type. +Here the nested `list[T]` makes `T` invariant despite both items being read-only. + +```py +from typing_extensions import ReadOnly, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Node[T](TypedDict): + child: ReadOnly["Node[list[T]] | None"] + value: ReadOnly[T] + +class Producer[T]: + def get(self) -> Node[T]: + raise NotImplementedError + +static_assert(not is_subtype_of(Producer[bool], Producer[int])) +static_assert(not is_subtype_of(Producer[int], Producer[bool])) +``` + ## Type aliases The variance of the type alias matches the variance of the value type (RHS type). @@ -1552,19 +2151,19 @@ a method that consumes an `out` parameter would let a caller pass a supertype of holds: ```by -# snapshot: invalid-generic-class class BadProducer[out T]: + # snapshot: invalid-generic-class def set(self, value: T) -> None: pass ``` ```snapshot -error[invalid-generic-class]: Variance of type variable `T` is incompatible with its usage in `BadProducer` - --> src/mdtest_snippet.by:2:19 +error[invalid-generic-class]: Variance of type variable `T` is incompatible with method `set` + --> src/mdtest_snippet.by:3:26 | -2 | class BadProducer[out T]: - | ^^^^^ -help: Type variable `T` is declared as covariant, but `BadProducer` uses it contravariantly +3 | def set(self, value: T) -> None: + | ^ +info: Type variable `T` is declared as covariant, but this method requires it to be contravariant ``` #### `in` in a producing position @@ -1572,8 +2171,8 @@ help: Type variable `T` is declared as covariant, but `BadProducer` uses it cont a method that produces an `in` parameter would hand back a subtype of what the caller wrote: ```by -# error: [invalid-generic-class] "Variance of type variable `T` is incompatible with its usage in `BadConsumer`" class BadConsumer[in T]: + # error: [invalid-generic-class] "Variance of type variable `T` is incompatible with method `get`" def get(self) -> T: raise ValueError ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md index 927cd71d32..854d4d8ffa 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md @@ -28,6 +28,72 @@ def f() -> None: x: T ``` +## Constructor calls require bound type variables + +A type argument in a constructor call must be bound in an enclosing generic scope. Assigning the +result to a variable does not introduce a generic context, and nested type arguments follow the same +rule. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +# error: [unbound-type-variable] +list[T]() +# error: [unbound-type-variable] +items = list[T]() +# error: [unbound-type-variable] +list[list[T]]() + +class Box(Generic[T]): ... + +# error: [unbound-type-variable] +Box[T]() +``` + +Generic functions and classes can use their own type variables when calling constructors. + +```py +def make(value: T) -> list[T]: + result = list[T]([value]) + reveal_type(result) # revealed: list[T@make] + return result + +class Factory(Generic[T]): + def make(self, value: T) -> list[T]: + return list[T]([value]) + +def modern[T](value: T) -> list[T]: + return list[T]([value]) +``` + +An alias assignment or a class base can introduce a generic context. Calling a generic alias without +explicit type arguments also remains valid. + +```py +Alias = list[T] +reveal_type(Alias[int]()) # revealed: list[int] +Alias() + +class Derived(list[T]): ... +``` + +## Constructor calls in stubs + +Constructor calls in stubs follow the same scoping rules as calls in Python source files. + +```pyi +from typing import TypeVar + +T = TypeVar("T") + +# error: [unbound-type-variable] +list[T]() +# error: [unbound-type-variable] +items = list[T]() +``` + ## Legacy typevar used multiple times > A type variable used in a generic function could be inferred to represent different types in the @@ -87,6 +153,88 @@ c.m2(1) c.m2("string") ``` +## Passing bounded class typevars to broader parameters + +A class typevar is fixed by the receiver. Passing it to an `object` parameter must not infer a new +specialization of the class typevar from that parameter's annotation. + +```py +class G[T: int]: + def takes_object(self, value: object) -> None: ... + def echo(self, value: T) -> T: + return value + + def caller(self, value: T, other: "G[int]") -> None: + self.takes_object(value) + other.takes_object(value) + reveal_type(self.echo(value)) # revealed: T@G + # error: [invalid-argument-type] "Expected `int`" + other.echo("bad") + + def explicit_receiver(self: "G[T]", value: T) -> None: + self.takes_object(value) +``` + +The same applies to classmethods and to membership tests, which call `__contains__`. + +```py +class Container[T: int]: + @classmethod + def takes_object(cls, value: object) -> None: ... + def __contains__(self, value: object) -> bool: + return False + + def caller(self, value: T) -> None: + self.takes_object(value) + self.__contains__(value) + reveal_type(value in self) # revealed: bool +``` + +## Passing class typevars to a superclass of their bound + +The parameter need not be `object`: any superclass of the typevar's bound accepts its values. + +```py +class Base: ... +class Child(Base): ... + +class G[T: Child]: + def takes_base(self, value: Base) -> None: ... + def caller(self, value: T) -> None: + self.takes_base(value) +``` + +## Passing constrained class typevars to broader parameters + +Every allowed specialization is assignable to `object`, without selecting one of the constraints +again at the method call. + +```py +class G[T: (int, str)]: + def takes_object(self, value: object) -> None: ... + def echo(self, value: T) -> T: + return value + + def caller(self, value: T) -> None: + self.takes_object(value) + reveal_type(self.echo(value)) # revealed: T@G +``` + +## Passing legacy class typevars to broader parameters + +Legacy class typevars follow the same rule. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T", bound=int) + +class G(Generic[T]): + def takes_object(self, value: object) -> None: ... + def caller(self, value: T) -> None: + self.takes_object(value) +``` + ## Functions on generic classes are descriptors This repeats the tests in the [Functions as descriptors](./call/methods.md) test suite, but on a @@ -116,7 +264,7 @@ reveal_type(bound_method.__func__) # revealed: def f(self, x: int) -> str reveal_type(C[int]().f(1)) # revealed: str reveal_type(bound_method(1)) # revealed: str -# error: [invalid-argument-type] "Argument to function `C.f` is incorrect: Argument type `Literal[1]` does not satisfy upper bound `C[T@C]` of type variable `Self`" +# error: [invalid-argument-type] "Argument to function `C.f` is incorrect: Argument type `Literal[1]` does not satisfy upper bound `C[int]` of type variable `Self`" C[int].f(1) # error: [missing-argument] reveal_type(C[int].f(C[int](), 1)) # revealed: str diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index 9ad4e9c0c5..4e09593bed 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -7,8 +7,14 @@ This test suite explores the interplay between generics and set theoretic gradua python-version = "3.14" ``` +## Derivations and general results + +This section concentrates on deriving the main results while the next section covers some more edge +cases. + ```pyi -from typing import Any +from typing import Any, Coroutine, Sequence +from types import CoroutineType from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to, is_subtype_of ``` @@ -149,7 +155,7 @@ This result highlights a tension between a naive "replace `Any` with a more prec understanding of materialization, and the "interval" representation of gradual types. The type `Co[P] | Co[Any]` clearly has something like `Co[P] | Co[Q]` as a possible materialization. However, this is much less clear for `Co[P | Any]`. Following a strict "gradual types are intervals" -approach, `Co[P | Any]` also needs to be able to materialize to to `Co[P] | Co[Q]`, though. It is a +approach, `Co[P | Any]` also needs to be able to materialize to `Co[P] | Co[Q]`, though. It is a supertype of the bottom materialization `Co[P | Never] = Co[P]`, and a subtype of the top materialization `Co[P | object] = Co[object]`. See [this discussion](https://github.com/astral-sh/ruff/pull/26054/changes#r3429787797) for more @@ -193,12 +199,19 @@ Contra[P] & Contra[Any] = Contra[P | Any] (5b) We can encode all of these in ty assertions: ```pyi -# TODO: all of these should pass +# TODO: both should pass static_assert(is_equivalent_to(Co[P] | Co[Any], Co[P | Any])) # error: [static-assert-error] -static_assert(is_equivalent_to(Co[P] & Co[Any], Co[P & Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Co[Any] | Co[P], Co[P | Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Co[P] & Co[Any], Co[P & Any])) +static_assert(is_equivalent_to(Co[Any] & Co[P], Co[P & Any])) + +# TODO: both should pass static_assert(is_equivalent_to(Contra[P] | Contra[Any], Contra[P & Any])) # error: [static-assert-error] -static_assert(is_equivalent_to(Contra[P] & Contra[Any], Contra[P | Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Contra[Any] | Contra[P], Contra[P & Any])) # error: [static-assert-error] + +static_assert(is_equivalent_to(Contra[P] & Contra[Any], Contra[P | Any])) +static_assert(is_equivalent_to(Contra[Any] & Contra[P], Contra[P | Any])) ``` What about invariance? We can naively write `Invariant[Any]` in its interval representation: @@ -242,7 +255,7 @@ If we use the interpretation where `Bottom[Invariant[Any]]` is a special bottom `Invariant`. And so we get: ```ignore -Invariant[P] & Invariant[Any] = Invariant[P] +Invariant[P] & Invariant[Any] = Invariant[P] (6) ``` One seemingly problematic observation here is the following. If we compute the bottom @@ -287,3 +300,583 @@ static_assert(is_equivalent_to(Invariant[P] & Invariant[Any], Invariant[P])) static_assert(not is_equivalent_to(Invariant[P] | Invariant[Any], Invariant[P])) ``` + +In strict-mode `isinstance` narrowing, we intersect with the top-materialization of generic classes. +Suppose `Sub[T]` is a subtype of `Base[T]`. There are two directions to consider, depending on which +class is top-materialized. The first simplifies immediately: + +```ignore +Sub[P] & Top[Base[Any]] = Sub[P] +``` + +This relation holds regardless of the variance of `Base` and `Sub`: + +```pyi +from ty_extensions import Top + +class CoBase[T]: + def get(self) -> T: ... + +class CoSub[T](CoBase[T]): ... + +class ContraBase[T]: + def push(self, x: T) -> None: ... + +class ContraSub[T](ContraBase[T]): ... +class InvariantBase[T](CoBase[T], ContraBase[T]): ... +class InvariantSub[T](InvariantBase[T]): ... + +static_assert(is_equivalent_to(CoSub[P] & Top[CoBase[Any]], CoSub[P])) +static_assert(is_equivalent_to(ContraSub[P] & Top[ContraBase[Any]], ContraSub[P])) +static_assert(is_equivalent_to(InvariantSub[P] & Top[InvariantBase[Any]], InvariantSub[P])) +``` + +The other direction, `Base[P] & Top[Sub[Any]]`, is more interesting. It can only be simplified under +the additional assumption that `Base` (and `Sub`) are nominal types. There are five cases to +consider, depending on the variance of `Base` and `Sub` (variance can only be restricted further in +subtypes): + +- `Base` is covariant and `Sub` is covariant +- `Base` is covariant and `Sub` is invariant +- `Base` is contravariant and `Sub` is contravariant +- `Base` is contravariant and `Sub` is invariant +- `Base` is invariant and `Sub` is invariant + +We look at the covariant `Base` case first (first two items): + +```ignore +class CoBase[T]: ... # covariant +class Sub[T](CoBase[T]): ... # covariant or invariant +``` + +Since `CoBase` is a nominal type, inhabitants of the intersection `CoBase[P] & Top[Sub[Any]]` need +to have a consistent specialization of `CoBase` in their MRO. Inhabitants of that intersection are +therefore instances of `Sub[P']` which cannot further (multiply) inherit from a `CoBase` +specialization that is not `CoBase[P']`. Since these inhabitants further need to be a subtype of +`CoBase[P]`, covariance requires `P' <: P`. Therefore the intersection consists of the possible +`Sub` specializations whose type argument is upper-bounded by `P`. This type can be succinctly +expressed as `Top[Sub[P & Any]]`: + +```ignore +CoBase[P] & Top[Sub[Any]] = Top[Sub[P & Any]] (7a) +``` + +If `Sub` is covariant, this relation further simplifies: + +```ignore +CoBase[P] & Top[CoSub[Any]] = CoSub[P & object] = CoSub[P] (7b) +``` + +Two practical examples of these relations are: + +```ignore +Sequence[int] & Top[tuple[Any, ...]] = tuple[int, ...] (covariant base, covariant subtype) +Sequence[int] & Top[list[Any]] = Top[list[int & Any]] (covariant base, invariant subtype) +``` + +The latter example is illustrative: When you have a `Sequence[int]` and narrow using +`isinstance(.., list)`, you end up with the type `Top[list[int & Any]]`. When iterating over that +type, we get elements of `Top[int & Any] = int`, which is what we expect by starting from +`Sequence[int]`. However, if we try to `append` an element to that list, since the element type +appears in contravariant position, we get `Bottom[int & Any] = Never`. This means that we cannot +append any elements to that list. This is expected, since we could be dealing with a `list[int]` or +a `list[bool]`, or a `list[Literal[False]]`, and so on. So whatever we want to append needs to be +compatible with all of those types, which is impossible. + +We can encode these relations in ty assertions: + +```pyi +class InvariantSubOfCoBase[T](CoBase[T]): + def push(self, x: T) -> None: ... + +static_assert(is_equivalent_to(CoBase[P] & Top[CoSub[Any]], CoSub[P])) +static_assert(is_equivalent_to(Top[CoSub[Any]] & CoBase[P], CoSub[P])) + +static_assert(is_equivalent_to(CoBase[P] & Top[InvariantSubOfCoBase[Any]], Top[InvariantSubOfCoBase[P & Any]])) +static_assert(is_equivalent_to(Top[InvariantSubOfCoBase[Any]] & CoBase[P], Top[InvariantSubOfCoBase[P & Any]])) + +static_assert(is_equivalent_to(Sequence[int] & Top[tuple[Any, ...]], tuple[int, ...])) +static_assert(is_equivalent_to(Sequence[int] & Top[list[Any]], Top[list[int & Any]])) +``` + +Next, we look at the contravariant `Base` case (items 3 and 4). The reasoning is similar: subtypes +of `ContraBase[P] & Top[Sub[Any]]` must be of the form `Sub[P']`, but now, `P'` needs to be a +*supertype* of `P`. The intersection therefore consists of all `Sub` specializations whose type +argument is lower-bounded by `P`: + +```ignore +ContraBase[P] & Top[Sub[Any]] = Top[Sub[P | Any]] (8a) +``` + +Again, this makes intuitive sense: If we have a `ContraBase[P]` and narrow using +`isinstance(.., Sub)`, we can still `push` any elements of type `Bottom[P | Any] = P`, but we can +only `get` out elements of type `Top[P | Any] = object`, which is what we would have expected from +the `Top[Sub[Any]]` type that contributes the `get` method. + +If `Sub` is contravariant, (8a) further simplifies to: + +```ignore +ContraBase[P] & Top[ContraSub[Any]] = ContraSub[P | Never] = ContraSub[P] (8b) +``` + +An example of this relation would be: + +```ignore +Coroutine[str, int, bytes] & Top[CoroutineType[str, Any, bytes]] = CoroutineType[str, int, bytes] +``` + +where both `Coroutine` and `CoroutineType` are contravariant in their "Send" type parameter. + +Again, we can encode the results in ty assertions: + +```pyi +class InvariantSubOfContraBase[T](ContraBase[T]): + def get(self) -> T: ... + +static_assert(is_equivalent_to(ContraBase[P] & Top[ContraSub[Any]], ContraSub[P])) +static_assert(is_equivalent_to(Top[ContraSub[Any]] & ContraBase[P], ContraSub[P])) + +static_assert(is_equivalent_to(ContraBase[P] & Top[InvariantSubOfContraBase[Any]], Top[InvariantSubOfContraBase[P | Any]])) +static_assert(is_equivalent_to(Top[InvariantSubOfContraBase[Any]] & ContraBase[P], Top[InvariantSubOfContraBase[P | Any]])) + +static_assert(is_equivalent_to(Coroutine[str, int, bytes] & Top[CoroutineType[str, Any, bytes]], CoroutineType[str, int, bytes])) +``` + +Finally, we look at the invariant `Base` case (item 5). Here, we need `P'` to be equal to `P`, and +so we immediately get: + +```ignore +InvariantBase[P] & Top[Sub[Any]] = Sub[P] (9) +``` + +In ty assertions: + +```pyi +class InvariantSubOfInvariantBase[T](InvariantBase[T]): ... + +static_assert(is_equivalent_to(InvariantBase[P] & Top[InvariantSubOfInvariantBase[Any]], InvariantSubOfInvariantBase[P])) +static_assert(is_equivalent_to(Top[InvariantSubOfInvariantBase[Any]] & InvariantBase[P], InvariantSubOfInvariantBase[P])) +``` + +In summary, we have: + +```ignore +Base[P] & Top[Sub[Any]] = Sub[P] (Base and Sub have the same variance) +Base[P] & Top[Sub[Any]] = Top[Sub[P & Any]] (Base: covariant, Sub: invariant) +Base[P] & Top[Sub[Any]] = Top[Sub[P | Any]] (Base: contravariant, Sub: invariant) +``` + +Above, we made the assumption that `Base` and `Sub` are nominal types. In general, these results do +not hold for structural types. Consider for example: + +```pyi +from typing import Protocol + +class BaseProtocol[T](Protocol): + def get1(self) -> T: ... + +class SubProtocol[T](BaseProtocol[T], Protocol): + def get2(self) -> T: ... +``` + +The intersection `BaseProtocol[P] & SubProtocol[object]` is not equivalent to `SubProtocol[P]`. +Consider the following `CounterExample` class which is a subtype of the intersection, but not a +subtype of `SubProtocol[P]`. + +```pyi +class CounterExample: + def get1(self) -> P: ... + def get2(self) -> object: ... + +static_assert(is_subtype_of(CounterExample, BaseProtocol[P] & SubProtocol[object])) +static_assert(not is_subtype_of(CounterExample, SubProtocol[P])) +``` + +This proves that `BaseProtocol[P] & SubProtocol[object]` is not equivalent to `SubProtocol[P]`: + +```pyi +static_assert(not is_equivalent_to(BaseProtocol[P] & SubProtocol[object], SubProtocol[P])) +``` + +However, there are cases where this simplification would be helpful. Consider narrowing something of +type `Iterable[P]` via `isinstance(.., frozenset)`. It would be useful to get a narrowed type with a +(read) element type `P`. + +```pyi +from typing import Iterable + +def f(xs: Iterable[P]) -> None: + if isinstance(xs, frozenset): + for x in xs: + x # we would like this to be of type `P` +``` + +Let's look at the (simplified) definitions of `Iterable` and `frozenset`: + +```ignore +class Iterable[T_co](Protocol): + def __iter__(self) -> Iterator[T_co]: ... + +class frozenset[T_co](AbstractSet[T_co]): + def __iter__(self) -> Iterator[T_co]: ... +``` + +Just like in the counterexample above, we could construct a class that is a (nominal) subtype of +`frozenset[object]` and a (structural) subtype of `Iterable[P]` (and therefore a subtype of the +intersection `Iterable[P] & frozenset[object]`), but not a subtype of `frozenset[P]`: + +```pyi +from typing import Iterable, Iterator + +class Weird(frozenset[object]): + def __iter__(self) -> Iterator[P]: ... +``` + +The return type of `Iterator[P]` is compatible with that of `frozenset[object]` due to covariance. +However, the returned iterator would only yield values of type `P` while the underlying `frozenset` +may contain other values. Here, we assume that subclasses of built-in containers preserve their +usual iteration behavior instead: iteration over a `frozenset` exposes its stored elements. Under +this assumption, knowing the iteration element type also constrains the stored element type. This +behavioral assumption is not enforced by the type system, so the simplification is unsound: + +```pyi +static_assert(is_equivalent_to(Iterable[P] & tuple[object, ...], tuple[P, ...])) +static_assert(is_equivalent_to(Iterable[P] & frozenset[object], frozenset[P])) +static_assert(is_equivalent_to(Iterable[P] & Top[list[Any]], Top[list[P & Any]])) +static_assert(is_equivalent_to(Iterable[P] & Top[set[Any]], Top[set[P & Any]])) +``` + +A similar justification applies to the following case. `Iterator`s are supposed to follow a +[behavioral contract](https://docs.python.org/3/library/stdtypes.html#iterator-types): an iterator's +`__iter__()` returns the iterator itself. Iteration and direct calls to `next()` therefore yield the +same values. This supports simplifying `Iterable[P] & Iterator[object]` to `Iterator[P]`: + +```pyi +static_assert(is_equivalent_to(Iterable[P] & Iterator[object], Iterator[P])) +``` + +## Edge cases + +### Multi-parameter and mixed-variance generics + +The same-class intersection relations (4b), (5b), and (6) can apply simultaneously to the covariant, +contravariant, and invariant type parameters of a multi-parameter generic class: + +```pyi +from typing import Any, Generic, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to + +class P: ... +class Q: ... +class R: ... + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) +T_invariant = TypeVar("T_invariant") + +class Mixed(Generic[T_co, T_contra, T_invariant]): ... + +static_assert(is_equivalent_to(Mixed[P, Q, R] & Mixed[Any, Any, Any], Mixed[P & Any, Q | Any, R])) +``` + +### Unrelated subclass type parameters + +Intersecting with a specialized base preserves gradual subclass arguments that do not specialize +that base. Here, `U` is covariant, `V` is contravariant, and `W` is invariant: + +```pyi +from typing import Any +from ty_extensions import Top, static_assert +from ty_extensions._internal import is_equivalent_to + +class Base[T]: + def get(self) -> T: ... + +class Child[T, U, V, W](Base[T]): + def extra(self) -> U: ... + def consume(self, value: V) -> None: ... + item: W + +static_assert(is_equivalent_to(Base[int] & Child[object, Any, Any, Any], Child[int, Any, Any, Any])) +``` + +If the subclass is already top-materialized, the result retains that materialization: + +```pyi +static_assert(is_equivalent_to(Base[int] & Top[Child[Any, Any, Any, Any]], Top[Child[int, Any, Any, Any]])) +``` + +### Tuples + +Tuple types are covariant in every type parameter, so the results derived for `Co[T]` above apply to +`tuple` at every position: + +```pyi +from typing import Any, Sequence, reveal_type +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to, is_subtype_of + +class P: ... +class Q: ... + +static_assert(is_equivalent_to(tuple[P] & tuple[Any], tuple[P & Any])) +static_assert(is_equivalent_to(tuple[Any] & tuple[P], tuple[P & Any])) + +static_assert(is_equivalent_to(tuple[P, ...] & tuple[Any, ...], tuple[P & Any, ...])) + +static_assert(is_equivalent_to(tuple[P, Q] & tuple[Any, Q], tuple[P & Any, Q])) +static_assert(is_equivalent_to(tuple[Any, Q] & tuple[P, Q], tuple[P & Any, Q])) + +static_assert(is_equivalent_to(tuple[P, Q] & tuple[P, Any], tuple[P, Q & Any])) +static_assert(is_equivalent_to(tuple[P, Any] & tuple[P, Q], tuple[P, Q & Any])) + +static_assert(is_equivalent_to(tuple[P, Q] & tuple[Any, Any], tuple[P & Any, Q & Any])) +static_assert(is_equivalent_to(tuple[Any, Any] & tuple[P, Q], tuple[P & Any, Q & Any])) +``` + +Intersecting a `Sequence` with a variable-length tuple retains its required prefix or suffix. These +intersections currently remain unsimplified. The variable-length elements could be narrowed to +`int`, but the required `bool` element must be preserved: + +```pyi +type Prefix = tuple[bool, *tuple[object, ...]] +type Suffix = tuple[*tuple[object, ...], bool] + +def _(prefix: Sequence[int] & Prefix, suffix: Sequence[int] & Suffix): + # TODO: Simplify to `tuple[bool, *tuple[int, ...]]`. + reveal_type(prefix) # revealed: Sequence[int] & tuple[bool, *tuple[object, ...]] + # TODO: Simplify to `tuple[*tuple[int, ...], bool]`. + reveal_type(suffix) # revealed: Sequence[int] & tuple[*tuple[object, ...], bool] +``` + +Required positions also preserve the minimum length, even when every element type is `object`: + +```pyi +static_assert(not is_subtype_of(tuple[()], Sequence[int] & tuple[object, *tuple[object, ...]])) +static_assert(not is_subtype_of(tuple[int], Sequence[int] & tuple[object, *tuple[object, ...], object])) +``` + +### `type[...]` + +`type[...]` is also a covariant type constructor, so the same intersection relation should apply. + +```pyi +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to + +class P: ... + +# TODO: Support intersections inside `type[...]`. +# error: [static-assert-error] +static_assert(is_equivalent_to(type[P] & type[Any], type[P & Any])) +``` + +### Type var bounds and `NewTypes` + +The simplification preserves the identities of type variables and `NewType` instances: + +```pyi +from typing import Any, NewType +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to + +class P: ... +class Q: ... + +class Co[T]: + def get(self) -> T: + raise NotImplementedError + +CoId = NewType("CoId", Co[P]) + +def preserve_typevar[T: Co[P]](value: T & Co[Any]) -> T: + return value + +def preserve_newtype(value: CoId & Co[Any]) -> CoId: + return value +``` + +### Specializations with type variables + +Relations like (7b) also hold if they involve specializations using (non inferable) type variables: + +```pyi +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to + +class Co[T]: + def get(self) -> T: + raise NotImplementedError + +class Child[T](Co[T]): ... + +def generic[T]() -> None: + static_assert(is_equivalent_to(Co[T] & Child[object], Child[T])) +``` + +### Specializations with indirect gradual types + +A type alias can introduce a gradual argument indirectly. Intersecting with a top-materialized +subclass must not discard the base's gradual member types: + +```pyi +from typing import Any, reveal_type +from ty_extensions import Top + +class Co[T]: + def get(self) -> T: ... + +class Child[T](Co[T]): + def put(self, value: T) -> None: ... + +type Dynamic = Any + +def alias(value: Co[Dynamic] & Top[Child[Any]]) -> str: + reveal_type(value) # revealed: Co[Dynamic] & Top[Child[Any]] + reveal_type(value.get()) # revealed: Any + return value.get() +``` + +### Nested specializations of nonrecursive protocols + +A protocol can appear inside its own type argument without a recursive declaration. These finite +nested specializations still allow intersection simplification: + +```pyi +from typing import Protocol, reveal_type + +class Value[T](Protocol): + @property + def value(self) -> T: ... + +class Co[T]: + def get(self) -> T: ... + +class Child[T](Co[T]): ... + +def _(value: Co[Value[Value[int]]] & Child[object]): + reveal_type(value) # revealed: Child[Value[Value[int]]] +``` + +### Specializations with recursive types + +Fully static recursive types still allow simplification. Evaluating protocol attributes and +`TypedDict` fields can itself require simplifying intersections that refer back to those types: + +```pyi +from typing import Protocol, TypedDict, reveal_type + +class Co[T]: + def get(self) -> T: ... + +class Child[T](Co[T]): ... + +type RecursiveAlias = int | list[RecursiveAlias] + +class RecursiveProtocol(Protocol): + child: Co[RecursiveProtocol] & Child[object] + +class RecursiveRecord(TypedDict): + child: Co[RecursiveRecord] & Child[object] + +def _( + alias: Co[RecursiveAlias] & Child[object], + protocol: Co[RecursiveProtocol] & Child[object], + record: Co[RecursiveRecord] & Child[object], +): + reveal_type(alias) # revealed: Child[int | list[RecursiveAlias]] + reveal_type(protocol) # revealed: Child[RecursiveProtocol] + reveal_type(record) # revealed: Child[RecursiveRecord] +``` + +A generic protocol method that returns the same specialization also permits simplification. Its +recursive return type does not introduce new type arguments: + +```pyi +class RecursiveMethods[T](Protocol): + def value(self) -> T: ... + def next(self) -> RecursiveMethods[T]: ... + +def _(value: Co[RecursiveMethods[int]] & Child[object]): + reveal_type(value) # revealed: Child[RecursiveMethods[int]] +``` + +Recursive generic specializations can grow on each step, so we conservatively leave these +intersections unsimplified. Checking only for repeated specializations does not prevent infinite +expansion: + +```pyi +type GrowingAlias[T] = T | list[Co[GrowingAlias[list[T]]] & Child[object]] + +class GrowingRecord[T](TypedDict): + child: Co[GrowingRecord[list[T]]] & Child[object] + +def _( + alias: Co[GrowingAlias[int]] & Child[object], + record: Co[GrowingRecord[int]] & Child[object], +): + reveal_type(alias) # revealed: Co[GrowingAlias[int]] & Child[object] + reveal_type(record) # revealed: Co[GrowingRecord[int]] & Child[object] +``` + +### Inherited properties with recursive protocol bounds + +Reading an inherited generic property preserves its type parameter, even when the parameter's bound +contains a method with an expanding recursive return type: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol, reveal_type + +class Recursive[T](Protocol): + def grow(self) -> Recursive[tuple[int, T]]: ... + +class Base[T]: + @property + def value(self) -> T: + raise NotImplementedError + +class Child[T: Recursive[int]](Base[T]): + def read(self) -> T: + reveal_type(self.value) # revealed: T@Child + return self.value +``` + +### Recursive protocols with gradual members + +An `Any` member does not remove an expanding recursive method from a protocol. Intersections with +these protocols remain unsimplified regardless of member order: + +```pyi +from typing import Any, Protocol, reveal_type + +class AnyFirst[T](Protocol): + a_marker: Any + + def grow(self) -> AnyFirst[tuple[int, T]]: ... + +class AnyLast[T](Protocol): + def grow(self) -> AnyLast[tuple[int, T]]: ... + + z_marker: Any + +class Co[T]: + def get(self) -> T: ... + +class Child[T](Co[T]): ... + +def _( + first: Co[AnyFirst[int]] & Child[object], + last: Co[AnyLast[int]] & Child[object], +): + reveal_type(first) # revealed: Co[AnyFirst[int]] & Child[object] + reveal_type(last) # revealed: Co[AnyLast[int]] & Child[object] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md b/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md index 3f5b167f4c..cabb8fe8c5 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md @@ -258,7 +258,7 @@ class Node: class Unrelated: ... def _(node: Node) -> None: - # error: [invalid-argument-type] "Argument type `Unrelated` does not satisfy upper bound `Self@link` of type variable `T`" + # error: [invalid-argument-type] "Argument type `Unrelated` does not satisfy upper bound `Node` of type variable `T`" node.link(Unrelated()) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md index e6469d5393..1e7946ea6b 100644 --- a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md @@ -632,6 +632,73 @@ class Derived2(GenericBaseAlias[int]): pass ``` +### Generic typed dictionaries in aliases + +First, define a generic typed dictionary whose field uses a legacy type variable: + +```py +from typing import Generic, TypeVar, TypedDict + +T = TypeVar("T") + +class Item(TypedDict, Generic[T]): + value: T +``` + +An implicit union alias remains generic when its only type variable appears in the typed dictionary, +and specialization preserves the dictionary's field type: + +```py +OptionalItem = Item[T] | None + +def _(item: OptionalItem[int]): + reveal_type(item) # revealed: Item[int] | None + + if item is not None: + reveal_type(item["value"]) # revealed: int +``` + +Without an explicit type argument, the alias uses the type variable's default specialization: + +```py +def _(item: OptionalItem): + reveal_type(item) # revealed: Item[Unknown] | None +``` + +The type variable is also discovered when the typed dictionary is nested inside a container: + +```py +Items = list[Item[T]] + +def _(items: Items[str]): + reveal_type(items) # revealed: list[Item[str]] + reveal_type(items[0]["value"]) # revealed: str +``` + +### Type-variable order in generic typed-dictionary aliases + +Type variables that appear inside a typed dictionary are collected in the same order as variables in +other union members: + +```py +from typing import Generic, TypeVar, TypedDict + +T = TypeVar("T") +U = TypeVar("U") + +class Item(TypedDict, Generic[T]): + value: T + +TypedDictFirst = Item[T] | list[U] +TypedDictLast = list[U] | Item[T] +Repeated = Item[T] | list[T] + +def _(first: TypedDictFirst[int, str], last: TypedDictLast[str, int], repeated: Repeated[int]): + reveal_type(first) # revealed: Item[int] | list[str] + reveal_type(last) # revealed: list[str] | Item[int] + reveal_type(repeated) # revealed: Item[int] | list[int] +``` + ### Imported aliases Generic implicit type aliases can be imported from other modules and specialized: @@ -664,6 +731,45 @@ def _( reveal_type(list_of_str_or_none) # revealed: list[str] | None ``` +### Imported tagged typed-dictionary aliases + +A stub can define a generic tagged union containing a typed dictionary and expose a concrete +specialization through a function signature: + +`events.pyi`: + +```pyi +from typing import Generic, Literal, TypeVar, TypedDict, Union + +T = TypeVar("T") + +class ObjectEvent(TypedDict, Generic[T]): + type: Literal["ADDED"] + object: T + +class BookmarkEvent(TypedDict): + type: Literal["BOOKMARK"] + object: object + +DecodedEvent = Union[ObjectEvent[T], BookmarkEvent, None] + +def get_event() -> DecodedEvent[int]: ... +``` + +After excluding the other union members, Python code sees the concrete typed-dictionary field type: + +`main.py`: + +```py +from events import get_event + +event = get_event() +if event is not None and event["type"] != "BOOKMARK": + reveal_type(event) # revealed: ObjectEvent[int] + reveal_type(event["object"]) # revealed: int + event["object"].bit_length() +``` + ### In stringified annotations Generic implicit type aliases can be specialized in stringified annotations: @@ -744,8 +850,9 @@ def _(doubly_specialized: Tuple[int]): reveal_type(doubly_specialized) # revealed: Unknown T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) -class LegacyProto(Protocol[T]): +class LegacyProto(Protocol[T_co]): pass LegacyProtoInt = LegacyProto[int] @@ -1975,6 +2082,26 @@ def _( reveal_type(recursive_dict4) # revealed: dict[Divergent, int] ``` +### Recursive typed-dictionary fields in generic aliases + +A recursive field on a non-generic typed dictionary does not make an unrelated enclosing generic +alias recursive: + +```py +from typing import TypeVar, TypedDict + +T = TypeVar("T") +RecursiveList = list["RecursiveList | None"] + +class Payload(TypedDict): + value: RecursiveList + +ListOrPayload = list[T] | Payload + +def _(value: ListOrPayload[int]): + reveal_type(value) # revealed: list[int] | Payload +``` + ### Self-referential generic implicit type aliases ```py diff --git a/crates/ty_python_semantic/resources/mdtest/import/missing_direct_dependency.md b/crates/ty_python_semantic/resources/mdtest/import/missing_direct_dependency.md new file mode 100644 index 0000000000..cc590f46c7 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/import/missing_direct_dependency.md @@ -0,0 +1,1096 @@ +# Missing direct dependencies + +An installed package is not necessarily a declared dependency. The `missing-direct-dependency` rule +checks imports against the current project's direct dependencies when dependency metadata is +available. + +## Without dependency metadata + +Enabling the rule has no effect when the package manager has not supplied dependency metadata. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +import indirect +``` + +## Direct dependency declarations + +The project declares `direct-dependency`, which provides `direct` and `facade`. It also has +`indirect-distribution` installed, but does not declare that distribution as a direct dependency. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", distribution = "app", dependencies = ["direct"] }] + +[dependency-metadata.distributions] +app = { name = "app-project" } +direct = { name = "direct-dependency" } +indirect = { name = "indirect-distribution" } + +[dependency-metadata.module-owners] +app = ["app"] +direct = ["direct"] +facade = ["direct"] +indirect = ["indirect"] +``` + +### Plain and aliased imports + +Importing a declared dependency is allowed. Imports of an undeclared distribution are reported, +including aliases and imports of its submodules. + +`/.venv//direct/__init__.py`: + +```py +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`/.venv//indirect/child.py`: + +```py +value = 1 +``` + +`main.py`: + +```py +import direct + +# snapshot: missing-direct-dependency +import indirect +import indirect as alias # error: [missing-direct-dependency] "Import of `indirect` requires a direct dependency on `indirect-distribution`" +import indirect.child # error: [missing-direct-dependency] +from indirect.child import value # error: [missing-direct-dependency] +``` + +```snapshot +warning[missing-direct-dependency]: Import of `indirect` requires a direct dependency on `indirect-distribution` + --> src/main.py:4:8 + | +4 | import indirect + | ^^^^^^^^ +help: Declare `indirect-distribution` in `project.dependencies` or `project.optional-dependencies` in your `pyproject.toml` +info: See https://docs.astral.sh/uv/concepts/projects/dependencies/ +``` + +### From imports and star imports + +Each imported name from an undeclared distribution is reported. Star imports also require a direct +dependency, whether they occur in the same file or another file. + +`/.venv//indirect/__init__.py`: + +```py +first = 1 +second = 2 +``` + +`main.py`: + +```py +# error: [missing-direct-dependency] "Import of `indirect` requires a direct dependency on `indirect-distribution`" +# error: [missing-direct-dependency] +from indirect import first, second +from indirect import * # error: [missing-direct-dependency] +``` + +`star.py`: + +```py +from indirect import * # error: [missing-direct-dependency] +``` + +### Star imports without exported names + +A star import of an empty module still uses its distribution, even though it binds no names. + +`/.venv//indirect.py`: + +```py +``` + +`main.py`: + +```py +from indirect import * # error: [missing-direct-dependency] +``` + +### Imports inside functions and classes + +Imports in nested scopes require direct dependencies just like imports at module scope. Imports in +functions and classes are reported independently of imports at module scope. + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +def use_dependency(): + import indirect # error: [missing-direct-dependency] + +class UsesDependency: + import indirect # error: [missing-direct-dependency] + +import indirect # error: [missing-direct-dependency] +``` + +`class_first.py`: + +```py +class UsesDependency: + import indirect # error: [missing-direct-dependency] + +def use_dependency(): + import indirect # error: [missing-direct-dependency] + +import indirect # error: [missing-direct-dependency] +``` + +### Type-checking and unreachable imports + +Imports guarded by `TYPE_CHECKING` do not introduce runtime dependencies. Unreachable imports are +also ignored, including in nested scopes. Neither hides a later runtime import's diagnostic. + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import indirect + +if False: + import indirect + +def use_dependency(): + if TYPE_CHECKING: + import indirect + if False: + import indirect + +import indirect # error: [missing-direct-dependency] +``` + +### Stub files + +Imports in a stub describe types, not runtime dependencies. They do not require declarations in the +project's runtime dependencies. + +`/.venv//indirect/__init__.py`: + +```py +class Value: ... +``` + +`main.pyi`: + +```pyi +import indirect +from indirect import Value +``` + +### Unresolved imports and unknown ownership + +An import that cannot be resolved receives only `unresolved-import`. A resolved module with no known +distribution owner is not enough evidence to report a missing dependency. Standard-library imports +do not require project dependencies either. + +`/.venv//unowned.py`: + +```py +``` + +`main.py`: + +```py +import indirect # error: [unresolved-import] +import unowned +import sys +``` + +### Local modules and self-imports + +A first-party module can shadow an installed module with the same name. Imports of that local +module, or of the project itself, do not require another dependency declaration. + +`/.venv//indirect/__init__.py`: + +```py +``` + +`indirect.py`: + +```py +``` + +`app/__init__.py`: + +```py +``` + +`main.py`: + +```py +import indirect +import app +``` + +### Re-exported values + +A declared dependency can expose values implemented by one of its dependencies. Importing the public +value from the declared dependency does not require a direct dependency on its implementation. + +`/.venv//indirect/__init__.py`: + +```py +class Value: ... +``` + +`/.venv//facade/__init__.py`: + +```py +from indirect import Value +``` + +`main.py`: + +```py +from facade import Value +``` + +## Module ownership + +Import names can differ from distribution names. Namespace packages can also contain modules from +several distributions, so the most specific known owner determines the dependency to declare. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", dependencies = ["core"] }] + +[dependency-metadata.distributions] +core = { name = "core-distribution" } +storage = { name = "storage-distribution" } +other = { name = "other-distribution" } +runtime = { name = "runtime-distribution" } +indirect = { name = "indirect-distribution" } + +[dependency-metadata.module-owners] +ns = ["storage", "other"] +"ns.core" = ["core"] +"ns.storage" = ["storage"] +"ns.other" = ["other"] +shared = ["storage", "other"] +shared_namespace = ["indirect"] +"shared_namespace.external" = ["indirect"] +typed = ["runtime"] +``` + +### Namespace children + +The namespace itself has no unique owner. Its children do, including children imported with +`from ns import ...`. Distinct missing distributions in one statement receive separate diagnostics. + +`/.venv//ns/storage/__init__.py`: + +```py +value = 1 +``` + +`/.venv//ns/other/__init__.py`: + +```py +``` + +`/.venv//ns/core/__init__.py`: + +```py +``` + +`/.venv//ns/storage_extra.py`: + +```py +``` + +`main.py`: + +```py +import ns +import ns.core + +# error: [missing-direct-dependency] "direct dependency on `storage-distribution`" +# error: [missing-direct-dependency] "direct dependency on `other-distribution`" +from ns import storage, other + +import ns.storage # error: [missing-direct-dependency] "direct dependency on `storage-distribution`" +from ns.storage import value # error: [missing-direct-dependency] "direct dependency on `storage-distribution`" + +# `ns.storage` is not a module-name prefix of `ns.storage_extra`. +import ns.storage_extra +``` + +### Namespaces containing local and installed modules + +A namespace can contain both local modules and modules from an installed distribution. Ownership of +the installed part does not imply ownership of the namespace or its local children. Only imports of +the external child require that distribution as a dependency. + +`shared_namespace/local.py`: + +```py +``` + +`/.venv//shared_namespace/external.py`: + +```py +``` + +`main.py`: + +```py +import shared_namespace +from shared_namespace import local +import shared_namespace.local + +from shared_namespace import external # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +import shared_namespace.external # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +``` + +### Shared namespaces with inline stubs + +An installed `__init__.pyi` does not change which modules share the namespace at runtime. Imports of +the namespace and its local child remain allowed; imports of its installed child require a +dependency. + +`shared_namespace/local.py`: + +```py +``` + +`/.venv//shared_namespace/__init__.pyi`: + +```pyi +``` + +`/.venv//shared_namespace/external.py`: + +```py +``` + +`main.py`: + +```py +import shared_namespace +from shared_namespace import local +import shared_namespace.local + +from shared_namespace import external # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +import shared_namespace.external # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +``` + +### Ambiguous ownership + +When multiple distributions claim the same module, the rule does not guess which one to declare. + +TODO: When none of the possible owners is an allowed dependency, report the import and list the +candidate distributions in the diagnostic. + +`/.venv//shared/__init__.py`: + +```py +``` + +`main.py`: + +```py +import shared +``` + +### Runtime distributions with separate stubs + +Type checking can resolve an import through a stub package. The runtime module's distribution still +determines the dependency required by a runtime import. + +`/.venv//typed-stubs/__init__.pyi`: + +```pyi +value: int +``` + +`/.venv//typed/__init__.py`: + +```py +value = 1 +``` + +`main.py`: + +```py +import typed # error: [missing-direct-dependency] "direct dependency on `runtime-distribution`" +from typed import value # error: [missing-direct-dependency] "direct dependency on `runtime-distribution`" + +reveal_type(value) # revealed: int +``` + +### Package stubs for runtime namespaces + +An `__init__.pyi` does not make a regular package at runtime. The resulting namespace may include +files from other locations, so importing it does not identify a missing dependency. + +`/.venv//typed/__init__.pyi`: + +```pyi +value: int +``` + +`main.py`: + +```py +import typed +from typed import value + +reveal_type(typed.value) # revealed: int +reveal_type(value) # revealed: int +``` + +### Native runtime distributions with visible stubs + +For a native runtime module, ty may only resolve a `.pyi` file. Ownership supplied by the package +manager still identifies the runtime dependency, while the stub supplies its types. + +`/.venv//typed.pyi`: + +```pyi +value: int +``` + +`main.py`: + +```py +import typed # error: [missing-direct-dependency] "direct dependency on `runtime-distribution`" + +reveal_type(typed.value) # revealed: int +``` + +## Projects and dependency groups + +The root project declares `direct-dependency` as a runtime dependency and `development-tool` in a +dependency group. A nested project has its own dependency declarations. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [ + { path = "/src", distribution = "app", dependencies = ["direct"], group-dependencies = ["dev"] }, + { path = "/src/nested", distribution = "nested", dependencies = ["indirect"] }, +] + +[dependency-metadata.distributions] +app = { name = "app-project" } +nested = { name = "nested-project" } +direct = { name = "direct-dependency" } +dev = { name = "development-tool" } +indirect = { name = "indirect-distribution" } +editable = { name = "editable-distribution", editable-path = "/editable/lib" } + +[dependency-metadata.module-owners] +app = ["app"] +nested = ["nested"] +direct = ["direct"] +devtool = ["dev"] +indirect = ["indirect"] +``` + +### Package code and tests + +Package code cannot rely on a dependency group. Tests can use direct group dependencies, but not +packages installed only as their transitive dependencies. + +`/.venv//direct/__init__.py`: + +```py +``` + +`/.venv//devtool/__init__.py`: + +```py +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`app/__init__.py`: + +```py +import direct +import devtool # error: [missing-direct-dependency] "direct dependency on `development-tool`" +``` + +`tests/test_app.py`: + +```py +import direct +import devtool +import indirect # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +``` + +### Nested workspace members + +The nearest containing project supplies the dependency declarations. Neither project can borrow the +other's direct dependencies. + +`/.venv//direct/__init__.py`: + +```py +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +import direct +import indirect # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +``` + +`nested/main.py`: + +```py +import indirect +import direct # error: [missing-direct-dependency] "direct dependency on `direct-dependency`" +``` + +### Editable dependencies + +An editable distribution can expose a module whose name differs from its distribution name. Its +source path identifies the owner even without an entry in the module-owner map. + +`/.venv//editable.pth`: + +```pth +/editable/lib/src +``` + +`/editable/lib/src/lib_module/__init__.py`: + +```py +``` + +`main.py`: + +```py +import lib_module # error: [missing-direct-dependency] "direct dependency on `editable-distribution`" +``` + +### Editable legacy namespaces + +Two editable distributions contribute to the same legacy namespace. The project directly depends on +the distribution providing `ns.child`, but not on the distribution providing `ns/__init__.py`. +Importing the child requires only the child's distribution, regardless of the import syntax. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", dependencies = ["child"] }] + +[dependency-metadata.distributions] +parent = { name = "parent-distribution", editable-path = "/editable/parent" } +child = { name = "child-distribution", editable-path = "/editable/child" } +``` + +`/.venv//_parent.pth`: + +```pth +/editable/parent/src +``` + +`/.venv//child.pth`: + +```pth +/editable/child/src +``` + +`/editable/parent/src/ns/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) +value = 1 +``` + +`/editable/child/src/ns/child.py`: + +```py +value = 2 +``` + +`main.py`: + +```py +import ns.child +from ns.child import value +from ns import child +``` + +Importing an attribute of the parent still requires its distribution, even when the same statement +also imports the child. + +`attributes.py`: + +```py +from ns import child, value # error: [missing-direct-dependency] "direct dependency on `parent-distribution`" +``` + +Star imports also require the parent's distribution. + +`star.py`: + +```py +from ns import * # error: [missing-direct-dependency] "direct dependency on `parent-distribution`" +``` + +### Editable source roots also configured explicitly + +The `.pth` file identifies package code even when the same directory is configured in `extra-paths`. +Tests outside that source directory can use direct dependency-group dependencies. + +```toml +[environment] +python = "/.venv" +extra-paths = ["package-src"] + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", distribution = "app", group-dependencies = ["dev"] }] + +[dependency-metadata.distributions] +app = { name = "app-project", editable-path = "/src" } +dev = { name = "development-tool" } + +[dependency-metadata.module-owners] +devtool = ["dev"] +``` + +`/.venv//app.pth`: + +```pth +/src/package-src +``` + +`/.venv//devtool/__init__.py`: + +```py +``` + +`package-src/app/__init__.py`: + +```py +import devtool # error: [missing-direct-dependency] "direct dependency on `development-tool`" +``` + +`tests/test_app.py`: + +```py +import devtool +``` + +### Flat editable roots + +An editable search path covering the whole member also makes its tests and development scripts +importable. Without module ownership, that path does not identify which files belong to the +distribution. Direct group dependencies stay allowed, while undeclared dependencies are still +reported. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src/member", distribution = "app", group-dependencies = ["dev"] }] + +[dependency-metadata.distributions] +app = { name = "app-project", editable-path = "/src/member" } +dev = { name = "development-tool" } +indirect = { name = "indirect-distribution" } + +[dependency-metadata.module-owners] +devtool = ["dev"] +indirect = ["indirect"] +``` + +`/.venv//app.pth`: + +```pth +/src/member +``` + +`/.venv//devtool/__init__.py`: + +```py +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`member/app/__init__.py`: + +```py +import devtool +``` + +`member/tests/test_app.py`: + +```py +import devtool +``` + +`member/scripts/develop.py`: + +```py +import devtool +import indirect # error: [missing-direct-dependency] "direct dependency on `indirect-distribution`" +``` + +## Script dependency declarations + +A PEP 723 script declares runtime dependencies in its inline `dependencies` list. It can import +`direct-dependency`, but importing the installed `indirect-distribution` requires its own +declaration. Imports guarded by `TYPE_CHECKING` do not count. Each runtime import of an undeclared +dependency is reported. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//direct/__init__.py`: + +```py +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`script.py`: + +```py +# /// script +# dependencies = ["direct-dependency"] +# [tool.ty.rules] +# missing-direct-dependency = "warn" +# [tool.ty.dependency-metadata] +# projects = [{ path = "/src/script.py", dependencies = ["direct"] }] +# [tool.ty.dependency-metadata.distributions] +# direct = { name = "direct-dependency" } +# indirect = { name = "indirect-distribution" } +# [tool.ty.dependency-metadata.module-owners] +# direct = ["direct"] +# indirect = ["indirect"] +# /// + +from typing import TYPE_CHECKING + +import direct + +if TYPE_CHECKING: + import indirect + +# snapshot: missing-direct-dependency +import indirect +import indirect as alias # error: [missing-direct-dependency] "Import of `indirect` requires a direct dependency on `indirect-distribution`" +``` + +```snapshot +warning[missing-direct-dependency]: Import of `indirect` requires a direct dependency on `indirect-distribution` + --> src/script.py:23:8 + | +23 | import indirect + | ^^^^^^^^ +help: Declare `indirect-distribution` in the script's inline `dependencies` metadata +info: See https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies +``` + +## Script and workspace isolation + +The project and two scripts have different declarations. An import is allowed only when the file's +own declarations include its distribution. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", dependencies = ["project"] }] + +[dependency-metadata.distributions] +project = { name = "project-dependency" } +script = { name = "script-dependency" } + +[dependency-metadata.module-owners] +project_dep = ["project"] +script_dep = ["script"] +``` + +`/.venv//project_dep/__init__.py`: + +```py +``` + +`/.venv//script_dep/__init__.py`: + +```py +``` + +`main.py`: + +```py +import project_dep +import script_dep # error: [missing-direct-dependency] "direct dependency on `script-dependency`" +``` + +The first script declares only `script-dependency`. The project's declaration of +`project-dependency` does not apply to it. + +`first.py`: + +```py +# /// script +# dependencies = ["script-dependency"] +# [tool.ty.rules] +# missing-direct-dependency = "warn" +# [tool.ty.dependency-metadata] +# projects = [{ path = "/src/first.py", dependencies = ["script"] }] +# [tool.ty.dependency-metadata.distributions] +# project = { name = "project-dependency" } +# script = { name = "script-dependency" } +# [tool.ty.dependency-metadata.module-owners] +# project_dep = ["project"] +# script_dep = ["script"] +# /// + +import script_dep +import project_dep # error: [missing-direct-dependency] "direct dependency on `project-dependency`" +``` + +The second script declares only `project-dependency`. It cannot use the first script's declaration +of `script-dependency`. + +`second.py`: + +```py +# /// script +# dependencies = ["project-dependency"] +# [tool.ty.rules] +# missing-direct-dependency = "warn" +# [tool.ty.dependency-metadata] +# projects = [{ path = "/src/second.py", dependencies = ["project"] }] +# [tool.ty.dependency-metadata.distributions] +# project = { name = "project-dependency" } +# script = { name = "script-dependency" } +# [tool.ty.dependency-metadata.module-owners] +# project_dep = ["project"] +# script_dep = ["script"] +# /// + +import project_dep +import script_dep # error: [missing-direct-dependency] "direct dependency on `script-dependency`" +``` + +## Dependencies imported by local modules + +Script dependency checks currently cover imports in the script itself. They do not check imports in +local modules against the script's declarations, a limitation tracked in +[ty#4417](https://github.com/astral-sh/ty/issues/4417). Here, the project declares `attrs`, but the +script declares no dependencies. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src", dependencies = ["attrs"] }] + +[dependency-metadata.distributions] +attrs = { name = "attrs" } + +[dependency-metadata.module-owners] +attrs = ["attrs"] +``` + +`/.venv//attrs/__init__.py`: + +```py +``` + +`b.py`: + +```py +import attrs +``` + +Importing `b` does not report its dependency on `attrs` as missing from the script. Importing +`attrs` directly does report the missing declaration. + +`script.py`: + +```py +# /// script +# dependencies = [] +# [tool.ty.rules] +# missing-direct-dependency = "warn" +# [tool.ty.dependency-metadata] +# projects = [{ path = "/src/script.py" }] +# [tool.ty.dependency-metadata.distributions] +# attrs = { name = "attrs" } +# [tool.ty.dependency-metadata.module-owners] +# attrs = ["attrs"] +# /// + +import b +import attrs # error: [missing-direct-dependency] "Import of `attrs` requires a direct dependency on `attrs`" +``` + +## Unavailable script metadata + +Without metadata for a script's environment, the rule cannot establish module ownership. It skips +that script even when the enclosing project has dependency metadata. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "warn" + +[dependency-metadata] +projects = [{ path = "/src" }] + +[dependency-metadata.distributions] +indirect = { name = "indirect-distribution" } + +[dependency-metadata.module-owners] +indirect = ["indirect"] +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +import indirect # error: [missing-direct-dependency] +``` + +`script.py`: + +```py +# /// script +# dependencies = [] +# [tool.ty.rules] +# missing-direct-dependency = "warn" +# /// + +import indirect +``` + +## Disabled rule + +Dependency metadata does not make the rule mandatory. It can be disabled through rule selection. + +```toml +[environment] +python = "/.venv" + +[rules] +missing-direct-dependency = "ignore" + +[dependency-metadata] +projects = [{ path = "/src" }] + +[dependency-metadata.distributions] +indirect = { name = "indirect-distribution" } + +[dependency-metadata.module-owners] +indirect = ["indirect"] +``` + +`/.venv//indirect/__init__.py`: + +```py +``` + +`main.py`: + +```py +import indirect +``` diff --git a/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md b/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md index 79cce812fe..9207cfd74d 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md +++ b/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md @@ -16,6 +16,63 @@ def __getattr__(name: str) -> str: return "hi" ``` +## Invalid `__getattr__` calls + +A module-level `__getattr__` must accept the attribute name passed by Python. If the call fails, the +access is invalid, but the function's return type remains available for error recovery. + +```py +import invalid_getattr_module + +invalid_getattr_module.missing # snapshot: invalid-attribute-access + +# error: [invalid-attribute-access] "Invalid access to attribute `missing` on type ``" +reveal_type(invalid_getattr_module.missing) # revealed: str + +reveal_type(invalid_getattr_module.defined) # revealed: Literal[1] +``` + +```snapshot +error[invalid-attribute-access]: Invalid access to attribute `missing` on type `` + --> src/mdtest_snippet.py:3:1 + | +3 | invalid_getattr_module.missing # snapshot: invalid-attribute-access + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Too many positional arguments to function `__getattr__`: expected 0, got 1 +info: This access implicitly calls `__getattr__` +info: Function signature here + --> src/invalid_getattr_module.py:3:5 + | +3 | def __getattr__() -> str: + | ^^^^^^^^^^^^^^^^^^^^ +``` + +`invalid_getattr_module.py`: + +```py +defined = 1 + +def __getattr__() -> str: + return "fallback" +``` + +## Invalid `__getattr__` attribute-name types + +An incompatible attribute-name parameter also makes a module-level fallback call invalid. + +```py +import invalid_getattr_name + +# error: [invalid-attribute-access] "Invalid access to attribute `missing` on type ``" +reveal_type(invalid_getattr_name.missing) # revealed: bytes +``` + +`invalid_getattr_name.py`: + +```py +def __getattr__(name: int) -> bytes: + return b"fallback" +``` + ## `from import` with `__getattr__` At runtime, if `module` has a `__getattr__` implementation, you can do `from module import whatever` @@ -34,6 +91,40 @@ def __getattr__(name: str) -> int: return 42 ``` +## Invalid `__getattr__` calls in `from` imports + +An invalid module-level `__getattr__` call is reported on `from ... import` statements while +retaining the function's return type for recovery. Since the failed operation is an import, it +receives an `invalid-module-getattr-call` diagnostic instead of an `invalid-attribute-access` +diagnostic. + +```py +from invalid_getattr_module import missing # snapshot: invalid-module-getattr-call + +reveal_type(missing) # revealed: str +``` + +```snapshot +error[invalid-module-getattr-call]: Cannot import `missing` from module `invalid_getattr_module` + --> src/mdtest_snippet.py:1:36 + | +1 | from invalid_getattr_module import missing # snapshot: invalid-module-getattr-call + | ^^^^^^^ Too many positional arguments to function `__getattr__`: expected 0, got 1 +info: This import implicitly calls a module-level `__getattr__` function +info: Function signature here + --> src/invalid_getattr_module.py:1:5 + | +1 | def __getattr__() -> str: + | ^^^^^^^^^^^^^^^^^^^^ +``` + +`invalid_getattr_module.py`: + +```py +def __getattr__() -> str: + return "fallback" +``` + ## Precedence: explicit attributes take priority over `__getattr__` ```py @@ -110,20 +201,48 @@ from mod import sub reveal_type(sub) # revealed: ``` +## Precedence: submodules vs invalid `__getattr__` + +A real submodule takes precedence even when the package's `__getattr__` would reject its name. + +`invalid_mod/__init__.py`: + +```py +def __getattr__() -> str: + return "fallback" +``` + +`invalid_mod/sub.py`: + +```py +value = 42 +``` + +```py +from invalid_mod import sub + +reveal_type(sub) # revealed: +``` + ## Limiting names handled by `__getattr__` -If a module `__getattr__` is annotated to only accept certain string literals, then the module -`__getattr__` will be ignored for other names. (In principle this could be a more explicit way to -handle the precedence issues discussed above, but it's not currently used in the ecosystem.) +If a module `__getattr__` is annotated to accept only certain string literals, unsupported names +produce an import or attribute-access diagnostic, respectively, while preserving the recovered +return type. ```py from limited_getattr_module import known_attr -# error: [unresolved-import] +# error: [invalid-module-getattr-call] "Cannot import `unknown_attr` from module `limited_getattr_module`" from limited_getattr_module import unknown_attr reveal_type(known_attr) # revealed: int -reveal_type(unknown_attr) # revealed: Unknown +reveal_type(unknown_attr) # revealed: int + +import limited_getattr_module + +# error: [invalid-attribute-access] "Invalid access to attribute `unknown_attr` on type ``" +reveal_type(limited_getattr_module.unknown_attr) # revealed: int ``` `limited_getattr_module.py`: diff --git a/crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md b/crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md index c4072939fe..a98b4d7d80 100644 --- a/crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md +++ b/crates/ty_python_semantic/resources/mdtest/instance_layout_conflict.md @@ -64,6 +64,38 @@ class AB( # error: [instance-layout-conflict] ): ... ``` +## Slot names that are not statically known + +A nonempty tuple still creates a distinct instance layout when its individual slot names are not +known. + +```py +def create(name: str) -> None: + class A: + __slots__ = (name,) + + class B: + __slots__ = ("value",) + + class C(A, B): ... # error: [instance-layout-conflict] +``` + +A variable-length tuple also creates a distinct instance layout when its type guarantees at least +one slot name. + +```py +from typing_extensions import Unpack + +def create_with_variadic_slots(names: tuple[str, Unpack[tuple[str, ...]]]) -> None: + class VariadicSlots: + __slots__ = names + + class KnownSlots: + __slots__ = ("value",) + + class Incompatible(VariadicSlots, KnownSlots): ... # error: [instance-layout-conflict] +``` + ## Synthesized `__slots__` from dataclasses ```py @@ -275,6 +307,20 @@ class Task(asyncio.Task[Any]): ... class SubClass(Task, Future): ... # fine ``` +## Slot names declared in a list + +A list of slot names restricts the instance layout just like a tuple. + +```py +class A: + __slots__ = ["a"] + +class B: + __slots__ = ("b",) + +class C(A, B): ... # error: [instance-layout-conflict] +``` + ## False negatives ### Possibly unbound `__slots__` @@ -309,19 +355,6 @@ def _(flag: bool): class C(A, B): ... ``` -### Non-tuple `__slots__` definitions - -```py -class A: - __slots__ = ["a", "b"] # This is treated as "dynamic" - -class B: - __slots__ = ("c", "d") - -# False negative: [incompatible-slots] -class C(A, B): ... -``` - ### Diagnostic if `__slots__` is externally modified We special-case type inference for `__slots__` and return the pure inferred type, even if the symbol diff --git a/crates/ty_python_semantic/resources/mdtest/intersection_types.md b/crates/ty_python_semantic/resources/mdtest/intersection_types.md index 65f70cdfeb..fc67b778d9 100644 --- a/crates/ty_python_semantic/resources/mdtest/intersection_types.md +++ b/crates/ty_python_semantic/resources/mdtest/intersection_types.md @@ -1093,13 +1093,13 @@ class UsesNew: def _(cls: type[UsesInit]) -> None: if issubclass(cls, UsesNew): reveal_type(cls) # revealed: type[UsesInit] & type[UsesNew] - # error: [invalid-argument-type] "class `UsesNew`" + # error: [invalid-argument-type] "Argument to constructor `UsesNew.__new__` is incorrect: Expected `str`, found `None`" # snapshot: invalid-argument-type cls(None) ``` ```snapshot -error[invalid-argument-type]: Argument to class `UsesInit` is incorrect +error[invalid-argument-type]: Argument to `UsesInit.__init__` is incorrect --> src/mdtest_snippet.py:15:13 | 15 | cls(None) @@ -1401,6 +1401,153 @@ def f(c: C): reveal_type(c.x) # revealed: ~AlwaysFalsy ``` +## Meta-types of intersections + +### Positive class constraints + +The class of an intersection must satisfy the class constraints supplied by every positive element. +Instantiating the resulting class intersection recovers the corresponding instance intersection. + +```py +class Left: ... +class Right: ... + +def positive(value: Left & Right) -> None: + reveal_type(value.__class__) # revealed: type[Left] & type[Right] + reveal_type(type(value)) # revealed: type[Left] & type[Right] + reveal_type(type(value)()) # revealed: Left & Right +``` + +### Bounded type variables + +Projecting an intersection into its class type preserves a bounded type variable instead of +replacing it with its upper bound. An unrelated positive class constraint is preserved too. + +```py +class Bound: ... +class Other: ... + +def preserve[T: Bound](value: T & Other) -> None: + reveal_type(type(value)) # revealed: type[T@preserve] & type[Other] + reveal_type(type(value)()) # revealed: T@preserve & Other +``` + +### Excluded alternatives in type-variable bounds + +Excluding an alternative from a type variable's union bound can reveal a definite class. Preserve +both that class constraint and the original type variable in the resulting class type. + +```py +class Bound: + label = "bound" + +def exclude_none[T: Bound | None](value: T) -> None: + if value is not None: + reveal_type(type(value)) # revealed: type[T@exclude_none] & type[Bound] + reveal_type(type(value).label) # revealed: str +``` + +### Excluded alternatives in class-object bounds + +If the remaining bound is a class object, its class is its metaclass. Preserve that metaclass +constraint alongside the original type variable. + +```py +class Meta(type): ... +class Bound(metaclass=Meta): ... + +def accepts_meta(value: type[Meta]) -> None: ... +def exclude_none[T: type[Bound] | None](value: T) -> None: + if value is not None: + reveal_type(type(value)) # revealed: type[T@exclude_none] & type[Meta] + accepts_meta(type(value)) +``` + +For a final class, the metaclass is known exactly. This also holds for a specialized generic class. + +```py +from typing import final + +@final +class FinalBound(metaclass=Meta): ... + +@final +class FinalGenericBound[U](metaclass=Meta): ... + +def exclude_none_final[T: type[FinalBound] | None](value: T) -> None: + if value is not None: + reveal_type(type(value)) # revealed: type[T@exclude_none_final] & + accepts_meta(type(value)) + +def exclude_none_generic[T: type[FinalGenericBound[int]] | None](value: T) -> None: + if value is not None: + reveal_type(type(value)) # revealed: type[T@exclude_none_generic] & + accepts_meta(type(value)) +``` + +### Truthiness refinements + +Whether an individual object is truthy or falsy does not constrain its runtime class. Both positive +and negative truthiness refinements must therefore disappear from its meta-type. + +```py +from ty_extensions import AlwaysFalsy + +class Base: ... + +def truthiness(falsy: Base & AlwaysFalsy, not_falsy: Base & ~AlwaysFalsy) -> None: + reveal_type(type(falsy)) # revealed: type[Base] + reveal_type(type(not_falsy)) # revealed: type[Base] +``` + +### Truthiness-narrowed `Self` + +Truthiness describes an individual instance, not its class. Narrowing `Self` by truthiness must +therefore preserve `type[Self]` while discarding the value-only refinement. + +```py +from typing import Self + +class Base: + def __bool__(self) -> bool: + return True + + def clone(self: Self) -> Self: + if not self: + return self + + reveal_type(self) # revealed: Self@clone & ~AlwaysFalsy + reveal_type(type(self)) # revealed: type[Self@clone] + return type(self)() +``` + +### Negative value constraints + +Excluding particular instance values does not exclude their classes: a nonzero integer can still +have class `int`. + +```py +from typing import Literal + +def nonzero(value: int & ~Literal[0]) -> None: + reveal_type(type(value)) # revealed: type[int] +``` + +### Intersections without a positive class constraint + +A pure negation supplies no positive class bound, and a truthiness constraint describes only an +instance value. Both conservatively project to the unconstrained class type. + +```py +from ty_extensions import AlwaysTruthy + +class Excluded: ... + +def unconstrained(negative: ~Excluded, truthy: AlwaysTruthy & ~Excluded) -> None: + reveal_type(type(negative)) # revealed: type + reveal_type(type(truthy)) # revealed: type +``` + ## Methods on intersections ### The same method from a common base diff --git a/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md b/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md index b11b9c7e9e..d9399c273c 100644 --- a/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md +++ b/crates/ty_python_semantic/resources/mdtest/invalid_syntax.md @@ -106,6 +106,16 @@ out = (obj.attr := obj).attr out = (obj[0] := obj).attr ``` +## Multiple starred assignment targets + +Even when a recovered assignment has more than one starred target, unpacking records types for its +bindings without panicking. + +```py +first, *left, *right = [1, 2, 3] # error: [invalid-syntax] "Two starred expressions in assignment" +first, *left, *right = (1, 2, 3) # error: [invalid-syntax] "Two starred expressions in assignment" +``` + ## Match-pattern alternatives binding different names A capture present in only one invalid `or` alternative is possibly undefined. @@ -278,3 +288,43 @@ InvalidEmptyAnnotated = Annotated[] def _(a: InvalidEmptyAnnotated): reveal_type(a) # revealed: Unknown ``` + +## Incomplete type parameter lists + +A generic protocol with an empty, unclosed type parameter list produces diagnostics without +panicking while constructing an autofix. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + +# error: [invalid-syntax] "Type parameter list cannot be empty" +# error: [invalid-generic-class] +class P[(Protocol[T]): ... +``` + +## Incomplete type parameter lists with Unicode names + +An unclosed type parameter list can also contain a non-ASCII name. The missing closing bracket does +not cause part of the name to be treated as a delimiter when constructing an autofix. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") + +# error: [invalid-syntax] "Expected `]`, found `(`" +# error: [invalid-generic-class] +class P[Ä(Protocol[T]): ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md index e97328a671..bb330149d5 100644 --- a/crates/ty_python_semantic/resources/mdtest/liskov.md +++ b/crates/ty_python_semantic/resources/mdtest/liskov.md @@ -1675,6 +1675,120 @@ class B4(A4): def method(self, x: int) -> int: ... ``` +## Overrides with `Self` return types + +An inherited `Self` return type refers to the subclass on which the method is called. An override +can preserve that return type regardless of whether either method explicitly annotates `self`: + +```pyi +from typing_extensions import Self + +class Base: + def implicit(self) -> Self: ... + def explicit(self: Self) -> Self: ... + +class PreservesSelf(Base): + def implicit(self) -> Self: ... + def explicit(self: Self) -> Self: ... + +class ChangesReceiverAnnotation(Base): + def implicit(self: Self) -> Self: ... + def explicit(self) -> Self: ... +``` + +Returning the superclass is incompatible: it does not satisfy the inherited promise to return an +instance of the subclass. Adding or omitting `self: Self` does not change this: + +```pyi +class ReturnsBase(Base): + def implicit(self) -> Base: ... # snapshot: invalid-method-override + def explicit(self: Self) -> Base: ... # error: [invalid-method-override] + +class ReturnsBaseWithChangedAnnotation(Base): + def implicit(self: Self) -> Base: ... # error: [invalid-method-override] + def explicit(self) -> Base: ... # error: [invalid-method-override] +``` + +```snapshot +error[invalid-method-override]: Invalid override of method `implicit` + --> src/mdtest_snippet.pyi:15:9 + | +15 | def implicit(self) -> Base: ... # snapshot: invalid-method-override + | ^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Base.implicit` + | + ::: src/mdtest_snippet.pyi:4:9 + | + 4 | def implicit(self) -> Self: ... + | ---------------------- `Base.implicit` defined here +info: incompatible return types: `Base` is not assignable to `ReturnsBase` +info: This violates the Liskov Substitution Principle +``` + +Repeating an already invalid override does not produce another diagnostic on a subclass: + +```pyi +class RepeatsInvalidOverride(ReturnsBase): + def implicit(self) -> Base: ... + def explicit(self: Self) -> Base: ... +``` + +## Overrides with `Self` parameters + +Repeating `other: Self` in an override narrows the parameter's bound from the base class to the +subclass. A call through a base-class reference can pass a base-class instance that the override +does not accept. We currently miss this violation for both implicit and explicit receiver +annotations. This is a known limitation tracked in +[#2255](https://github.com/astral-sh/ty/issues/2255), related to the broader +[generic override limitation](https://github.com/astral-sh/ty/issues/4133): + +```pyi +from typing_extensions import Self + +class Base: + def implicit(self, other: Self) -> None: ... + def explicit(self: Self, other: Self) -> None: ... + +class PreservesSelf(Base): + # TODO: Emit `invalid-method-override` for narrowing `other`. + def implicit(self, other: Self) -> None: ... + # TODO: Emit `invalid-method-override` for narrowing `other`. + def explicit(self: Self, other: Self) -> None: ... + +class ChangesReceiverAnnotation(Base): + # TODO: Emit `invalid-method-override` for narrowing `other`. + def implicit(self: Self, other: Self) -> None: ... + # TODO: Emit `invalid-method-override` for narrowing `other`. + def explicit(self, other: Self) -> None: ... +``` + +An override cannot replace the `Self` parameter with an unrelated type: + +```pyi +class Incompatible(Base): + def implicit(self, other: int) -> None: ... # error: [invalid-method-override] + def explicit(self: Self, other: int) -> None: ... # error: [invalid-method-override] +``` + +For generic superclasses, we use the inherited specialization of the class's type parameters, but +still miss the narrowing of `other: Self`: + +```toml +[environment] +python-version = "3.12" +``` + +```pyi +class GenericBase[T]: + def method(self, other: Self, value: T) -> Self: ... + +class Specialized(GenericBase[int]): + # TODO: Emit `invalid-method-override` for narrowing `other`. + def method(self, other: Self, value: int) -> Self: ... + +class IncompatibleSpecialization(GenericBase[int]): + def method(self, other: Self, value: str) -> Self: ... # error: [invalid-method-override] +``` + ## Protocol annotations on mixin receivers A mixin can annotate `self` with a protocol that the mixin itself does not implement. An override @@ -2363,6 +2477,41 @@ class InvalidSwapEvent(Event): def deserialize(cls: type[InvalidSwapEvent], data: dict[str, int]) -> InvalidSwapEvent: ... ``` +## Classmethod overrides with `Self` + +In a class method, `Self` refers to an instance, while `cls` is a class object. A caller with a +`type[Base]` reference can pass a `Base` instance as `other`, so narrowing that parameter to the +subclass's `Self` is invalid. We currently miss this violation with or without an explicit +`cls: type[Self]` annotation: + +```pyi +from typing_extensions import Self + +class Base: + @classmethod + def compare(cls, other: Self) -> None: ... + @classmethod + def copy(cls) -> Self: ... + +class ImplicitReceiver(Base): + @classmethod + # TODO: Emit `invalid-method-override` for narrowing `other`. + def compare(cls, other: Self) -> None: ... + +class ExplicitReceiver(Base): + @classmethod + # TODO: Emit `invalid-method-override` for narrowing `other`. + def compare(cls: type[Self], other: Self) -> None: ... +``` + +An override that returns the superclass does not satisfy the inherited `Self` return type: + +```pyi +class ReturnsBase(Base): + @classmethod + def copy(cls) -> Base: ... # error: [invalid-method-override] +``` + ## Overloaded methods with positional-only parameters with defaults When a base class has an overloaded method where one overload accepts only keyword arguments diff --git a/crates/ty_python_semantic/resources/mdtest/literal/collections/dictionary.md b/crates/ty_python_semantic/resources/mdtest/literal/collections/dictionary.md index 084d9545ef..5679585d93 100644 --- a/crates/ty_python_semantic/resources/mdtest/literal/collections/dictionary.md +++ b/crates/ty_python_semantic/resources/mdtest/literal/collections/dictionary.md @@ -326,6 +326,70 @@ def _(y: Y): f1(**y.inner) ``` +## Known key values after loop replacements + +An accepted dictionary replacement contributes its known key values to subsequent iterations. + +```py +def accepted(): + values: dict[str, int] = {"a": 1} + for _ in range(2): + reveal_type(values["a"]) # revealed: Literal[1, 2] + values = {"a": 2} + reveal_type(values["a"]) # revealed: Literal[2] +``` + +## Rejected dictionary replacements in loops + +A rejected replacement instead falls back to the declared value type. An assertion after the +replacement narrows that fallback on the next iteration, rather than preserving the original key's +literal type or using the rejected value. + +```py +def rejected(repeat: bool): + values: dict[str, int | None] = {"a": 1} + while repeat: + reveal_type(values["a"]) # revealed: int + values = {"a": "bad"} # error: [invalid-assignment] + assert values["a"] is not None + reveal_type(values["a"]) # revealed: int +``` + +## Setter dictionary assignments in loops + +A property setter need not store the assigned dictionary. Key reads use the getter's value type, +including when a key was already read before the loop and the setter accepts a different value type. + +```py +class C: + @property + def values(self) -> dict[str, int]: + return {"a": 1} + + @values.setter + def values(self, value: dict[str, str]) -> None: + pass + +def f(c: C, repeat: bool) -> int: + reveal_type(c.values["a"]) # revealed: int + while repeat: + reveal_type(c.values["a"]) # revealed: int + c.values = {"a": "bad"} + return c.values["a"] +``` + +The same applies when the assigned dictionary depends on a key read from an earlier iteration. +Inferring that assignment must converge without using the setter's input type for getter reads. + +```py +def loop_carried_value(c: C, repeat: bool) -> int: + reveal_type(c.values["a"]) # revealed: int + while repeat: + reveal_type(c.values["a"]) # revealed: int + c.values = {"a": str(c.values["a"])} + return c.values["a"] +``` + ## Rejected annotations in stubs Annotation-only declarations in stubs are also bindings. A rejected annotation should fall back to diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index ac995e8bf0..51afcd6c42 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -66,6 +66,45 @@ for from_count in range(count): reveal_type(from_count) # revealed: int ``` +Narrowing established by a non-empty loop remains available after the loop. + +```py +def narrowing_after_non_empty_range(value: int | None) -> None: + for _ in range(1): + if value is None: + return + + reveal_type(value) # revealed: int +``` + +The same narrowing is preserved at module scope. + +```py +def get_value() -> int | None: + return None + +module_value = get_value() + +for _ in range(1): + if module_value is None: + raise RuntimeError + +reveal_type(module_value) # revealed: int +``` + +It also works in a class body. + +```py +class Example: + value = get_value() + + for _ in range(1): + if value is None: + raise RuntimeError + + reveal_type(value) # revealed: int +``` + The emptiness refinement is independent of the order in which range values are assigned: ```py @@ -1719,6 +1758,28 @@ for _ in iterable(): x ``` +### Deletions in nested loops reach the outer loop + +A deletion followed by `continue` in an inner loop can remain visible after a later `break`. The +variable can be unbound on the next outer iteration, even when exhausting the inner loop returns +from the function. + +```py +def f(flags: list[bool]): + x = 0 + for _ in flags: + x # error: [possibly-unresolved-reference] + for stop in flags: + if stop: + break + x = 0 + del x + continue + else: + return + x # error: [possibly-unresolved-reference] +``` + ### Bindings in a loop are possibly-unbound after the loop ```py @@ -1751,6 +1812,23 @@ for _ in range(1_000_000): reveal_type(x) # revealed: int ``` +### Unpacking alongside a recursively growing value + +The first element remains precise even when its sibling's type grows on each loop iteration. Reading +each literal element independently preserves that information during cycle recovery. + +```py +x = 0 +for _ in range(10): + first, x = (1, (x,)) + reveal_type(first) # revealed: Literal[1] + +x = 0 +for _ in range(10): + first, x = [1, (x,)] + reveal_type(first) # revealed: Literal[1] +``` + ### Avoid oscillations We need to avoid oscillating cycles in cases like the following, where the type of one of these loop @@ -1930,7 +2008,7 @@ for _ in range(1_000_000): break node = node.next reveal_type(node) # revealed: Node -reveal_type(node.next) # revealed: Node | None +reveal_type(node.next) # revealed: None | Node ``` ### Nested collection cycles do not panic @@ -1980,7 +2058,10 @@ def _(): nonlocal y # error: [invalid-syntax] "name `y` is used prior to nonlocal declaration" ``` -### Loop header definitions don't shadow member bindings +### Rebinding an object before an unconditional `break` + +Rebinding an object followed by an unconditional `break` does not affect its members at the start of +the loop, because the new object never reaches another iteration. ```py class C: @@ -2002,3 +2083,61 @@ for _ in range(1): d = [] break ``` + +### Rebinding an object resets attribute narrowing across iterations + +The first iteration sees the initial object; later iterations see a replacement narrowed at the end +of the previous iteration. A replacement's attribute initially has the full declared union. + +```py +class Box: + value: int | str | None + +def example(box: Box): + assert isinstance(box.value, int) + reveal_type(box.value) # revealed: int + + for _ in range(2): + # The first iteration sees int; subsequent iterations see str. + reveal_type(box.value) # revealed: int | str + + box = Box() + reveal_type(box.value) # revealed: int | str | None + + assert isinstance(box.value, str) + + # The loop is non-empty, so the current value has been narrowed to str. + reveal_type(box.value) # revealed: str +``` + +### Boolean attribute narrowing after rebinding + +The loop body can observe either the initial object or a replacement from a previous iteration. A +guard on the initial object therefore does not narrow `box.value` throughout the loop. + +```py +class Box: + value: bool + +def f(box: Box, replacement: Box): + if box.value: + return + + for _ in range(2): + reveal_type(box.value) # revealed: bool + box = replacement +``` + +Narrowing established on a replacement object also reaches the next iteration. If each replacement +has `value` narrowed to `False`, the loop body keeps that narrowing. + +```py +def narrowed_replacement(box: Box, replacement: Box): + if box.value: + return + + for _ in range(2): + reveal_type(box.value) # revealed: Literal[False] + box = replacement + assert not box.value +``` diff --git a/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md b/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md index 45b47c3ad1..8c6556b581 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md @@ -412,6 +412,74 @@ while x < 10: reveal_type(x) # revealed: Literal[1] ``` +### Deletions in nested loops reach the outer loop + +An inner loop can delete a variable on one iteration, then exit through `break` on a later +iteration. The variable can therefore be unbound both after the inner loop and at the start of the +next outer iteration. + +```py +def stop() -> bool: + raise NotImplementedError + +def f(repeat: bool): + x = 0 + while repeat: + x # error: [possibly-unresolved-reference] + while True: + if stop(): + break + x = 0 + del x + x # error: [possibly-unresolved-reference] +``` + +### Rebinding after an inner loop restores boundness + +An inner loop's deletion does not make a variable possibly unbound on later outer iterations if the +variable is reassigned before reaching the next iteration. + +```py +def stop() -> bool: + raise NotImplementedError + +def f(repeat: bool): + x = 0 + while repeat: + reveal_type(x) # revealed: Literal[0] + while True: + if stop(): + break + x = 0 + del x + x = 0 +``` + +### Statically unreachable deletions in nested loops preserve boundness + +A deletion guarded by an impossible comparison does not make the variable possibly unbound, even +through several nested loops. Evaluating the loop conditions and comparison depends on bindings from +the enclosing loops. + +```py +def stop() -> bool: + raise NotImplementedError + +def f(repeat: bool): + x = 1 + while repeat: + reveal_type(x) # revealed: Literal[1] + while x: # error: [redundant-condition] "This condition is always true" + if stop(): + break + while x: # error: [redundant-condition] "This condition is always true" + if stop(): + break + if x == 4: + del x + reveal_type(x) # revealed: Literal[1] +``` + ### Bindings in a loop are possibly-unbound after the loop ```py @@ -471,6 +539,89 @@ while random(): reveal_type(y) # revealed: Literal[1, 2] ``` +### Loop increments guarded by chained comparisons converge + +A negated comparison chain validates an increment before the loop updates its offset. Inference +converges even though the guard depends on the value added to the loop variable. + +```py +def advance(data: bytes, offset: int) -> None: + while offset < len(data): + byte = data[offset] + if byte == 0: + return + step = byte & 15 + if not 1 <= step <= 8: + raise ValueError + offset += step + reveal_type(offset) # revealed: int +``` + +### Loop updates guarded by compound conditions converge + +Type checks and a negated comparison chain validate a record's size before advancing the offset. +Combining these checks with `or` preserves the integer type of the updated offset. + +```py +def read_record(offset: int) -> tuple[int | None, int | None]: + return 1, 1 + +def read_records(offset: int, end: int) -> None: + while offset < end: + value, size = read_record(offset) + if not isinstance(value, int) or not isinstance(size, int) or not 0 <= size <= end - offset: + raise ValueError + offset += size + reveal_type(offset) # revealed: int +``` + +### Worklists guarded by chained comparisons converge + +A chained comparison guards both extending a worklist and inserting into a set. The set's inferred +element type remains `str` as entries are added and queued for later loop iterations. + +```py +def visit(start: str, height: int) -> None: + columns = "abc" + column = columns.index(start[0]) + row = int(start[1:]) - 1 + visited = {start} + pending = [(column, row)] + while pending: + current_column, current_row = pending.pop() + for x, y in ((current_column, current_row - 1),): + if not 0 <= y < height: + continue + visited.add(f"{columns[x]}{y + 1}") + pending.append((x, y)) + reveal_type(visited) # revealed: set[str] +``` + +### Conditional attribute updates converge + +Each batch depends on an instance attribute that is updated from the last item in the batch. The +condition and the attribute's type depend on each other across loop iterations. Inference converges, +and the condition narrows the assigned value to a non-empty `str`. + +```py +class Inventory: + after: str | None + + def next_batch(self, after: object) -> "list[Inventory]": + return [] + + def iterate(self): + while True: + item = None + batch = self.next_batch(self.after) + assert batch + for item in batch: + pass + if item and item.after: + self.after = item.after + reveal_type(self.after) # revealed: str & ~AlwaysFalsy +``` + ### Monotonic widening can keep stale loopback bindings reachable ```py @@ -636,7 +787,10 @@ while True: x = 1 ``` -### Loop header definitions don't shadow member bindings +### Rebinding an object before an unconditional `break` + +Rebinding an object followed by an unconditional `break` does not affect its members at the start of +the loop, because the new object never reaches another iteration. ```py class C: @@ -684,5 +838,166 @@ def escaped(limit: int) -> int: return px ``` +The same applies to narrowing from a guard before the loop. The condition is always true on the only +iteration, but the replacement object's attribute is not narrowed after the `break`. + +```py +class Box: + value: bool + +def f(box: Box): + if box.value: + return + + while reveal_type(not box.value): # revealed: Literal[True] + box = Box() + break + + reveal_type(box.value) # revealed: bool +``` + +## Rebinding an object resets attribute narrowing across iterations + +The first iteration sees the initial object's `int` value; later iterations see the replacement's +`str` value. The type at the start of the body is therefore `int | str`. Rebinding restores the full +declared attribute type, including `None`, until the replacement is narrowed again. + +```py +class Box: + value: int | str | None + +def example(box: Box): + assert isinstance(box.value, int) + reveal_type(box.value) # revealed: int + + while True: + reveal_type(box.value) # revealed: int | str + + box = Box() + reveal_type(box.value) # revealed: int | str | None + + assert isinstance(box.value, str) +``` + +## Rebinding an object affects the loop condition + +A guard before the loop only constrains the initial object. Rebinding `box` can make `box.value` +true on a later iteration, so the loop condition is not always true. + +```py +class Box: + value: bool + +def condition(box: Box, replacement: Box): + if box.value: + return + + reveal_type(box.value) # revealed: Literal[False] + + while reveal_type(not box.value): # revealed: bool + box = replacement +``` + +When the loop exits normally, `box.value` is `True`. + +```py +def normal_exit(box: Box, replacement: Box): + if box.value: + return + + reveal_type(box.value) # revealed: Literal[False] + + while not box.value: + reveal_type(box.value) # revealed: Literal[False] + box = replacement + reveal_type(box.value) # revealed: bool + + reveal_type(box.value) # revealed: Literal[True] +``` + +## Rebinding an object before `continue` + +Rebinding also invalidates attribute narrowing when the next iteration is reached through +`continue`. A replacement whose `value` is `True` can end the loop. + +```py +class Box: + value: bool + +def f(box: Box, replacement: Box): + if box.value: + return + + while not box.value: + box = replacement + continue + + reveal_type(box.value) # revealed: Literal[True] +``` + +## Rebinding in an inner loop reaches the next outer iteration + +An inner loop can rebind `box` on one iteration, then exit through a `break` before reaching the +assignment again. The replacement is visible both after the inner loop and on later outer +iterations, so the initial guard no longer narrows `box.value`. + +```py +class Box: + value: bool + +def stop() -> bool: + raise NotImplementedError + +def f(box: Box, replacement: Box, repeat: bool): + if box.value: + return + + while repeat: + reveal_type(box.value) # revealed: bool + while True: + if stop(): + break + box = replacement + reveal_type(box.value) # revealed: bool +``` + +## Rebinding a member resets nested attribute narrowing + +Replacing `wrapper.box` invalidates narrowing of `wrapper.box.value` on subsequent iterations, even +though the outer `wrapper` object is unchanged. + +```py +class Box: + value: bool + +class Wrapper: + box: Box + +def f(wrapper: Wrapper, replacement: Box): + if wrapper.box.value: + return + + while not wrapper.box.value: + wrapper.box = replacement + + reveal_type(wrapper.box.value) # revealed: Literal[True] +``` + +## Rebinding a collection resets subscript narrowing + +A guard on the initial tuple's element does not constrain the corresponding element of a replacement +tuple. The replacement can therefore end the loop. + +```py +def f(flags: tuple[bool], replacement: tuple[bool]): + if flags[0]: + return + + while not flags[0]: + flags = replacement + + reveal_type(flags[0]) # revealed: Literal[True] +``` + [divergent_debugging]: https://github.com/astral-sh/ruff/pull/22794#issuecomment-3852095578 [real cases]: https://github.com/Finistere/antidote/blob/7d64ff76b7e283e5d9593ca09ea7a52b9b054957/src/antidote/_internal/localns.py#L34-L35 diff --git a/crates/ty_python_semantic/resources/mdtest/metaclass.md b/crates/ty_python_semantic/resources/mdtest/metaclass.md index 6393a9560a..d5c6aebfb1 100644 --- a/crates/ty_python_semantic/resources/mdtest/metaclass.md +++ b/crates/ty_python_semantic/resources/mdtest/metaclass.md @@ -671,6 +671,254 @@ class C(A, B): ... reveal_type(C.__class__) # revealed: ``` +## Protocol metaclass inheritance + +A protocol declared in Python source uses `typing._ProtocolMeta`, which derives from `ABCMeta`. +Explicitly specifying `ABCMeta` selects the more derived `_ProtocolMeta`. A compatible custom +metaclass is preserved, including when its base is obtained by calling `type`. + +```py +from abc import ABC, ABCMeta +from typing import Protocol + +class P(Protocol): ... +class Base(ABC): ... +class Combined(Base, P): ... +class ExplicitABC(Protocol, metaclass=ABCMeta): ... + +reveal_type(type(Combined)) # revealed: +reveal_type(type(ExplicitABC)) # revealed: + +class Meta(type(Protocol)): ... +class Derived(Base, P, metaclass=Meta): ... + +reveal_type(type(Derived)) # revealed: +``` + +An unrelated metaclass conflicts with this constraint, both when declaring a protocol and when +subclassing an existing one. + +```py +class Unrelated(type): ... + +# error: [conflicting-metaclass] "`_ProtocolMeta` (metaclass of base class `typing.Protocol`)" +class InvalidDirect(Protocol, metaclass=Unrelated): ... +class InvalidSubclass(P, metaclass=Unrelated): ... # error: [conflicting-metaclass] +``` + +Deriving an otherwise unrelated metaclass from `ABCMeta` does not make it compatible with +`_ProtocolMeta`. + +```py +class UnrelatedABC(ABCMeta): ... +class InvalidABC(P, metaclass=UnrelatedABC): ... # error: [conflicting-metaclass] +``` + +## Protocol metaclass fallback in typeshed + +Typeshed can list `Protocol` as a base even when the runtime class does not inherit from +`typing.Protocol`. For example, `collections.abc.Iterable` is an ordinary abstract base class at +runtime. Its typeshed definition therefore does not establish that the class has `_ProtocolMeta` as +its metaclass. + +When no custom metaclass is selected, ty uses `ABCMeta` instead of `type` for class attribute +lookup. This fallback makes ABC methods such as `register` available: + +```py +from collections.abc import Iterable + +class Registered: ... + +reveal_type(type(Iterable)) # revealed: +reveal_type(Iterable.register(Registered)) # revealed: type[Registered] +``` + +The inferred `ABCMeta` is not a claim about the exact runtime metaclass. It does not constrain +subclasses, so a subclass can choose a metaclass unrelated to `ABCMeta` without a conflict. + +```py +class Meta(type): ... +class Direct(Iterable[object], metaclass=Meta): ... + +reveal_type(type(Direct)) # revealed: +``` + +## Protocol metaclass fallback in a custom typeshed + +The same fallback applies to a configured typeshed. These minimal standard-library stubs provide the +types used below. + +```toml +[environment] +typeshed = "/typeshed" +``` + +`/typeshed/stdlib/builtins.pyi`: + +```pyi +class object: ... +class type: ... +class tuple: ... +``` + +`/typeshed/stdlib/abc.pyi`: + +```pyi +class ABCMeta(type): ... +``` + +`/typeshed/stdlib/typing.pyi`: + +```pyi +from abc import ABCMeta + +class _SpecialForm: ... + +Protocol: _SpecialForm + +class _ProtocolMeta(ABCMeta): ... + +def reveal_type(obj, /): ... +``` + +`/typeshed/stdlib/interface.pyi`: + +```pyi +from typing import Protocol + +class Interface(Protocol): ... +``` + +The typeshed protocol gets the lookup fallback, but an unrelated explicit metaclass wins. + +`main.py`: + +```py +from interface import Interface +from typing import reveal_type + +class Meta(type): ... +class Derived(Interface, metaclass=Meta): ... + +reveal_type(Interface.__class__) # revealed: +reveal_type(Derived.__class__) # revealed: +``` + +## Inheritance of a typeshed protocol metaclass fallback + +A source-defined subclass inherits the same non-constraining fallback from a typeshed protocol. An +indirect or dynamically created subclass can choose an unrelated metaclass. `Child` can also share a +subclass with `Other`, despite `Other`'s final metaclass, and `type[Child]` is not a subtype of +`ABCMeta`. + +```py +from abc import ABCMeta +from collections.abc import Iterable +from typing import final +from ty_extensions import static_assert +from ty_extensions._internal import is_disjoint_from, is_subtype_of + +class Child(Iterable[object]): ... + +@final +class Meta(type): ... + +class Other(metaclass=Meta): ... +class Left(Child, Other): ... +class Right(Other, Child): ... + +Dynamic = type("Dynamic", (Iterable,), {}) +Combined = type("Combined", (Child, Other), {}) + +class ViaDynamic(Dynamic, metaclass=Meta): ... + +reveal_type(type(Child)) # revealed: +reveal_type(type(Left)) # revealed: +reveal_type(type(Right)) # revealed: +reveal_type(type(Combined)) # revealed: +reveal_type(type(ViaDynamic)) # revealed: +static_assert(not is_disjoint_from(Child, Other)) +static_assert(not is_subtype_of(type[Child], ABCMeta)) +``` + +Explicitly listing `Protocol` in source declares a new protocol with a `_ProtocolMeta` constraint, +even when another base contributes only the typeshed fallback. + +```py +from typing import Protocol + +class SourceProtocol(Iterable[object], Protocol): ... +class Invalid(SourceProtocol, metaclass=Meta): ... # error: [conflicting-metaclass] +``` + +## Explicit typeshed protocol metaclasses + +Explicitly choosing the inferred `ABCMeta` makes it a real constraint on later subclasses. + +```py +from collections.abc import Iterable + +class Meta(type): ... +class Pinned(Iterable[object], metaclass=type(Iterable)): ... +class Invalid(Pinned, metaclass=Meta): ... # error: [conflicting-metaclass] + +reveal_type(type(Pinned)) # revealed: +``` + +## Typeshed protocol metaclass attributes in the class namespace + +The `ABCMeta` fallback inferred from typeshed bases does not guarantee that the runtime metaclass +creates attributes in the class namespace. It therefore does not make attributes such as +`__abstractmethods__` available on instances. + +For example, typeshed declares `weakref.WeakSet` as a `MutableSet` subclass, but at runtime it +inherits directly from `object` and has metaclass `type`. + +```py +from weakref import WeakSet + +class Child(WeakSet[object]): ... + +reveal_type(type(Child)) # revealed: + +def f(child: Child): + child.__abstractmethods__ # error: [unresolved-attribute] +``` + +The fallback also does not constrain the types of attributes defined in the class namespace. For +example, a `WeakSet` subclass can define its own `__abstractmethods__` without matching `ABCMeta`'s +declaration. + +```py +class OwnAttribute(WeakSet[object]): + __abstractmethods__ = 1 +``` + +## Built-in collection metaclasses + +Typeshed includes collection ABCs in some built-in classes' bases to describe their interfaces. +Those stub-only bases do not change the built-ins' runtime metaclasses or introduce conflicts when +they are subclassed. + +```py +from collections import deque +from types import GeneratorType + +reveal_type(type(str)) # revealed: +reveal_type(type(tuple)) # revealed: +reveal_type(type(list)) # revealed: +reveal_type(type(dict)) # revealed: +reveal_type(type(deque)) # revealed: +reveal_type(type(GeneratorType)) # revealed: + +class Meta(type): ... +class CustomList(list[int], metaclass=Meta): ... +class OrdinaryList(list[int]): ... + +reveal_type(type(CustomList)) # revealed: +reveal_type(type(OrdinaryList)) # revealed: +``` + ## Metaclass metaclass A class has an explicit base with a custom metaclass. That metaclass itself has a custom metaclass. @@ -772,6 +1020,32 @@ reveal_type(D) # revealed: reveal_type(D.__class__) # revealed: ``` +## Metaclass bounds + +With a metaclass annotated as `type[Meta]`, the resulting class and its subclasses are instances of +`Meta`, and therefore of `type`. Matching a `type[C]` value against `type()` is exhaustive, but +returning it as `int` is invalid. + +```py +from typing_extensions import assert_never + +class Meta(type): ... + +def _(meta: type[Meta]): + class C(metaclass=meta): ... + + def check(cls: type[C]) -> None: + reveal_type(cls.__class__) # revealed: type[Meta] + match cls: + case type(): + pass + case _: + assert_never(cls) + + def as_int(cls: type[C]) -> int: + return cls # error: [invalid-return-type] +``` + ## Diagnostic range ```py diff --git a/crates/ty_python_semantic/resources/mdtest/mro.md b/crates/ty_python_semantic/resources/mdtest/mro.md index 4f123c4abe..a748e4bd13 100644 --- a/crates/ty_python_semantic/resources/mdtest/mro.md +++ b/crates/ty_python_semantic/resources/mdtest/mro.md @@ -691,7 +691,7 @@ reveal_mro(Sub) ```py from typing_extensions import Protocol, TypeVar, Generic -T = TypeVar("T") +T = TypeVar("T", covariant=True) class Foo(Protocol): ... class Bar(Protocol[T]): ... diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index a917450c4b..ef55a98724 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -132,6 +132,33 @@ reveal_type(alice5.id) # revealed: int reveal_type(alice5.name) # revealed: str ``` +### Fields declared in stubs + +An annotation-only field in a stub remains a required constructor argument. An explicit ellipsis +assignment represents a default and makes its field optional. + +`records.pyi`: + +```pyi +from typing import NamedTuple + +class Record(NamedTuple): + required: int + optional: str = ... +``` + +The generated constructor requires the first field but permits omitting the second: + +```py +from records import Record + +reveal_type(Record.__new__) # revealed: [Self](_cls: type[Self], required: int, optional: str = ...) -> Self + +Record(1) +Record(1, "value") +Record() # error: [missing-argument] +``` + ### Name mismatch diagnostics @@ -1232,6 +1259,36 @@ reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str reveal_type(LegacyProperty("height", 3.4).value) # revealed: int | float ``` +### Methods with default type parameters + +Methods on generic named tuples honor explicit type arguments that override their defaults, +including when accessed through a subclass. The `_make` class method returns the specialized +receiver type. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import NamedTuple + +class Box[T = int](NamedTuple): + value: T + +class Child[T = int](Box[T]): + pass + +def methods(box: Box[str], child: Child[str]) -> None: + reveal_type(box._asdict()) # revealed: dict[str, Any] + reveal_type(child._asdict()) # revealed: dict[str, Any] + reveal_type(Box[str]._make(("value",))) # revealed: Box[str] + reveal_type(Child[str]._make(("value",))) # revealed: Child[str] + +reveal_type(Box._make((1,))) # revealed: Box[int] +reveal_type(Child._make((1,))) # revealed: Child[int] +``` + ### Functional syntax with generics Generic namedtuples can also be defined using the functional syntax with type variables in the field @@ -1464,7 +1521,8 @@ satisfy: ```py def expects_named_tuple(x: typing.NamedTuple): reveal_type(x) # revealed: tuple[object, ...] & NamedTupleLike - reveal_type(x._make) # revealed: bound method type[NamedTupleLike]._make(iterable: Iterable[Any]) -> NamedTupleLike + # revealed: bound method (type[tuple[object, ...]] & type[NamedTupleLike])._make(iterable: Iterable[Any]) -> tuple[object, ...] & NamedTupleLike + reveal_type(x._make) # revealed: bound method (tuple[object, ...] & NamedTupleLike)._replace(...) -> tuple[object, ...] & NamedTupleLike reveal_type(x._replace) # revealed: Overload[(value: tuple[object, ...], /) -> tuple[object, ...], [T](value: tuple[T, ...], /) -> tuple[object, ...]] diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md b/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md index 58a8774b7c..47e14971e9 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md @@ -269,6 +269,268 @@ def _(x: int | None): reveal_type(x) # revealed: None ``` +## Alias defined in the `if` branch + +Only the `if` branch relates `condition` to `x`. After the branches merge, the independent +assignment can produce either condition outcome for any value of `x`. + +```py +def _(x: int | None, flag: bool, other: bool): + if flag: + condition = x is not None + else: + condition = other + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Alias defined in the `else` branch + +The same applies when the `else` branch assigns the check. Its relationship to `x` does not hold for +the independent assignment in the `if` branch. + +```py +def _(x: int | None, flag: bool, other: bool): + if flag: + condition = other + else: + condition = x is not None + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Alias assigned conditionally + +If the branch is skipped, `condition` keeps the independent argument value. Neither outcome of the +condition narrows `x`. + +```py +def _(x: int | None, flag: bool, condition: bool): + if flag: + condition = x is not None + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Possibly unbound local alias + +A missing local binding raises `UnboundLocalError` instead of falling back to an outer scope. If +evaluation succeeds, `condition` comes from the narrowing expression. + +```py +def _(x: int | None, flag: bool): + if flag: + condition = x is not None + + if condition: # error: [possibly-unresolved-reference] + reveal_type(x) # revealed: int + else: + reveal_type(x) # revealed: None +``` + +## Class-local alias with a global fallback + +An unbound class-local name falls back to the global binding. That independent boolean can produce +either condition outcome without narrowing the class-local target. + +```py +condition: bool = True + +def _(value: int | None, flag: bool): + class C: + x = value + if flag: + condition = x is not None + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Global alias assigned conditionally + +If the assignment is skipped, a `global` name keeps its independent module-level value. Neither +outcome narrows the local target. + +```py +condition: bool = True + +def _(x: int | None, flag: bool): + global condition + if flag: + condition = x is not None + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Alias assigned on a terminal branch + +A check assigned on a branch that returns cannot describe the condition used afterward. The +independent assignment is the only one that reaches this use, so neither outcome narrows `x`. + +```py +def _(x: int | None, flag: bool, other: bool): + condition = other + if flag: + condition = x is not None + return + + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None +``` + +## Alias assigned on the only continuing branch + +If the other branch returns, the check is the only assignment that reaches the condition. Its +relationship to `x` still permits narrowing after the branches merge. + +```py +def _(x: int | None, flag: bool): + if flag: + condition = x is not None + else: + return + + if condition: + reveal_type(x) # revealed: int + else: + reveal_type(x) # revealed: None +``` + +## Alias assigned on an always-taken branch + +An alias assigned under a condition that is always true is definitely initialized. Both outcomes of +the alias narrow its target. + +```py +def get_value() -> int | str: + return 1 + +x = get_value() +if None is None: + is_int = isinstance(x, int) + +if is_int: + reveal_type(x) # revealed: int +else: + reveal_type(x) # revealed: str +``` + +## Alias reassignment on the false branch + +If the final `check` is false, the `None` check ran and ruled out `None`. Otherwise, assigning +`True` to `value` leaves a `bool` on that path too. + +```py +def _(value: bool | None, check: bool): + if not check: + check = value is None + if check: + reveal_type(value) # revealed: bool | None + value = True + reveal_type(value) # revealed: bool +``` + +## Alias preserved across loop iterations + +The cached value and its alias are initialized together on the first iteration. Later iterations +reuse both, so the alias still narrows the cached value. + +```py +def _(value: int | str): + cached = None + for _ in range(2): + if cached is None: + cached = value + is_int = isinstance(cached, int) + # TODO: recognize that `is_int` is initialized on the first iteration. + if is_int: # error: [possibly-unresolved-reference] + reveal_type(cached) # revealed: int + else: + reveal_type(cached) # revealed: str +``` + +## Cached alias updated when false + +A cached condition starts out false and is replaced by the `None` check whenever it is false. A +later true condition therefore implies that `x` is not `None`, even across loop iterations. + +```py +def _(x: int | None): + condition = False + for _ in range(2): + if not condition: + condition = x is not None + if condition: + reveal_type(x) # revealed: int +``` + +## Cached alias updated when true + +The same reasoning applies with the outcomes reversed: a false condition can only come from the +`None` check, so it rules out `None`. + +```py +def _(x: int | None): + condition = True + for _ in range(2): + if condition: + condition = x is None + if not condition: + reveal_type(x) # revealed: int +``` + +## Alias reassigned on a loop backedge + +A different assignment can reach the condition from an earlier iteration. That assignment does not +describe `x`, so the condition cannot narrow it. + +```py +def _(x: int | None, flags: list[bool], other: bool): + condition = False + for flag in flags: + if flag: + condition = x is not None + if condition: + reveal_type(x) # revealed: int | None + else: + reveal_type(x) # revealed: int | None + if other: + condition = True +``` + +## Alias replaced by an independent loop-carried value + +When `replace` is false, the value carried from the first iteration makes `condition` true on the +second iteration even if `x` is `None`. The condition therefore cannot narrow `x`. + +```py +def _(x: int | None, replace: bool): + carry = False + for _ in range(2): + condition = carry + if replace: + condition = x is not None + if condition: + reveal_type(x) # revealed: int | None + carry = not condition +``` + ## Nested scope can preserve alias > TODO: This feature is not supported yet. diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/callable.md b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md index 66f5795d93..978e8323a5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md @@ -236,14 +236,18 @@ reveal_type(CollectionsAbcCallable) # revealed: None: match subj: case abc.Callable(): @@ -273,6 +277,10 @@ def _(subj: abc.Callable[..., str]) -> None: ```py import typing +def accepts_type(x: type): ... + +accepts_type(typing.Callable) # error: [invalid-argument-type] + def _(subj: None | typing.Callable[..., str]) -> None: match subj: # error: [invalid-match-pattern] "`` cannot be used in a class pattern because it is not a type" diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md b/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md index 4ead907a2e..c959b0255b 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md @@ -366,6 +366,22 @@ def _(x: tuple[Literal["a"], A] | tuple[Literal["b"], B]): reveal_type(x) # revealed: tuple[Literal["a"], A] ``` +A tuple can have several literal tags. Matching a different tag rules out that tuple, while +excluding only one of its possible tags leaves it in the union: + +```py +def multiple_tags(x: tuple[Literal["a"], int] | tuple[Literal["b", "c"], str]): + if "a" == x[0]: + reveal_type(x) # revealed: tuple[Literal["a"], int] + else: + reveal_type(x) # revealed: tuple[Literal["b", "c"], str] + + if x[0] != "b": + reveal_type(x) # revealed: tuple[Literal["a"], int] | tuple[Literal["b", "c"], str] + else: + reveal_type(x) # revealed: tuple[Literal["b", "c"], str] +``` + Enum literals are supported as tuple tags, including `IntEnum` literals: ```py @@ -406,6 +422,19 @@ def _(x: tuple[Literal["tag1"], A] | tuple[str, B]): reveal_type(x) # revealed: tuple[str, B] ``` +This also applies when a tag is a union of literal and non-literal types. The non-literal +alternative can compare equal to the tag being checked: + +```py +class MatchesAnything: + def __eq__(self, other: object) -> bool: + return True + +def nonliteral_tag_union(x: tuple[Literal["a"], int] | tuple[Literal["b"] | MatchesAnything, str]): + if x[0] == "a": + reveal_type(x) # revealed: tuple[Literal["a"], int] | tuple[Literal["b"] | MatchesAnything, str] +``` + If the index is out of bounds for any tuple in the union, we also skip narrowing (a diagnostic will be emitted elsewhere for the out-of-bounds access): diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md index 24b26d0f25..948b4cf45f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md @@ -5,7 +5,7 @@ ```py def _(x: int): if x == 1: - reveal_type(x) # revealed: Literal[1, True] + reveal_type(x) # revealed: Literal[1] elif x == 2: reveal_type(x) # revealed: Literal[2] elif x != 3: diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index 688b350a10..5b3b86d3ef 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -314,8 +314,8 @@ def compare_functional_flags(left: FunctionalPermission, right: FunctionalPermis reveal_type(left == right) # revealed: bool ``` -An enum with a custom `_missing_` method can create unnamed members, so two values need not be equal -even when only one member is declared: +A custom `_missing_` method does not change the enum's static member set, so an enum with one +declared member remains a singleton: ```py from enum import Enum @@ -325,13 +325,13 @@ class MissingValueEnum(Enum): @classmethod def _missing_(cls, value: object) -> "MissingValueEnum": - return object.__new__(cls) + return cls.ONLY -def compare_open_enums(left: MissingValueEnum, right: MissingValueEnum): - reveal_type(left == right) # revealed: bool +def compare_custom_missing_enums(left: MissingValueEnum, right: MissingValueEnum): + reveal_type(left == right) # revealed: Literal[True] if left != right: - reveal_type(left) # revealed: MissingValueEnum + reveal_type(left) # revealed: Never ``` A custom enum metaclass can add members that do not appear in the class body. Two values of a @@ -664,24 +664,40 @@ def compare_false_to_integer_enum(left: MixedLeft1 | Literal[False], right: Mixe reveal_type(right) # revealed: Literal[MixedRight0.A] ``` -An open identity-comparing enum can still be narrowed to all of its declared members. Undeclared -runtime members are not retained merely because every declared member matches: +An identity-comparing enum with a custom `_missing_` method remains equivalent to the union of its +declared members: ```py from enum import Enum from typing import Literal -class OpenIdentity(Enum): +class CustomMissingIdentity(Enum): A = "a" B = "b" @classmethod - def _missing_(cls, value: object) -> "OpenIdentity": + def _missing_(cls, value: object) -> "CustomMissingIdentity": raise ValueError class OtherIdentity(Enum): C = "c" +def compare_custom_missing_identity( + left: CustomMissingIdentity | OtherIdentity, + right: Literal[CustomMissingIdentity.A, CustomMissingIdentity.B], +): + if left == right: + reveal_type(left) # revealed: CustomMissingIdentity +``` + +A metaclass can inject undeclared members, leaving an identity-comparing enum genuinely open. +Comparing against its declared members can still exclude those undeclared members. + +```py +class OpenIdentity(Enum, metaclass=InjectingEnumMeta): + A = "a" + B = "b" + def compare_open_identity( left: OpenIdentity | OtherIdentity, right: Literal[OpenIdentity.A, OpenIdentity.B], @@ -711,7 +727,8 @@ reveal_type(IntegerAliases.ZERO == IntegerAliases.FALSE) # revealed: Literal[Tr ``` Plain enum members from different classes use identity comparison, even when their declared values -are equal. Custom comparison methods and open scalar enums remain ambiguous: +are equal. Custom comparison methods remain ambiguous, while scalar enums can compare across enum +classes: ```py from enum import Enum, StrEnum @@ -748,13 +765,25 @@ class CustomNeLeft(StrEnum): reveal_type(CustomNeLeft.MEMBER == CustomRight.MEMBER) # revealed: Literal[True] reveal_type(CustomNeLeft.MEMBER != CustomRight.MEMBER) # revealed: bool -class OpenLeft(StrEnum): +class CustomMissingLeft(StrEnum): MEMBER = "shared" @classmethod - def _missing_(cls, value: object) -> "OpenLeft": + def _missing_(cls, value: object) -> "CustomMissingLeft": raise ValueError +def compare_custom_missing(left: CustomMissingLeft, right: CustomRight): + if left == right: + reveal_type(left) # revealed: CustomMissingLeft +``` + +A metaclass can add undeclared scalar members, so cross-enum comparison must retain the full open +enum: + +```py +class OpenLeft(StrEnum, metaclass=InjectingEnumMeta): + MEMBER = "shared" + def compare_open(left: OpenLeft, right: CustomRight): if left == right: reveal_type(left) # revealed: OpenLeft @@ -768,8 +797,17 @@ def compare_optional_custom(left: CustomLeft | None, right: CustomRight): reveal_type(left) # revealed: CustomLeft ``` -An enum with `_missing_` may have members that do not appear in its definition. Adding `None` must -not cause the comparison to assume that its declared member is the only possible match: +A custom `_missing_` method does not affect comparison narrowing, including when the enum is +combined with `None`: + +```py +def compare_optional_custom_missing(left: CustomMissingLeft | None, right: CustomRight): + if left == right: + reveal_type(left) # revealed: CustomMissingLeft +``` + +Undeclared members of a genuinely open scalar enum must survive cross-enum comparison even when the +enum is combined with `None`: ```py def compare_optional_open(left: OpenLeft | None, right: CustomRight): @@ -1120,8 +1158,9 @@ def _(answer: CoupledInequality): ## Recursive aliases containing enum domains -Comparisons involving recursive enum aliases remain valid. Comparing against a specific enum member -narrows both branches to their remaining members while preserving any `NewType` tag. +Comparisons involving invalid recursive enum aliases still use their non-recursive members. +Comparing against a specific enum member narrows both branches to their remaining members while +preserving any `NewType` tag. ```toml [environment] @@ -1136,13 +1175,13 @@ class EnumValue(Enum): VALUE = 1 OTHER = 2 -type Recursive = EnumValue | Recursive +type Recursive = EnumValue | Recursive # error: [cyclic-type-alias-definition] def _(left: Recursive, right: EnumValue): reveal_type(left == right) # revealed: bool BrandedEnumValue = NewType("BrandedEnumValue", EnumValue) -type RecursiveBrand = BrandedEnumValue | RecursiveBrand +type RecursiveBrand = BrandedEnumValue | RecursiveBrand # error: [cyclic-type-alias-definition] def compare_recursive_brand_to_member(left: RecursiveBrand) -> None: if left == EnumValue.VALUE: @@ -1168,7 +1207,7 @@ class Number(IntEnum): TWO = 2 BrandedNumber = NewType("BrandedNumber", Number) -type Changing[T] = T | Changing[bool] +type Changing[T] = T | Changing[bool] # error: [cyclic-type-alias-definition] def compare_changing_specialization(value: Changing[BrandedNumber]) -> None: if value == Number.ONE: @@ -1183,8 +1222,8 @@ aliases does not remove their shared `bool` alternative. ```py from ty_extensions import Intersection -type RecursiveWithBool = RecursiveWithBrand | bool -type RecursiveWithBrand = RecursiveWithBool | BrandedNumber +type RecursiveWithBool = RecursiveWithBrand | bool # error: [cyclic-type-alias-definition] +type RecursiveWithBrand = RecursiveWithBool | BrandedNumber # error: [cyclic-type-alias-definition] def compare_mutually_recursive_intersection( value: Intersection[RecursiveWithBool, RecursiveWithBrand], @@ -1278,7 +1317,7 @@ def narrow_final_object_equality(value: A | B, other: A): reveal_type(value) # revealed: A ``` -Different inherited built-in implementations cannot compare equal: +Final classes with different inherited built-in equality implementations cannot compare equal: ```py from typing import final @@ -1630,8 +1669,8 @@ def custom_equality(value: AlwaysEqual | None, other: AlwaysEqual): ## Narrowing builtin types to literals -Equality with a literal narrows broad `str`, `int`, and `bytes` types to the values that compare -equal to that literal: +Equality with a literal narrows broad `str`, `int`, and `bytes` types to that literal. By default, +integer literals do not introduce the boolean values that compare equal to `0` or `1`: ```py def narrow_string(value: str): @@ -1646,8 +1685,15 @@ def narrow_reversed_string(value: str): def narrow_integer(value: int): if value == 1: - # `True == 1` at runtime. - reveal_type(value) # revealed: Literal[1, True] + reveal_type(value) # revealed: Literal[1] + +def narrow_zero(value: int): + if value == 0: + reveal_type(value) # revealed: Literal[0] + +def narrow_reversed_integer(value: int): + if 1 == value: + reveal_type(value) # revealed: Literal[1] def narrow_bytes(value: bytes): if value == b"a": @@ -2141,6 +2187,59 @@ def gradual_enum_union_inequality(value: Color | Any, other: Color): reveal_type(value) # revealed: Color | Any ``` +## Unions of gradual string literals + +Comparing a union of string literals intersected with `Any` keeps the matching alternative for +equality and removes it for inequality: + +```py +from typing import Any, Literal +from ty_extensions import Intersection + +def equality(value: Intersection[Any, Literal["a"]] | Intersection[Any, Literal["b"]]): + if value == "a": + reveal_type(value) # revealed: Any & Literal["a"] + else: + reveal_type(value) # revealed: Any & Literal["b"] + + if value != "a": + reveal_type(value) # revealed: Any & Literal["b"] + else: + reveal_type(value) # revealed: Any & Literal["a"] +``` + +Larger unions must narrow without expanding the complement of every rejected alternative, which +would make memory use grow exponentially: + +```py +def larger_union( + value: ( + Intersection[Any, Literal["a"]] + | Intersection[Any, Literal["b"]] + | Intersection[Any, Literal["c"]] + | Intersection[Any, Literal["d"]] + | Intersection[Any, Literal["e"]] + | Intersection[Any, Literal["f"]] + | Intersection[Any, Literal["g"]] + | Intersection[Any, Literal["h"]] + | Intersection[Any, Literal["i"]] + | Intersection[Any, Literal["j"]] + | Intersection[Any, Literal["k"]] + | Intersection[Any, Literal["l"]] + | Intersection[Any, Literal["m"]] + | Intersection[Any, Literal["n"]] + | Intersection[Any, Literal["o"]] + | Intersection[Any, Literal["p"]] + | Intersection[Any, Literal["q"]] + | Intersection[Any, Literal["r"]] + | Intersection[Any, Literal["s"]] + | Intersection[Any, Literal["t"]] + ), +): + if value == "a": + reveal_type(value) # revealed: Any & Literal["a"] +``` + ## Booleans and integers ```py @@ -2168,6 +2267,64 @@ def _(b: bool, i: Literal[1, 2]): reveal_type(i) # revealed: Literal[2] ``` +## Integers and booleans with non-strict equality semantics + +With non-strict equality semantics, broad integers narrow to integer literals, while boolean +literals that compare equal remain in explicitly annotated literal unions. + +```toml +[analysis] +strict-equality-semantics = false +``` + +```py +from typing import Literal + +reveal_type(1 == True) # revealed: Literal[True] + +def f(x: int, y: Literal[1, True, 2]): + if x == 1: + reveal_type(x) # revealed: Literal[1] + + if y == 1: + reveal_type(y) # revealed: Literal[1, True] + + if x in [1, 2]: + reveal_type(x) # revealed: Literal[1, 2] + + if y in [1, True]: + reveal_type(y) # revealed: Literal[1, True] +``` + +## Integers and booleans with strict equality semantics + +With strict equality semantics, broad integers are preserved, while explicitly annotated literal +unions still narrow to the integer and boolean literals that compare equal. + +```toml +[analysis] +strict-equality-semantics = true +``` + +```py +from typing import Literal + +reveal_type(1 == True) # revealed: Literal[True] + +def f(x: int, y: Literal[1, True, 2]): + if x == 1: + reveal_type(x) # revealed: int + + if y == 1: + reveal_type(y) # revealed: Literal[1, True] + + if x in [1, 2]: + reveal_type(x) # revealed: int + + if y in [1, True]: + reveal_type(y) # revealed: Literal[1, True] +``` + ## Final subclasses of scalar builtins Final subclasses can inherit the equality behavior of `int`, `str`, or `bytes`. Instances of these @@ -2310,6 +2467,40 @@ def tuple_with_erased_element_identity(value: NeverEqualTupleElement) -> None: reveal_type((LeftElement(value),) != (RightElement(value),)) # revealed: bool ``` +## Comparing sequences with tuples + +A `Sequence[object]` can be an empty tuple, so the equality branch remains reachable and we report +errors inside it: + +```py +from collections.abc import Sequence + +def _(value: Sequence[object]): + if value == (): + reveal_type(value) # revealed: Sequence[object] + 1 + "a" # error: [unsupported-operator] +``` + +## Comparing truthy sequences with literals + +A truthy sequence can still be a string or bytes object. Comparing a literal on the left with such a +sequence does not make the equality branch unreachable: + +```py +from collections.abc import Sequence + +def _(text: Sequence[str], data: Sequence[int]): + if text: + reveal_type("x" == text) # revealed: bool + reveal_type("x" != text) # revealed: bool + if "x" == text: + 1 + "a" # error: [unsupported-operator] + + if data: + reveal_type(b"x" == data) # revealed: bool + reveal_type(b"x" != data) # revealed: bool +``` + ## Narrowing with NewTypes A `NewType` constructor returns its argument unchanged at runtime. A `WrappedIdentityEnum` value can @@ -2451,6 +2642,10 @@ class B: tag: Literal["b"] field_b: str +class C1: + tag: Literal["c", 1] + field_c1: str + class Marker(Protocol): marked: bool @@ -2466,6 +2661,12 @@ class TaggedB(Protocol): @property def tag(self) -> Literal["b"]: ... +class TaggedC1(Protocol): + field_c1: str + + @property + def tag(self) -> Literal["c", 1]: ... + class Container: value: A | B | None @@ -2487,6 +2688,34 @@ def _(x: A | B): else: reveal_type(x) # revealed: A +def multiple_tags(x: A | C1): + if x.tag == "a": + reveal_type(x) # revealed: A + reveal_type(x.field_a) # revealed: int + else: + reveal_type(x) # revealed: C1 + reveal_type(x.field_c1) # revealed: str + + if "a" == x.tag: + reveal_type(x) # revealed: A + else: + reveal_type(x) # revealed: C1 + + if x.tag != "a": + reveal_type(x) # revealed: C1 + else: + reveal_type(x) # revealed: A + + if x.tag == "c": + reveal_type(x) # revealed: C1 + else: + reveal_type(x) # revealed: A | C1 + + if x.tag != "c": + reveal_type(x) # revealed: A | C1 + else: + reveal_type(x) # revealed: C1 + def truthiness_guard(value: A | B | None): # error: [overlapping-condition] "This condition does not distinguish between `A & ~AlwaysTruthy`, `B & ~AlwaysTruthy` and `None`" if not value: @@ -2526,6 +2755,14 @@ def protocol_union(value: TaggedA | TaggedB): else: reveal_type(value) # revealed: TaggedB reveal_type(value.field_b) # revealed: str + +def protocol_union_multiple_tags(value: TaggedA | TaggedC1): + if value.tag == "a": + reveal_type(value) # revealed: TaggedA + reveal_type(value.field_a) # revealed: int + else: + reveal_type(value) # revealed: TaggedC1 + reveal_type(value.field_c1) # revealed: str ``` Enum literals are also supported as attribute tags: @@ -2623,6 +2860,10 @@ def broad(value: str): else: reveal_type(value) # revealed: str & ~Literal["a"] +def broad_integer(value: int): + if value == 1: + reveal_type(value) # revealed: int + def inequality(value: str): if value != "a": reveal_type(value) # revealed: str & ~Literal["a"] diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index 60e40c5a27..5629093680 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -2,10 +2,13 @@ ## `in` for tuples +Broad integer subjects narrow to the integer literals present in the tuple. By default, equality +narrowing does not add the boolean literals that also compare equal to `0` or `1`. + ```py def _(x: int): if x in (1, 2, 3): - reveal_type(x) # revealed: Literal[1, 2, 3, True] + reveal_type(x) # revealed: Literal[1, 2, 3] else: reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[True] & ~Literal[2] & ~Literal[3] ``` @@ -117,6 +120,14 @@ def inline_set(value: Choice): else: reveal_type(value) # revealed: Literal["a", "b"] +def integer_list(value: int): + assert value in [1, 2] + reveal_type(value) # revealed: Literal[1, 2] + +def integer_set(value: int): + assert value in {0, 2} + reveal_type(value) # revealed: Literal[0, 2] + def literal_locals(value: Choice): a = "a" b = "b" @@ -252,6 +263,10 @@ def inline_set(x: str): else: reveal_type(x) # revealed: str & ~Literal["a"] & ~Literal["b"] +def integer_list(x: int): + if x in [1, 2]: + reveal_type(x) # revealed: int + class Bar: ... def broad_element(x: Bar | None, values: list[Bar]): @@ -759,7 +774,7 @@ def default_equality(x: Token | Literal[1]): def overlapping_union_member(x: int | Literal["missing"]): if x in ("missing", 1): - reveal_type(x) # revealed: Literal[1, True, "missing"] + reveal_type(x) # revealed: Literal[1, "missing"] def custom_equality(x: AlwaysEqual | Literal[1]): if x in (1,): diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 5946f625d3..5c50407756 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -681,7 +681,7 @@ from typing import Any def excludes_bounded_generic(value: BoundedCovariant[Any] | bool) -> bool: if isinstance(value, BoundedCovariant): - reveal_type(value) # revealed: BoundedCovariant[Any] + reveal_type(value) # revealed: BoundedCovariant[int & Any] return False reveal_type(value) # revealed: bool @@ -695,7 +695,7 @@ def excludes_bounded_generic_tuple( value: BoundedCovariant[Any] | bool | bytes, ) -> bool: if isinstance(value, (BoundedCovariant, bytes)): - reveal_type(value) # revealed: BoundedCovariant[Any] | bytes + reveal_type(value) # revealed: BoundedCovariant[int & Any] | bytes return False reveal_type(value) # revealed: bool @@ -1084,11 +1084,70 @@ def _(value: Concrete[int]) -> None: reveal_type(value.read()) # revealed: int ``` +## Negative narrowing for protocols with gradual members + +Negative narrowing excludes every materialization of a protocol, including when its members are +gradual. `IntReader` is a subtype of the fully materialized `Reader` protocol, so the negative +branch retains only `None`: + +```py +from typing import Any, Protocol, runtime_checkable + +@runtime_checkable +class Reader(Protocol): + def read(self) -> Any: ... + +class IntReader: + def read(self) -> int: + return 1 + +def f(reader: IntReader | None): + if isinstance(reader, Reader): + reveal_type(reader.read()) # revealed: int & Any + else: + reveal_type(reader) # revealed: None +``` + +## Narrowing iterables to containers and iterators in strict mode + +```toml +[analysis] +strict-generic-narrowing = true +``` + +Narrowing an `Iterable[T]` to an `Iterator`, or to a container type, retains its element type. See +`generics/set_theoretic.md` for more details on the assumptions behind this, and for an explanation +of the behavior of invariant containers: + +```py +from typing import Iterable, Iterator + +def f(values: Iterable[int]): + if isinstance(values, Iterator): + reveal_type(values) # revealed: Iterator[int] + reveal_type(next(values)) # revealed: int + if isinstance(values, tuple): + reveal_type(values) # revealed: tuple[int, ...] + reveal_type(values[0]) # revealed: int + if isinstance(values, frozenset): + reveal_type(values) # revealed: frozenset[int] + reveal_type(next(iter(values))) # revealed: int + if isinstance(values, list): + reveal_type(values) # revealed: Top[list[Unknown & int]] + reveal_type(values[0]) # revealed: int + if isinstance(values, set): + reveal_type(values) # revealed: Top[set[Unknown & int]] + reveal_type(next(iter(values))) # revealed: int +``` + ## Use cases: `isinstance` narrowing and generics ### Strict mode ```toml +[environment] +python-version = "3.12" + [analysis] strict-generic-narrowing = true ``` @@ -1198,6 +1257,25 @@ def _(xs: list[str] | set[str]) -> str: return "it's a set!" ``` +#### Invariance with bounded type variables + +A value of a type variable bounded by `str` can also be an instance of a `Box` specialization +through multiple inheritance. Checking `isinstance(value, Box)` cannot establish that this +specialization is `Box[T]`, so the intersection with `T` survives and the return is rejected. + +```py +class Box[T]: + value: T + +def narrow_box[T: str](value: Box[T] | T) -> Box[T]: + if isinstance(value, Box): + reveal_type(value) # revealed: Box[T@narrow_box] | (T@narrow_box & Top[Box[Unknown]]) + return value # error: [invalid-return-type] + + reveal_type(value) # revealed: T@narrow_box & ~Top[Box[Unknown]] + raise TypeError +``` + ### Gradual mode ```toml @@ -1312,6 +1390,25 @@ def _(xs: list[str] | set[str]) -> str: return "it's a set!" ``` +#### Invariance with bounded type variables + +A value of a type variable bounded by `str` can also be an instance of a `Box` specialization +through multiple inheritance. In gradual mode, `isinstance(value, Box)` preserves this overlap using +`Box[Unknown]`, which is assignable to `Box[T]`, so the return statement is (unsoundly) accepted. + +```py +class Box[T]: + value: T + +def narrow_box[T: str](value: Box[T] | T) -> Box[T]: + if isinstance(value, Box): + reveal_type(value) # revealed: Box[T@narrow_box] | (T@narrow_box & Box[Unknown]) + return value + + reveal_type(value) # revealed: T@narrow_box & ~Top[Box[Unknown]] + raise TypeError +``` + ## Narrowing recursively bounded generics (strict mode) An `isinstance()` check must not recurse indefinitely when a generic bound refers to its own class. @@ -1453,8 +1550,9 @@ Narrowing must therefore preserve the original type argument instead of substitu default. ```py -from typing import assert_never +from typing import assert_never, final +@final class Box[T: str = str]: value: T @@ -1466,7 +1564,7 @@ def box_with_default[T: str = str](value: Box[T] | T) -> Box[T]: return value if not isinstance(value, Box): - reveal_type(value) # revealed: T@box_with_default & ~Top[Box[Unknown]] + reveal_type(value) # revealed: T@box_with_default return Box[T](value) assert_never(value) @@ -1491,8 +1589,8 @@ Negative narrowing also excludes gradual specializations of the defaulted tuple ```py def excludes_defaulted_tuple(value: DefaultedTuple[Any] | bool) -> bool: if isinstance(value, DefaultedTuple): - reveal_type(value) # revealed: DefaultedTuple[Any] - reveal_type(value[0]) # revealed: Any + reveal_type(value) # revealed: DefaultedTuple[int & Any] + reveal_type(value[0]) # revealed: int & Any reveal_type(value[1]) # revealed: str return False diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index afb3c312c4..a2558cae58 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -307,7 +307,7 @@ from typing import Sequence def narrow_sequence_to_list(cls: type[Sequence[int]]) -> None: if issubclass(cls, list): reveal_type(cls) # revealed: type[Sequence[int]] & type[Top[list[Unknown]]] - reveal_type(cls()) # revealed: Sequence[int] & Top[list[Unknown]] + reveal_type(cls()) # revealed: Top[list[Unknown & int]] ``` ### Gradual mode diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/len.md b/crates/ty_python_semantic/resources/mdtest/narrow/len.md index 3eb3b43086..e916e64287 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/len.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/len.md @@ -250,6 +250,218 @@ def _(value: TrueLength | FalseLength): reveal_type(value) # revealed: FalseLength ``` +Length narrowing preserves a tuple's shape when a required element has type `Never`: + +```py +from typing import Never + +def _(value: tuple[Never, *tuple[int, ...]]) -> None: + if len(value) == 1: + reveal_type(value) # revealed: tuple[Never] +``` + +## Exact length comparisons with type variable tuples + +Narrowing a tuple's length preserves its type variable tuple, so a function can still return its +input after checking for an empty or nonempty tuple. + +```toml +[environment] +python-version = "3.12" +``` + +```py +def identity[*Ts](value: tuple[*Ts]) -> tuple[*Ts]: + if len(value) == 0: + reveal_type(value) # revealed: tuple[*Ts@identity] & tuple[()] + return value + elif len(value) == 1: + reveal_type(value) # revealed: tuple[*Ts@identity] & tuple[object] + return value + return value +``` + +Fixed prefix and suffix elements retain their types while the original pack is preserved. + +```py +def with_boundaries[*Ts](value: tuple[int, *Ts, str]) -> tuple[int, *Ts, str]: + if len(value) == 2: + reveal_type(value) # revealed: tuple[int, *Ts@with_boundaries, str] & tuple[int, str] + return value + return value +``` + +An alias for the tuple preserves the same pack identity when its length is narrowed. + +```py +type Pack[*Ts] = tuple[*Ts] + +def aliased_identity[*Ts](value: Pack[*Ts]) -> Pack[*Ts]: + if len(value) == 1: + reveal_type(value) # revealed: tuple[*Ts@aliased_identity] & tuple[object] + return value + return value +``` + +With a required `Never` element, the refined type should be `tuple[Never, *Ts] & tuple[Never]`. +TODO: [#27920](https://github.com/astral-sh/ruff/pull/27920) addresses the tuple-disjointness checks +that currently collapse this to `Never` and suppress the invalid-return diagnostic. + +```py +from typing import Never + +def never_prefix[*Ts](value: tuple[Never, *Ts]) -> str: + if len(value) == 1: + reveal_type(value) # revealed: Never + return value + return "" +``` + +## Ordered length comparisons + +Ordered length comparisons select the compatible tuple alternatives in both branches. A length check +can establish that an index is valid, including when `len` appears on the right: + +```py +def _(value: tuple[int] | tuple[int, int]): + if len(value) > 1: + reveal_type(value) # revealed: tuple[int, int] + reveal_type(value[1]) # revealed: int + else: + reveal_type(value) # revealed: tuple[int] + + if 2 <= len(value): + reveal_type(value) # revealed: tuple[int, int] + else: + reveal_type(value) # revealed: tuple[int] + + if len(value) < 2: + reveal_type(value) # revealed: tuple[int] + else: + reveal_type(value) # revealed: tuple[int, int] + + if 1 >= len(value): + reveal_type(value) # revealed: tuple[int] + else: + reveal_type(value) # revealed: tuple[int, int] +``` + +## Ordered length comparisons with variable tuples + +A variable-length tuple remains possible when some of its lengths satisfy the comparison. Its +required elements can rule it out when the comparison requires a shorter tuple: + +```toml +[environment] +python-version = "3.11" +``` + +```py +def _(value: tuple[str] | tuple[int, *tuple[bytes, ...], int]): + if len(value) > 2: + reveal_type(value) # revealed: tuple[int, *tuple[bytes, ...], int] + else: + reveal_type(value) # revealed: tuple[str] | tuple[int, *tuple[bytes, ...], int] + + if len(value) < 2: + reveal_type(value) # revealed: tuple[str] + else: + reveal_type(value) # revealed: tuple[int, *tuple[bytes, ...], int] + + if len(value) <= 1: + reveal_type(value) # revealed: tuple[str] + + if len(value) > 1_000_000_000: + reveal_type(value) # revealed: tuple[int, *tuple[bytes, ...], int] +``` + +## Ordered length comparisons with string and bytes literals + +String and bytes literals encode their lengths, so ordered comparisons can select between them. +String lengths count Unicode code points: + +```py +from typing import Literal + +def _(text: Literal["é", "ab"], data: Literal[b"", b"a"]): + if len(text) >= 2: + reveal_type(text) # revealed: Literal["ab"] + else: + reveal_type(text) # revealed: Literal["é"] + + if 0 < len(data): + reveal_type(data) # revealed: Literal[b"a"] + else: + reveal_type(data) # revealed: Literal[b""] +``` + +## Ordered length comparisons with custom lengths + +A custom type is excluded only when none of its declared lengths satisfy the comparison. A list +remains possible in either branch because its type does not encode its length: + +```py +from typing import Literal + +class Short: + def __len__(self) -> Literal[1, 2]: + return 1 + +class Long: + def __len__(self) -> Literal[3]: + return 3 + +def _(value: Short | Long | list[int]): + if len(value) > 2: + reveal_type(value) # revealed: Long | list[int] + else: + reveal_type(value) # revealed: Short | list[int] + + if len(value) < 2: + reveal_type(value) # revealed: Short | list[int] + else: + reveal_type(value) # revealed: Short | Long | list[int] +``` + +## Ordered length comparisons with type variables + +Filtering a type variable's upper bound preserves the type variable, so the narrowed value can still +be returned with its original type: + +```py +from typing import TypeVar + +TupleValue = TypeVar("TupleValue", bound=tuple[int] | tuple[str, str]) + +def identity(value: TupleValue) -> TupleValue: + if len(value) >= 2: + reveal_type(value) # revealed: TupleValue@identity & tuple[str, str] + return value + else: + reveal_type(value) # revealed: TupleValue@identity & tuple[int] + return value +``` + +## Ordered length comparisons at zero + +Lengths are nonnegative, and boolean literals compare as their integer values. Zero separates empty +tuples from nonempty tuples, while a negative upper bound excludes both alternatives: + +```py +from typing_extensions import assert_never + +def _(value: tuple[()] | tuple[int]): + if len(value) <= False: + reveal_type(value) # revealed: tuple[()] + else: + reveal_type(value) # revealed: tuple[int] + + if len(value) < -1: + assert_never(value) + else: + reveal_type(value) # revealed: tuple[()] | tuple[int] +``` + ## Regression tests Length constraints must not become stale after mutating a value that does not encode its length: diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index b659c1bfa9..d700103415 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -162,7 +162,10 @@ python-version = "3.12" strict-generic-narrowing = true ``` -A `list()` pattern retains the original `Sequence` alongside the top-materialized list. +A `list()` pattern leads to a type that retains the known element type (`int`), but prevents +`.append` from accepting any type: `value` could be a list of `int`s, or a list of `bool`s, or a +list of `Literal[1]`, etc. So whatever we would try to append might be incompatible with the actual +element type of the list. ```py from typing import Sequence @@ -170,7 +173,10 @@ from typing import Sequence def narrow_sequence_to_list(value: Sequence[int]) -> None: match value: case list(): - reveal_type(value) # revealed: Sequence[int] & Top[list[Unknown]] + reveal_type(value) # revealed: Top[list[Unknown & int]] + reveal_type(value[0]) # revealed: int + + value.append(1) # error: [invalid-argument-type] "Expected `Never`, found `Literal[1]`" case _: reveal_type(value) # revealed: Sequence[int] & ~Top[list[Unknown]] ``` @@ -189,8 +195,9 @@ strict-generic-narrowing = true ``` ```py -from typing import Any +from typing import Any, final +@final class Box[T: str = str]: value: T @@ -202,7 +209,7 @@ def box_with_default[T: str = str](value: Box[T] | T) -> Box[T]: reveal_type(value) # revealed: Box[T@box_with_default] return value case remaining: - reveal_type(remaining) # revealed: T@box_with_default & ~Top[Box[Unknown]] + reveal_type(remaining) # revealed: T@box_with_default return Box[T](remaining) ``` @@ -497,6 +504,57 @@ def match_nested_list_of_tuples_captures( reveal_type(item) # revealed: bytes ``` +## Mutable starred sequence captures + +A starred capture creates a new list, just like a starred assignment target. Inferred literal types +are promoted in that list so it can be mutated, without widening the fixed captures or the original +tuple. + +```py +value = (1, "two") +first, *assigned = value +reveal_type(assigned) # revealed: list[str] + +match value: + case [first, *rest]: + reveal_type(first) # revealed: Literal[1] + reveal_type(rest) # revealed: list[str] + rest.append("three") + reveal_type(value) # revealed: tuple[Literal[1], Literal["two"]] +``` + +Singleton values follow the same promotion rules as in a list literal. + +```py +match (1, None): + case [first, *rest]: + reveal_type(rest) # revealed: list[None | Unknown] + rest.append(2) +``` + +Explicit literal annotations are preserved in the captured list. + +```py +from typing import Literal + +def explicit_literal_capture(value: tuple[int, Literal["two"]]): + match value: + case [first, *rest]: + reveal_type(rest) # revealed: list[Literal["two"]] +``` + +## Empty starred sequence captures + +When the fixed patterns consume every element, the starred capture gets an empty list with an +unknown element type, just like an empty list literal. + +```py +match (1,): + case [first, *rest]: + reveal_type(rest) # revealed: list[Unknown] + rest.append(2) +``` + ## Captures from unions of tuples When a union contains several tuple types, matching one element can determine the types of the other @@ -562,6 +620,20 @@ def test_match_capture_filters_aliased_union_members(value: MatchPair) -> None: reveal_type(item) # revealed: int ``` +Promoting the starred capture does not widen the fixed elements used to select a union member. +Matching `1` excludes the tuple beginning with `2`, so its integer element does not contribute to +`rest`. + +```py +def inferred_union_capture(flag: bool): + value = (1, "two") if flag else (2, 3) + match value: + case [1, *rest]: + reveal_type(rest) # revealed: list[str] + rest.append("three") + reveal_type(value) # revealed: tuple[Literal[1], Literal["two"]] +``` + ## Pattern aliases An `as` pattern binds the original matched value. The binding keeps facts already known about the @@ -1207,8 +1279,9 @@ def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: ### Strict mode -An invariant generic base determines its subclass's type arguments only when every argument has one -exact solution. Unconstrained arguments and variant bases retain conservative member types. +Captures retain the type information available in the narrowed subject. A known base argument +constrains the corresponding subclass argument even if other parameters remain unconstrained. +Covariant base arguments also constrain the types of captured values. ```toml [analysis] @@ -1327,14 +1400,14 @@ def test_match_partially_specialized_generic_subclass( ) -> None: match value: case PartiallySpecializedGenericPatternChild(item=item): - reveal_type(item) # revealed: Unknown + reveal_type(item) # revealed: int def test_match_covariant_generic_subclass( value: CovariantGenericPatternBase[int], ) -> None: match value: case CovariantGenericPatternChild(item=item): - reveal_type(item) # revealed: Unknown + reveal_type(item) # revealed: int def test_match_inherited_generic_subclass_capture( value: GenericMemberBase[GenericPatternT], @@ -1362,7 +1435,124 @@ def test_match_direct_generic_pattern_preserves_declared_member(value: object) - def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: match value: case DefaultGenericPatternBox(value=int() as item): - reveal_type(item) # revealed: Unknown & int + reveal_type(item) # revealed: int +``` + +### Strict mode with a union type alias + +Strict generic narrowing preserves the specialization when an invariant generic subject is +parameterized by a PEP 695 union type alias. + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = true +``` + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class AliasPatternBase(Generic[T]): ... + +class AliasPatternChild(AliasPatternBase[T]): + item: T + +type Item = int | str + +def test_union_alias_capture(value: AliasPatternBase[Item]) -> None: + match value: + case AliasPatternChild(item=item): + # revealed: int | str + reveal_type(item) + + # error: [unresolved-attribute] "Object of type `int | str` has no attribute `nonexistent`" + item.nonexistent() +``` + +### Strict mode with a recursive type alias + +The inferred specialization also retains recursion instead of replacing a recursive alias with an +unknown type. + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = true +``` + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class AliasPatternBase(Generic[T]): ... + +class AliasPatternChild(AliasPatternBase[T]): + item: T + +type RecursiveItem = int | list[RecursiveItem] + +def test_recursive_alias_capture(value: AliasPatternBase[RecursiveItem]) -> None: + match value: + case AliasPatternChild(item=item): + # revealed: int | list[RecursiveItem] + reveal_type(item) +``` + +## Class pattern captures from intersections + +In strict mode, a captured attribute retains the constraints from every part of the subject's +intersection, just like direct attribute access: + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = true +``` + +```pyi +from typing import reveal_type +from ty_extensions import Intersection + +class A: + def a(self) -> None: ... + +class B: + def b(self) -> None: ... + +class Co[T]: + __match_args__ = ("item",) + + @property + def item(self) -> T: ... + +def keyword(value: Intersection[Co[A], Co[B]]) -> None: + reveal_type(value.item) # revealed: A & B + match value: + case Co(item=item): + reveal_type(item) # revealed: A & B + item.a() + item.b() +``` + +Positional captures also retain both constraints when the intersection order is reversed: + +```pyi +def positional(value: Intersection[Co[B], Co[A]]) -> None: + reveal_type(value.item) # revealed: B & A + match value: + case Co(item): + reveal_type(item) # revealed: B & A + item.a() + item.b() ``` ## Positional class patterns @@ -1741,6 +1931,9 @@ class CustomGet(Mapping[str, int | str]): def get(self, key: object) -> int | str | None: ... @overload def get(self, key: object, default: Default) -> int | str | Default: ... + # `Mapping.get`'s third overload takes a default of any type at all, and this one takes a + # `Default`, so it does not accept everything the method it overrides accepts + # error: [invalid-method-override] "Invalid override of method `get`: Definition is incompatible with `Mapping.get`" def get(self, key: object, default: Default | None = None) -> int | str | Default | None: if key == "item": return "custom value" @@ -2435,6 +2628,30 @@ def runtime_protocol_pattern_is_exhaustive(value: RuntimeProtocolImplementer) -> return 1 ``` +## Negative narrowing for protocols with gradual members + +The fallback case excludes the fully materialized protocol. `IntReader` is a subtype of the fully +materialized `Reader` protocol, so the fallback case retains only `None`: + +```py +from typing import Any, Protocol, runtime_checkable + +@runtime_checkable +class Reader(Protocol): + def read(self) -> Any: ... + +class IntReader: + def read(self) -> int: + return 1 + +def f(reader: IntReader | None): + match reader: + case Reader(): + reveal_type(reader.read()) # revealed: int & Any + case _: + reveal_type(reader) # revealed: None +``` + ## Members from the subject type A keyword pattern reads the attribute from the matched value. The subject type can therefore provide @@ -3129,7 +3346,12 @@ def string_pattern(value: str): def integer_pattern(value: int): match value: case 1: - reveal_type(value) # revealed: Literal[1, True] + reveal_type(value) # revealed: Literal[1] + +def zero_pattern(value: int): + match value: + case 0: + reveal_type(value) # revealed: Literal[0] def bytes_pattern(value: bytes): match value: @@ -3698,8 +3920,8 @@ def test_match_alias_ignores_custom_ne(flag: bool) -> str: ## Recursive enum aliases in value patterns -An enum value pattern narrows a recursive alias to the matching member while preserving its -`NewType` tag. +An enum value pattern uses the non-recursive members of an invalid recursive alias, narrowing to the +matching member while preserving its `NewType` tag. ```toml [environment] @@ -3715,7 +3937,7 @@ class Number(IntEnum): TWO = 2 BrandedNumber = NewType("BrandedNumber", Number) -type RecursiveNumber = BrandedNumber | RecursiveNumber +type RecursiveNumber = BrandedNumber | RecursiveNumber # error: [cyclic-type-alias-definition] def match_recursive_branded_enum(value: RecursiveNumber) -> None: match value: @@ -3729,7 +3951,7 @@ A recursive alias that changes its specialization can also contain values outsid `True` compares equal to `Number.ONE`, both branches preserve the possible boolean values. ```py -type Changing[T] = T | Changing[bool] +type Changing[T] = T | Changing[bool] # error: [cyclic-type-alias-definition] def match_changing_specialization(value: Changing[BrandedNumber]) -> None: match value: @@ -3853,8 +4075,9 @@ def _(x: Literal["foo", b"bar"] | int): pass case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] pass - # error: [overlapping-condition] - case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int + # the wildcard is reached only where the patterns above did not match, so the literals + # they named are gone from it and nothing overlaps + case _ if reveal_type(x): # revealed: int & ~Literal[42] pass ``` @@ -4070,6 +4293,20 @@ def _(x: tuple[A, Literal["tag1"]] | tuple[B, Literal["tag2"]]): reveal_type(x) # revealed: Never ``` +A tuple with several literal tags can match more than one case. Failing one of those tags leaves the +tuple available to later cases: + +```py +def multiple_tags(x: tuple[Literal["a"], int] | tuple[Literal["b", "c"], str]): + match x[0]: + case "b": + reveal_type(x) # revealed: tuple[Literal["b", "c"], str] + case "a": + reveal_type(x) # revealed: tuple[Literal["a"], int] + case _: + reveal_type(x) # revealed: tuple[Literal["b", "c"], str] +``` + Narrowing is restricted to `Literal` tag elements: ```py @@ -4127,6 +4364,20 @@ def _(x: A | B): reveal_type(x) # revealed: Never ``` +A class can also have several literal tags. A pattern outside that set of tags rules out the class: + +```py +class MultipleTags: + tag: Literal["b", "c"] + +def multiple_tags(x: A | MultipleTags): + match x.tag: + case "a": + reveal_type(x) # revealed: A + case _: + reveal_type(x) # revealed: MultipleTags +``` + Non-literal tag arms are preserved during positive narrowing: ```py diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index 0e7f9373fc..70d7c740c9 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -12,6 +12,23 @@ def _(x: int | None): reveal_type(x) # revealed: int | None ``` +Calls in both branches must not prevent complementary narrowing paths from recombining into the +original type. + +```py +class Base: ... +class Child(Base): ... + +def consume(value: object) -> None: ... +def _(value: Base): + if isinstance(value, Child): + consume(value) + else: + consume(value) + + reveal_type(value) # revealed: Base +``` + ## Narrowing can have a persistent effect if the variable is mutated in one branch ```py @@ -297,10 +314,10 @@ def _(val: int | str | None): reveal_type(val) # revealed: int ``` -## Narrowing through always-true branches +## Narrowing through statically known branches -When a terminal (`return`) is inside an always-true branch, narrowing propagates through because the -else-branch is unreachable and contributes `Never` to the union. +When a terminal (`return`) is inside the reachable branch of a statically known condition, narrowing +propagates through because the unreachable branch contributes `Never` to the union. ```py def _(x: int | None): @@ -312,27 +329,119 @@ def _(x: int | None): ``` ```py +from typing import Final + def _(x: int | None): if 1 + 1 == 2: if x is None: return reveal_type(x) # revealed: int - # TODO: should be `int` (the else-branch of `1 + 1 == 2` is unreachable) + reveal_type(x) # revealed: int + +def _(x: int | None): + if 1 + 1 != 2: + pass + else: + if x is None: + return + reveal_type(x) # revealed: int + + reveal_type(x) # revealed: int + +def _(x: int | None, flag: bool): + if 1 + 1 == 2 or flag: + if x is None: + return + + reveal_type(x) # revealed: int + +def _(x: int | None, flag: bool): + if 1 + 1 != 2 and flag: + pass + else: + if x is None: + return + + reveal_type(x) # revealed: int + +def _(x: int | None, flag: bool): + if flag: + if x is None: + return + + # An ambiguous condition must not make its other branch unreachable. reveal_type(x) # revealed: int | None + +needs_inference: Final = True + +def _(x: int | None): + if needs_inference: # error: [redundant-condition] "This condition is always true" + if x is None: + return + reveal_type(x) # revealed: int + + reveal_type(x) # revealed: int ``` This also works when the always-true condition is nested inside a narrowing branch: ```py +from typing import Literal + def _(x: int | None): if x is None: if 1 + 1 == 2: return - # TODO: should be `int` (the inner always-true branch makes the outer - # if-branch terminal) - reveal_type(x) # revealed: int | None + reveal_type(x) # revealed: int + +def _(x: int | None): + if x is None: + if needs_inference: # error: [redundant-condition] "This condition is always true" + return + + reveal_type(x) # revealed: int + +def always_true(value: object) -> Literal[True]: + return True + +def _(x: int | None): + if x is None: + if always_true(x): + return + + reveal_type(x) # revealed: int +``` + +## Statically known branches inside module and class loops + +Narrowing also propagates through a statically known branch inside a module-level loop. + +```py +def get_value() -> int | None: + return None + +while bool(input()): + value = get_value() + if 1 + 1 == 2: + if value is None: + raise RuntimeError + + reveal_type(value) # revealed: int +``` + +The same condition narrows a value inside a class-body loop. + +```py +class Example: + while bool(input()): + value = get_value() + if 1 + 1 == 2: + if value is None: + raise RuntimeError + + reveal_type(value) # revealed: int ``` ## Narrowing from `assert` should not affect reassigned variables diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md index 6d7492dd01..c292fe8927 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md @@ -1,5 +1,17 @@ # Narrowing For Truthiness Checks (`if x` or `if not x`) +## Generator expressions + +A generator object is truthy even when it yields no values. + +```py +def narrow(value: int | None) -> None: + if (value for _ in ()): + if value is None: + return + reveal_type(value) # revealed: int +``` + ## Value Literals ```py diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md index 95ec7d9816..73d6b30326 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md @@ -616,6 +616,86 @@ def _(x: object): reveal_type(x) # revealed: B & C ``` +## TypeGuard narrowing across multiple bindings + +A conditional assignment can leave two bindings with the same original type. If a type guard +replaces the type of only one binding, both the replacement and the original type remain possible. + +```py +from typing_extensions import TypeGuard + +def make_int() -> int: + return 1 + +def is_str(value: object) -> TypeGuard[str]: + return True + +def _(flag: bool): + value = make_int() + if flag: + value = make_int() + if not is_str(value): + return + + reveal_type(value) # revealed: int | str +``` + +Once both branches of a type guard rejoin, the replacement no longer applies. A call on the negative +branch must not preserve the positive branch's replacement. + +```py +def _(flag: bool): + value = make_int() + if flag: + value = make_int() + + if is_str(value): + pass + else: + make_int() + + reveal_type(value) # revealed: int +``` + +## TypeGuard joins after repeated suppressed assignments + +Conditional assignments inside suppressing context managers preserve several possible bindings. +After a type guard's branches rejoin, its replacement no longer applies to any of those bindings, +even when a nested `TypeIs` check rules out the replacement type. + +```py +from contextlib import suppress +from typing_extensions import TypeGuard, TypeIs + +def make_int() -> int: + return 1 + +def is_str(value: object) -> TypeGuard[str]: + return True + +def is_int(value: object) -> TypeIs[int]: + return True + +def _(flag: bool, value: int | None) -> None: + with suppress(Exception): + if flag: + value = make_int() + with suppress(Exception): + if flag: + value = make_int() + with suppress(Exception): + if flag: + value = make_int() + with suppress(Exception): + if flag: + value = make_int() + + if is_str(value): + if is_int(value): + reveal_type(value) # revealed: Never + reveal_type(value) # revealed: int | None +``` + ## Boolean logic with TypeGuard and TypeIs TypeGuard constraints need to properly distribute through boolean operations. diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index f8bd5f6f41..64a7fbec41 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -274,7 +274,7 @@ def union_receiver(reader: Reader[int | str]): ## Method type variables inferred from `self` -Binding an overload whose explicit receiver introduces a method type variable should infer that +Binding a method whose explicit receiver introduces a method type variable should infer that variable from the concrete receiver and apply it to the remainder of the signature. ```toml @@ -295,6 +295,9 @@ class ReceiverGeneric[T]: def method(self, value: object) -> object: return value + def single[S, U](self: "ReceiverGeneric[S]", value: U) -> tuple[S, U]: + return self.value, value + reveal_type(ReceiverGeneric[str]().method) # revealed: Overload[(value: str) -> str, (value: bytes) -> bytes] def takes_callable(fn: Callable[..., Any]) -> None: ... @@ -304,6 +307,60 @@ def use_generic_receiver[T](value: ReceiverGeneric[T]) -> None: takes_callable(value.method) ``` +Non-overloaded methods should also specialize receiver-determined type variables while preserving +other type variables for argument inference. + +```py +# revealed: bound method ReceiverGeneric[str].single[U](value: U) -> tuple[str, U] +reveal_type(ReceiverGeneric[str]().single) +reveal_type(ReceiverGeneric[str]().single(1)) # revealed: tuple[str, Literal[1]] +``` + +Type aliases in the receiver, return type, or another parameter must not conceal a method type +variable determined by the receiver. + +```py +type ReceiverAlias[T] = ReceiverGeneric[T] +type ValueAlias[T] = T + +class AliasedReceiver[T](ReceiverGeneric[T]): + def aliased_return[S](self: ReceiverAlias[S]) -> tuple[ValueAlias[S]]: + return (self.value,) + + def aliased_argument[S](self: ReceiverGeneric[S], value: ValueAlias[S]) -> None: ... + +value = AliasedReceiver[str]() + +# revealed: bound method AliasedReceiver[str].aliased_return() -> tuple[ValueAlias[str]] +reveal_type(value.aliased_return) + +# revealed: bound method AliasedReceiver[str].aliased_argument(value: ValueAlias[str]) +reveal_type(value.aliased_argument) +# error: [invalid-argument-type] "Expected `ValueAlias[str]`, found `Literal[1]`" +value.aliased_argument(1) +``` + +## Method type variables used only in the receiver + +A method type variable that appears only in the receiver does not affect argument inference or the +return type, so binding the method does not need to specialize it. + +```toml +[environment] +python-version = "3.12" +``` + +```py +class Factory: + @classmethod + def describe[Receiver](cls: type[Receiver], value: int) -> str: + return str(value) + +# revealed: bound method .describe[Receiver](value: int) -> str +reveal_type(Factory.describe) +reveal_type(Factory.describe(1)) # revealed: str +``` + ## Constrained method type variables inferred from `self` Matching a receiver against a value-constrained method type variable must reject values outside that @@ -958,6 +1015,25 @@ def parameter_type(x: int) -> int | str: return 1 ``` +An inconsistent implementation does not disable a consistently applied method decorator. Calls still +use the overload signatures. + +```py +class StaticMethod: + @overload + @staticmethod + # error: [invalid-overload] "Implementation does not accept all arguments of this overload" + def method(x: int) -> int: ... + @overload + @staticmethod + def method(x: str) -> int: ... + @staticmethod + def method(x: str) -> int: + return 0 + +reveal_type(StaticMethod().method(1)) # revealed: int +``` + Generic overloads are left to the full implementation-consistency check. ```py @@ -1337,32 +1413,32 @@ from typing import Callable, overload class CheckStaticMethod: @overload - def method1(x: int) -> int: ... + def method1(self, x: int) -> int: ... @overload - def method1(x: str) -> str: ... + def method1(self, x: str) -> str: ... @staticmethod # error: [invalid-overload] "Overloaded function `method1` does not use the `@staticmethod` decorator consistently" - def method1(x: int | str) -> int | str: + def method1(self, x: int | str) -> int | str: return x @overload - def method2(x: int) -> int: ... + def method2(self, x: int) -> int: ... @overload @staticmethod - def method2(x: str) -> str: ... + def method2(self, x: str) -> str: ... @staticmethod # error: [invalid-overload] - def method2(x: int | str) -> int | str: + def method2(self, x: int | str) -> int | str: return x @overload @staticmethod - def method3(x: int) -> int: ... + def method3(self, x: int) -> int: ... @overload @staticmethod - def method3(x: str) -> str: ... + def method3(self, x: str) -> str: ... # error: [invalid-overload] - def method3(x: int | str) -> int | str: + def method3(self, x: int | str) -> int | str: return x @overload @@ -1376,6 +1452,61 @@ class CheckStaticMethod: return x ``` +An inconsistently applied `@staticmethod` decorator has no effect on method binding, including when +it decorates the implementation. The consistent overload set remains a static method. + +```py +instance = CheckStaticMethod() +reveal_type(instance.method1(1)) # revealed: int +reveal_type(instance.method2("a")) # revealed: str +reveal_type(instance.method3(1)) # revealed: int +reveal_type(instance.method4("a")) # revealed: str + +reveal_type(CheckStaticMethod.method1(instance, 1)) # revealed: int +CheckStaticMethod.method1(1) # error: [no-matching-overload] +``` + +#### Inconsistent `@staticmethod` decorators in stubs + +When a stub mixes static and instance overloads, calls bind the instance as the first argument. +Overloads whose first parameter cannot accept that instance are filtered out. The order of the +overloads does not affect this recovery. + +`widget.pyi`: + +```pyi +from typing import overload + +class Widget: + @overload + @staticmethod + def method(source: str, index: int) -> int: ... + @overload + # error: [invalid-overload] "Overloaded function `method` does not use the `@staticmethod` decorator consistently" + def method(self, index: int) -> str: ... + @overload + def reversed(self, index: int) -> str: ... + @overload + @staticmethod + # error: [invalid-overload] + def reversed(source: str, index: int) -> int: ... +``` + +Accessing the method on the class leaves the receiver unbound, so its first parameter must be passed +explicitly. + +`main.py`: + +```py +from widget import Widget + +widget = Widget() +reveal_type(widget.method(5)) # revealed: str +reveal_type(widget.reversed(5)) # revealed: str +reveal_type(Widget.method(widget, 5)) # revealed: str +reveal_type(Widget.method("a", 5)) # revealed: int +``` + #### `@classmethod` @@ -1439,7 +1570,26 @@ class CheckClassMethod: if isinstance(x, int): return cls(x) return None +``` + +Inconsistent `@classmethod` decorators likewise do not bind the class. Calls on an instance bind +that instance, and calls on the class require an explicit receiver. + +```py +instance = CheckClassMethod(1) +reveal_type(instance.try_from1("a")) # revealed: None +reveal_type(instance.try_from2(1)) # revealed: CheckClassMethod +reveal_type(CheckClassMethod.try_from3(CheckClassMethod, 1)) # revealed: CheckClassMethod +reveal_type(CheckClassMethod.try_from1(instance, "a")) # revealed: None +CheckClassMethod.try_from1(1) # error: [no-matching-overload] +reveal_type(CheckClassMethod.try_from4(1)) # revealed: CheckClassMethod +``` + +Consistent classmethod overloads can restrict which subclasses accept each overload by annotating +the receiver. + +```py class Base: @overload @classmethod @@ -1461,6 +1611,43 @@ good: Callable[[int], int] = Base.from_value bad: Callable[[str], str] = Base.from_value ``` +#### Inconsistent `@classmethod` decorators in stubs + +An explicit class receiver annotation cannot accept an instance. Ignoring an inconsistent +`@classmethod` decorator therefore filters out that overload when the method binds an instance, +regardless of overload order. + +`factory.pyi`: + +```pyi +from typing import overload + +class Factory: + @overload + @classmethod + def method(cls: type[Factory], value: int) -> int: ... + @overload + # error: [invalid-overload] "Overloaded function `method` does not use the `@classmethod` decorator consistently" + def method(self, value: int) -> str: ... + @overload + def reversed(self, value: int) -> str: ... + @overload + @classmethod + # error: [invalid-overload] + def reversed(cls: type[Factory], value: int) -> int: ... +``` + +`main.py`: + +```py +from factory import Factory + +factory = Factory() +reveal_type(factory.method(1)) # revealed: str +reveal_type(factory.reversed(1)) # revealed: str +reveal_type(Factory.method(factory, 1)) # revealed: str +``` + #### `@final` diff --git a/crates/ty_python_semantic/resources/mdtest/override.md b/crates/ty_python_semantic/resources/mdtest/override.md index 2556808303..175aa2f8b6 100644 --- a/crates/ty_python_semantic/resources/mdtest/override.md +++ b/crates/ty_python_semantic/resources/mdtest/override.md @@ -1082,10 +1082,12 @@ class MyMapping(MutableMapping[KT, VT]): def __len__(self) -> int: raise NotImplementedError def update(self, arg: MapOrItems[KT, VT] = (), /, **kw: VT) -> None: ... +``` + +The `DeferredChild1`-specific overload applies on that subclass, so its override cannot remove the +`extra` parameter: -# TODO: We should emit an `invalid-method-override` diagnostic on -# `DeferredChild1.method`. The `DeferredChild1`-specific overload applies to -# this subclass, so its override cannot remove the `extra` parameter. +```py class DeferredBase: @overload def method(self) -> None: ... @@ -1094,7 +1096,7 @@ class DeferredBase: def method(self, extra: str = "") -> None: ... class DeferredChild1(DeferredBase): - def method(self) -> None: ... + def method(self) -> None: ... # error: [invalid-method-override] # TODO: A strict Liskov check would emit an `invalid-method-override` # diagnostic here too. A subclass could inherit from both `DeferredChild1` diff --git a/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md index b7bc652d2f..ccd26668fe 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md @@ -183,6 +183,62 @@ def takes_list(value: ListAlias) -> None: takes_list([1]) ``` +## Class-scoped type variables + +```toml +[environment] +python-version = "3.12" +``` + +A legacy generic alias binds its own type variables and cannot capture a type variable already bound +to its enclosing class. The restriction also applies to stringified aliases. + +```py +from typing import Generic, TypeAlias, TypeVar + +T = TypeVar("T") +S = TypeVar("S") + +class Box(Generic[T]): + # error: [invalid-type-form] "Type alias cannot capture class-scoped type variable `T`" + Items: TypeAlias = list[T] + # error: [invalid-type-form] "Type alias cannot capture class-scoped type variable `T`" + Quoted: TypeAlias = "list[T]" + + Independent: TypeAlias = list[S] + Concrete: TypeAlias = list[int] + +reveal_type(Box.Independent[str]()) # revealed: list[str] +reveal_type(Box.Concrete()) # revealed: list[int] +``` + +PEP 695 `type` statements can capture the enclosing class's type parameters, but using `TypeAlias` +inside a PEP 695 class still follows the legacy alias rules. + +```py +class Modern[T]: + type Items = list[T] + # error: [invalid-type-form] "Type alias cannot capture class-scoped type variable `T`" + Legacy: TypeAlias = list[T] +``` + +The same restriction applies to class-scoped `ParamSpec` and `TypeVarTuple` parameters. + +```py +from typing import Callable, ParamSpec, TypeVarTuple + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") + +class Callbacks(Generic[P]): + # error: [invalid-type-form] "Type alias cannot capture class-scoped type variable `P`" + Callback: TypeAlias = Callable[P, None] + +class Tuples(Generic[*Ts]): + # error: [invalid-type-form] "Type alias cannot capture class-scoped type variable `Ts`" + Items: TypeAlias = tuple[*Ts] +``` + ## Subscripted generic alias in union ```py diff --git a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md index eea9140984..2581c302ab 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md @@ -23,6 +23,32 @@ def f() -> None: reveal_type(x) # revealed: int | str ``` +## Runtime classes + +On Python 3.12, aliases defined by a `type` statement or the `typing.TypeAliasType` constructor are +instances of the standard-library class, while aliases created with +`typing_extensions.TypeAliasType` are instances of the distinct backport class. + +```py +from typing import TypeAliasType as StdlibTypeAliasType +from typing_extensions import TypeAliasType as ExtensionsTypeAliasType +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_subtype_of + +type StatementAlias = int +StdlibAlias = StdlibTypeAliasType("StdlibAlias", int) +ExtensionsAlias = ExtensionsTypeAliasType("ExtensionsAlias", int) + +static_assert(is_subtype_of(TypeOf[StatementAlias], StdlibTypeAliasType)) +static_assert(not is_subtype_of(TypeOf[StatementAlias], ExtensionsTypeAliasType)) + +static_assert(is_subtype_of(TypeOf[StdlibAlias], StdlibTypeAliasType)) +static_assert(not is_subtype_of(TypeOf[StdlibAlias], ExtensionsTypeAliasType)) + +static_assert(is_subtype_of(TypeOf[ExtensionsAlias], ExtensionsTypeAliasType)) +static_assert(not is_subtype_of(TypeOf[ExtensionsAlias], StdlibTypeAliasType)) +``` + ## Type aliases in `type[...]` ```py @@ -203,7 +229,8 @@ def _(flag: bool): ```py type ListOrSet[T] = list[T] | set[T] -reveal_type(ListOrSet.__type_params__) # revealed: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] +# revealed: tuple[_TypeParameter, ...] +reveal_type(ListOrSet.__type_params__) type Tuple1[T] = tuple[T] def _(cond: bool): @@ -273,6 +300,50 @@ def g(a: Ints) -> None: reveal_type(ident(a)) # revealed: list[int] ``` +## Unpacking tuple aliases + +Both unpack spellings accept a tuple alias and preserve positional argument types and arity. + +```py +from typing import Unpack + +type Pair = tuple[int, str] + +def starred(*args: *Pair) -> None: + reveal_type(args) # revealed: tuple[int, str] + +def explicit(*args: Unpack[Pair]) -> None: + reveal_type(args) # revealed: tuple[int, str] + +starred(1, "a") +starred(1) # error: [missing-argument] +starred(1, 2) # error: [invalid-argument-type] +explicit(1, "a") +explicit(1, "a", 3) # error: [too-many-positional-arguments] +``` + +Unpacking also follows alias chains and applies generic substitutions. + +```py +type GenericPair[T] = tuple[T, str] +type SpecializedPair = GenericPair[bytes] + +def specialized(*args: *SpecializedPair) -> None: + reveal_type(args) # revealed: tuple[bytes, str] + +specialized(b"a", "b") +specialized(1, "a") # error: [invalid-argument-type] +``` + +Non-tuple aliases remain invalid. + +```py +type NotTuple = list[int] + +def invalid_starred(*args: *NotTuple) -> None: ... # error: [invalid-type-form] +def invalid_explicit(*args: Unpack[NotTuple]) -> None: ... # error: [invalid-type-form] +``` + ## Stringified values Stringifying the right-hand side of a type alias is redundant, but allowed: @@ -826,6 +897,66 @@ def g(x: B) -> None: reveal_type(x) # revealed: list[A] ``` +### Invalid cyclic `TypeAliasType` definitions + +An alias cannot refer only to itself, either directly or through other aliases. The same check +applies to aliases created by calling `TypeAliasType` as to aliases declared with `type`. + +```py +from typing_extensions import TypeAliasType, TypeVar + +# error: [cyclic-type-alias-definition] "Cyclic definition of `Itself`" +Itself = TypeAliasType("Itself", "Itself") + +# error: [cyclic-type-alias-definition] "Cyclic definition of `First`" +First = TypeAliasType("First", "Second") +# error: [cyclic-type-alias-definition] "Cyclic definition of `Second`" +Second = TypeAliasType("Second", First) + +T = TypeVar("T") + +# error: [cyclic-type-alias-definition] "Cyclic definition of `GenericCycle`" +GenericCycle = TypeAliasType("GenericCycle", "GenericCycle[T]", type_params=(T,)) +``` + +### Cyclic unions created with `TypeAliasType` + +Adding a union member does not make a circular definition valid. In contrast, recursion through a +container describes nested values and is allowed. + +```py +from typing_extensions import TypeAliasType, TypeVar, Union + +T = TypeVar("T") + +# error: [cyclic-type-alias-definition] "Cyclic definition of `IntOr`" +IntOr = TypeAliasType("IntOr", "int | IntOr") +# error: [cyclic-type-alias-definition] "Cyclic definition of `GenericCycle`" +GenericCycle = TypeAliasType("GenericCycle", T | "GenericCycle[str]", type_params=(T,)) +# error: [cyclic-type-alias-definition] "Cyclic definition of `UnionCycle`" +UnionCycle = TypeAliasType("UnionCycle", Union[int, "UnionCycle"]) + +Tree = TypeAliasType("Tree", T | "list[Tree[T]]", type_params=(T,)) + +tree: Tree[int] = [1, [2]] +``` + +### Cycles across alias syntaxes + +A cycle is also invalid when it passes through aliases defined using different syntaxes. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing_extensions import TypeAliasType + +type Statement = Functional # error: [cyclic-type-alias-definition] +Functional = TypeAliasType("Functional", "Statement") # error: [cyclic-type-alias-definition] +``` + ## Cyclic aliases ### Self-referential @@ -846,9 +977,13 @@ def g(x: RecursiveList): ### Invalid self-referential +An alias cannot be a member of its own union. We still recover the non-recursive union members so +that uses of the invalid alias can be checked. + ```py -# TODO emit a diagnostic on these two lines +# error: [cyclic-type-alias-definition] "Cyclic definition of `IntOr`" type IntOr = int | IntOr +# error: [cyclic-type-alias-definition] "Cyclic definition of `OrInt`" type OrInt = OrInt | int def f(x: IntOr, y: OrInt): @@ -892,14 +1027,89 @@ type I[T] = H[T] # It's not possible to create an element of this type, but it's not an error for now type DirectRecursiveList[T] = list[DirectRecursiveList[T]] -# TODO: this should probably be a cyclic-type-alias-definition error +# error: [cyclic-type-alias-definition] "Cyclic definition of `Foo`" type Foo[T] = list[T] | Bar[T] +# error: [cyclic-type-alias-definition] "Cyclic definition of `Bar`" type Bar[T] = int | Foo[T] def _(x: Bar[int]): reveal_type(x) # revealed: int | list[int] ``` +### Cyclic unions with specialized aliases + +Changing the type arguments on a recursive reference does not break a cycle through unions. This +also applies when the arguments become more deeply nested on each expansion. + +```py +# error: [cyclic-type-alias-definition] "Cyclic definition of `Cycle`" +type Cycle[T] = T | Cycle[str] +# error: [cyclic-type-alias-definition] "Cyclic definition of `Growing`" +type Growing[T] = T | Growing[list[T]] +``` + +Repeated applications of a non-recursive alias are valid. A generic alias can also introduce the +container that makes recursion valid. + +```py +type Identity[T] = T +type Repeated = Identity[Identity[int]] +type Items[T] = list[T] +type Tree = int | Items[Tree] + +repeated: Repeated = 1 +tree: Tree = [1, [2]] +``` + +An unused type argument does not make the alias recursive: `Constant[T]` always expands to `int`. + +```py +type Constant[T] = int +type UnusedArgument = Constant[UnusedArgument] + +unused: UnusedArgument = 1 +``` + +A generic alias that returns its type argument does not introduce a container and cannot break a +cycle. + +```py +# error: [cyclic-type-alias-definition] "Cyclic definition of `ThroughIdentity`" +type ThroughIdentity = Identity[ThroughIdentity] +``` + +### Finite nested applications of recursive aliases + +A recursive alias can appear in its own type arguments without creating a cycle in its expansion. +Here, expanding the two applications exposes `int`; the remaining recursion is inside `list`. + +```py +type Recursive[T] = T | list[Recursive[list[T]]] +type Repeated = Recursive[Recursive[int]] + +value: Repeated = 1 +``` + +An exposed type argument can still close a cycle. Unlike the finite nested applications above, this +argument leads back to the alias being defined. + +```py +type Cycle = Recursive[Cycle] # error: [cyclic-type-alias-definition] +``` + +The same rule applies to aliases created with `TypeAliasType`. + +```py +from typing_extensions import TypeAliasType, TypeVar + +T = TypeVar("T") +Functional = TypeAliasType("Functional", T | "list[Functional[list[T]]]", type_params=(T,)) +RepeatedFunctional = TypeAliasType("RepeatedFunctional", Functional[Functional[int]]) + +functional_value: RepeatedFunctional = 1 +FunctionalCycle = TypeAliasType("FunctionalCycle", Functional["FunctionalCycle"]) # error: [cyclic-type-alias-definition] +``` + ### With legacy generic ```py @@ -974,6 +1184,150 @@ type WrappedRight[T] = tuple[Box[Box[WrappedRight[list[T]]]]] static_assert(not is_subtype_of(WrappedLeft[int], WrappedRight[int])) ``` +### Recursive alias relations with finite specialization orbits + +A recursive specialization can change its arguments while still reaching an exact repetition after +finitely many expansions. This includes shifting arguments to the left and resetting arguments to +types that do not depend on the current specialization. + +```py +from typing import Protocol + +from ty_extensions import Intersection, static_assert +from ty_extensions._internal import is_subtype_of + +# Resetting the recursive argument makes these aliases reach a fixed specialization. +type L[T] = tuple[T] | tuple[T, L[int]] +type R[T] = tuple[T] | tuple[T, R[int]] + +def _(left: L[str], right: R[str]): + right = left + left = right + +type ShiftingLeft[A, B, C, D, E, F, G, H, I, J, K, L] = tuple[A, ShiftingLeft[B, C, D, E, F, G, H, I, J, K, L, None]] +type ShiftingRight[A, B, C, D, E, F, G, H, I, J, K, L] = tuple[A, ShiftingRight[B, C, D, E, F, G, H, I, J, K, L, None]] + +static_assert( + is_subtype_of( + ShiftingLeft[int, int, int, int, int, int, int, int, int, int, int, int], + ShiftingRight[int, int, int, int, int, int, int, int, int, int, int, int], + ) +) + +type ShiftingSource = ShiftingLeft[int, int, int, int, int, int, int, int, int, int, int, int] +type ShiftingRightAfterTwo = ShiftingRight[int, int, int, int, int, int, int, int, int, int, None, None] +type ShiftingShortcut = tuple[int, tuple[int, ShiftingRightAfterTwo]] +type ShiftingLongPath = ShiftingRight[int, int, int, int, int, int, int, int, int, int, int, int] + +static_assert(is_subtype_of(ShiftingSource, ShiftingShortcut)) +static_assert(is_subtype_of(ShiftingSource, ShiftingShortcut | ShiftingLongPath)) +static_assert(is_subtype_of(ShiftingSource, ShiftingLongPath | ShiftingShortcut)) + +type MutualLeft[T] = tuple[T, MutualLeftHelper[list[T]]] +type MutualLeftHelper[U] = tuple[U, MutualLeft[int]] +type MutualRight[T] = tuple[T, MutualRightHelper[list[T]]] +type MutualRightHelper[U] = tuple[U, MutualRight[int]] + +static_assert(is_subtype_of(MutualLeft[str], MutualRight[str])) + +# Repeatedly adding the same union element reaches a fixed point after one expansion. +type SaturatingLeft[T] = tuple[T, SaturatingLeft[T | int]] +type SaturatingRight[T] = tuple[T, SaturatingRight[T | int]] + +static_assert(is_subtype_of(SaturatingLeft[bytes], SaturatingRight[bytes])) + +# Repeatedly intersecting with the same type also reaches a fixed point. +type IntersectingLeft[T] = tuple[T, IntersectingLeft[Intersection[T, int]]] +type IntersectingRight[T] = tuple[T, IntersectingRight[Intersection[T, int]]] + +static_assert(is_subtype_of(IntersectingLeft[object], IntersectingRight[object])) + +# A structural wrapper still grows when it appears alongside or outside a saturating union. +type MixedGrowingLeft[T] = tuple[T, MixedGrowingLeft[T | list[T]]] +type MixedGrowingRight[T] = tuple[T, MixedGrowingRight[T | list[T]]] +type NestedSetGrowingLeft[T] = tuple[T, NestedSetGrowingLeft[list[T | int]]] +type NestedSetGrowingRight[T] = tuple[T, NestedSetGrowingRight[list[T | int]]] + +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(MixedGrowingLeft[int], MixedGrowingRight[int])) +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(NestedSetGrowingLeft[int], NestedSetGrowingRight[int])) + +# Alternating normalized set operations also reach a fixed point. +class SetElementA(Protocol): + a: int + +class SetElementB(Protocol): + b: int + +class SetElementC(Protocol): + c: int + +type AlternatingSetLeft[T] = tuple[T, AlternatingSetLeftHelper[T | SetElementB]] +type AlternatingSetLeftHelper[U] = tuple[U, AlternatingSetLeft[Intersection[U, SetElementC]]] +type AlternatingSetRight[T] = tuple[T, AlternatingSetRightHelper[T | SetElementB]] +type AlternatingSetRightHelper[U] = tuple[U, AlternatingSetRight[Intersection[U, SetElementC]]] + +static_assert(is_subtype_of(AlternatingSetLeft[SetElementA], AlternatingSetRight[SetElementA])) + +# A specialization can also have a finite period greater than one. +type PeriodicLeft[A, B] = tuple[A, B, PeriodicLeft[B, A | int]] +type PeriodicRight[A, B] = tuple[A, B, PeriodicRight[B, A | int]] + +static_assert(is_subtype_of(PeriodicLeft[bytes, str], PeriodicRight[bytes, str])) + +# A helper alias can erase an argument before the recursive reference sees it, so the recursive +# specialization reaches a fixed point after one step. +type ErasingArgument[T] = int +type ErasingLeft[T] = tuple[T, ErasingLeft[ErasingArgument[T]]] +type ErasingRight[T] = tuple[T, ErasingRight[ErasingArgument[T]]] + +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(ErasingLeft[str], ErasingRight[str])) + +# Neither recursive occurrence grows indefinitely by itself, but alternating between them adds +# another list layer on every cycle. +type AlternatingLeft[X, Y] = tuple[ + AlternatingLeft[Y, None], + AlternatingLeft[None, list[X]], +] +type AlternatingRight[X, Y] = tuple[ + AlternatingRight[Y, None], + AlternatingRight[None, list[X]], +] + +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(AlternatingLeft[int, str], AlternatingRight[int, str])) + +# The nested aliases grow their first argument, but the references back to the outer aliases erase +# that argument. The outer specialization orbits are therefore finite. +type OuterLeft[A, B] = NodeLeft[A, B] +type NodeLeft[A, B] = tuple[A, NodeLeft[list[A], B], OuterLeft[B, None]] +type OuterRight[A, B] = NodeRight[A, B] +type NodeRight[A, B] = tuple[A, NodeRight[list[A], B], OuterRight[B, None]] + +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(OuterLeft[int, str], OuterRight[int, str])) + +# If the reference back to the outer alias retains the growing argument, the outer specialization +# can grow transitively. +type TransitiveOuterLeft[T] = TransitiveNodeLeft[T] +type TransitiveNodeLeft[T] = tuple[ + T, + TransitiveNodeLeft[list[T]], + TransitiveOuterLeft[T], +] +type TransitiveOuterRight[T] = TransitiveNodeRight[T] +type TransitiveNodeRight[T] = tuple[ + T, + TransitiveNodeRight[list[T]], + TransitiveOuterRight[T], +] + +# TODO: These structurally equivalent aliases should be recognized as subtypes. +static_assert(not is_subtype_of(TransitiveOuterLeft[int], TransitiveOuterRight[int])) +``` + ### Non-recursive nested generic aliases A repeated use of the same generic alias can be a finite alias application instead of recursion. @@ -1039,6 +1393,7 @@ terminate and preserve the alias at the recursive position. ```py from typing import Callable, Concatenate +# error: [cyclic-type-alias-definition] type Recursive[T] = int | Recursive[list[T]] def _(value: Recursive[int]): @@ -1067,17 +1422,21 @@ def growing_callable(x: GrowingCallable[int]): reveal_type(x()) ``` -Non-growing recursive aliases should continue to preserve distinct specializations. +If a type parameter never appears outside an unchanged recursive reference, different +specializations satisfy the same recursive equation and are equivalent. ```py +from ty_extensions import static_assert +from ty_extensions._internal import is_equivalent_to + type StableWrapped[T] = list[StableWrapped[T]] +static_assert(is_equivalent_to(StableWrapped[int], StableWrapped[str])) + def stable_wrapped(x: StableWrapped[int], y: StableWrapped[str]): reveal_type(x) # revealed: list[StableWrapped[int]] reveal_type(y) # revealed: list[StableWrapped[str]] - # error: [invalid-assignment] "Object of type `StableWrapped[str]` is not assignable to `StableWrapped[int]`" x = y - # error: [invalid-assignment] "Object of type `StableWrapped[int]` is not assignable to `StableWrapped[str]`" y = x ``` @@ -1267,9 +1626,12 @@ reveal_type(CallableGuard) # revealed: TypeAliasType ### Recursive alias in binary operators doesn't stack overflow +An invalid union cycle still recovers its non-recursive member when checking operators. + ```py from typing import reveal_type +# error: [cyclic-type-alias-definition] type A = int | A def foo(x: A): diff --git a/crates/ty_python_semantic/resources/mdtest/promotion.md b/crates/ty_python_semantic/resources/mdtest/promotion.md index 716ea14885..ed46583c2b 100644 --- a/crates/ty_python_semantic/resources/mdtest/promotion.md +++ b/crates/ty_python_semantic/resources/mdtest/promotion.md @@ -99,6 +99,22 @@ reveal_type((1, 2, 3)) # revealed: tuple[Literal[1], Literal[2], Literal[3]] reveal_type(frozenset((1, 2, 3))) # revealed: frozenset[Literal[1, 2, 3]] ``` +## Callable defaults are not promoted + +Promoting a callable as a collection element does not change its default values. This applies both +to defaults on the source function and to values supplied by keyword to `functools.partial`. + +```py +from functools import partial + +def f(x: int = 5, *, y: int) -> int: + return x + y + +bound = partial(f, y=7) +reveal_type(bound) # revealed: partial[(x: int = 5, *, y: int = 7) -> int] +reveal_type([bound]) # revealed: list[partial[(x: int = 5, *, y: int = 7) -> int]] +``` + ## Unions of homogeneous, fixed-length tuples can be promoted to a single variadic tuple This type of promotion applies specifically when a collection literal contains at least two tuple @@ -712,6 +728,21 @@ reveal_type(i("a")) # revealed: list[str] reveal_type(i(1)) # revealed: list[Literal[1]] ``` +## Promotion respects inferred upper bounds + +Promotion must not select a solution that violates its inferred upper bound. + +```py +from typing import Callable + +def f[T](value: T, upper: Callable[[T], None]) -> list[T]: + return [value] + +def _(upper: Callable[[int], None]): + # error: [invalid-argument-type] + reveal_type(f("x", upper)) # revealed: list[str | int] +``` + ## Literal annotations from declaration are respected Literal types that are explicitly annotated when declared will not be promoted, even if they are diff --git a/crates/ty_python_semantic/resources/mdtest/properties.md b/crates/ty_python_semantic/resources/mdtest/properties.md index 03ca9d17fc..50cb400908 100644 --- a/crates/ty_python_semantic/resources/mdtest/properties.md +++ b/crates/ty_python_semantic/resources/mdtest/properties.md @@ -49,6 +49,211 @@ c.my_property = 2 c.my_property = "a" ``` +## Property subclasses + +A subclass that inherits the built-in property implementation retains both its nominal type and its +accessors. Adding a setter does not discard the getter's return type. + +```py +class CustomProperty(property): + def description(self) -> str: + return "custom" + +class C: + @CustomProperty + def value(self) -> int: + return 1 + + @value.setter + def value(self, value: str) -> None: + pass + +reveal_type(C.value) # revealed: CustomProperty +reveal_type(C.value.description()) # revealed: str +reveal_type(C().value) # revealed: int +C().value = "new" +C().value = 1 # error: [invalid-assignment] +``` + +## Replacing subclass accessors + +Direct construction and each accessor decorator preserve the subclass. Replacing one accessor also +preserves the other accessors. + +```py +class CustomProperty(property): ... + +def get_value(obj: object) -> int: + return 1 + +def set_value(obj: object, value: str) -> None: + pass + +def delete_value(obj: object) -> None: + pass + +def get_text(obj: object) -> str: + return "value" + +original = CustomProperty(get_value) +updated = original.setter(set_value).deleter(delete_value).getter(get_text) +reveal_type(original) # revealed: CustomProperty +reveal_type(updated) # revealed: CustomProperty +reveal_type(original.fget) # revealed: def get_value(obj: object) -> int +reveal_type(updated.fget) # revealed: def get_text(obj: object) -> str +reveal_type(updated.fset) # revealed: def set_value(obj: object, value: str) +reveal_type(updated.fdel) # revealed: def delete_value(obj: object) + +class C: + before = original + after = updated + +reveal_type(C.before) # revealed: CustomProperty +reveal_type(C().before) # revealed: int +reveal_type(C().after) # revealed: str +reveal_type(updated.__get__(C(), C)) # revealed: str +reveal_type(type(updated).__get__(updated, C(), C)) # revealed: str +C().after = "new" +C().after = 1 # error: [invalid-assignment] +del C().after +``` + +## Generic property subclasses + +The nominal class specialization is retained when an accessor is replaced. + +```py +from typing import Generic, TypeVar + +T = TypeVar("T") + +class CustomProperty(property, Generic[T]): + metadata: T + +def get_value(obj: object) -> int: + return 1 + +def set_value(obj: object, value: str) -> None: + pass + +descriptor = CustomProperty[bytes](get_value).setter(set_value) +reveal_type(descriptor) # revealed: CustomProperty[bytes] +reveal_type(descriptor.metadata) # revealed: bytes +``` + +Specializing the class that owns the property also specializes the descriptor's nominal type. + +```py +class Owner(Generic[T]): + value = CustomProperty[T](get_value) + +reveal_type(Owner[str].value) # revealed: CustomProperty[str] +reveal_type(Owner[str].value.metadata) # revealed: str +reveal_type(Owner[str]().value) # revealed: int +``` + +## Overridden property accessor methods + +A subclass can replace an accessor-copy method. Its declared return type takes precedence over the +built-in copy behavior. + +```py +from typing import Any, Callable + +class ReplacementProperty(property): ... + +class CustomProperty(property): + def setter(self, fset: Callable[[Any, Any], None], /) -> ReplacementProperty: + return ReplacementProperty() + +def set_value(obj: object, value: str) -> None: + pass + +reveal_type(CustomProperty().setter(set_value)) # revealed: ReplacementProperty +``` + +## Overridden property descriptor methods + +Subclasses that change the descriptor protocol are checked as ordinary descriptors. Their `__get__` +annotations must not be replaced with the stored getter's return type. + +```py +from typing import overload +from typing_extensions import Self + +def get_value(obj: object) -> int: + return 1 + +class CustomGetter(property): + @overload + def __get__(self, instance: None, owner: type, /) -> Self: ... + @overload + def __get__(self, instance: object, owner: type | None = None, /) -> str: ... + def __get__(self, instance: object, owner: type | None = None, /) -> Self | str: + return self if instance is None else "custom" + +class C: + value = CustomGetter(get_value) + +reveal_type(C.value) # revealed: CustomGetter +reveal_type(C().value) # revealed: str +``` + +## Overridden accessor attributes + +A subclass may hide an accessor attribute without changing the getter that the descriptor calls. The +stored callable must not replace that explicitly defined attribute. + +```py +class HiddenGetter(property): + fget: None = None + +def get_value(obj: object) -> int: + return 1 + +descriptor = HiddenGetter(get_value) +reveal_type(descriptor.fget) # revealed: None +``` + +## Custom property constructors + +A custom initializer can give its arguments a different meaning. We must not interpret those +arguments as the built-in getter, setter, and deleter parameters. + +```py +from typing import Any, Callable + +class CustomProperty(property): + def __init__(self, description: str, getter: Callable[[Any], Any]) -> None: + super().__init__(getter) + +def get_value(obj: object) -> int: + return 1 + +descriptor = CustomProperty("value", get_value) +reveal_type(descriptor) # revealed: CustomProperty + +class C: + value = descriptor + +reveal_type(C.value) # revealed: CustomProperty +reveal_type(C().value) # revealed: Unknown +``` + +## Property subclass truthiness + +Tracking the accessors does not make a subclass with a custom `__bool__` unconditionally truthy. + +```py +from typing import Literal + +class FalsyProperty(property): + def __bool__(self) -> Literal[False]: + return False + +reveal_type(bool(FalsyProperty())) # revealed: Literal[False] +``` + ## Properties returning `Self` A property that returns `Self` refers to an instance of the class: diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index aab0e5cb8e..23a5b08719 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -289,6 +289,65 @@ static_assert(is_subtype_of(TypeOf[Protocol], typing._ProtocolMeta)) reveal_type(issubclass(MyProtocol, Protocol)) # revealed: bool ``` +## Protocol metaclasses + +By default, a protocol declared outside typeshed uses `typing._ProtocolMeta`. Nominal subclasses +inherit this metaclass and the abstract base class methods it provides through `ABCMeta`. + +```py +from typing import Protocol, _ProtocolMeta +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class Parent(Protocol): ... +class Child(Parent, Protocol): ... +class Concrete(Child): ... + +reveal_type(type(Parent)) # revealed: +reveal_type(type(Child)) # revealed: +reveal_type(type(Concrete)) # revealed: +static_assert(is_subtype_of(type[Concrete], _ProtocolMeta)) +``` + +The same applies to generic protocols and the `typing_extensions` backport. + +```py +from typing import TypeVar +from typing_extensions import Protocol as ExtensionsProtocol + +T = TypeVar("T", covariant=True) + +class GenericProtocol(Protocol[T]): ... +class BackportedProtocol(ExtensionsProtocol): ... + +reveal_type(type(GenericProtocol)) # revealed: +reveal_type(type(BackportedProtocol)) # revealed: +``` + +## Virtual subclass registration + +Protocol classes inherit `ABCMeta.register`, which returns the registered class with its type +intact. + +```py +from typing import Protocol + +class P(Protocol): ... +class Concrete: ... + +reveal_type(P.register(Concrete)) # revealed: type[Concrete] +``` + +The method is also available on collection ABCs that typeshed models using protocols. This is a +regression test for . + +```py +from collections.abc import Container, Mapping + +reveal_type(Container.register(Concrete)) # revealed: type[Concrete] +reveal_type(Mapping.register(Concrete)) # revealed: type[Concrete] +``` + ## Diagnostics and autofixes for `Protocol` classes defined in invalid ways @@ -1046,7 +1105,8 @@ class AnySelf(Protocol): ``` Assignments in a comprehension and augmented assignments are also writes to the instance. -`__getattr__` provides the read side of `+=` below, so that case tests only the write: +`__getattr__` provides the read side of `+=` below, although the write is not yet recognized as +establishing an instance attribute: ```py class AssignmentForms(Protocol): @@ -1057,14 +1117,15 @@ class AssignmentForms(Protocol): [None for self.from_comprehension in [1]] # error: [ambiguous-protocol-member] def augmented_assignment(self) -> None: + # error: [unresolved-attribute] self.augmented += 1 # snapshot: ambiguous-protocol-member ``` ```snapshot warning[ambiguous-protocol-member]: Cannot assign to an undeclared attribute in a protocol method - --> src/mdtest_snippet.py:326:9 + --> src/mdtest_snippet.py:327:9 | -326 | self.augmented += 1 # snapshot: ambiguous-protocol-member +327 | self.augmented += 1 # snapshot: ambiguous-protocol-member | ^^^^^^^^^^^^^^ `augmented` is not declared as a protocol member info: Assigning to an undeclared attribute in a protocol method leads to an ambiguous interface --> src/mdtest_snippet.py:318:7 @@ -3923,8 +3984,8 @@ reveal_type(abs(5)) # revealed: int def f(x: Literal[5]) -> None: reveal_type(abs(x)) # revealed: int -InT = TypeVar("InT") -OutT = TypeVar("OutT") +InT = TypeVar("InT", contravariant=True) +OutT = TypeVar("OutT", covariant=True) class CanMul(Protocol[InT, OutT]): def __mul__(self, x: InT, /) -> OutT: ... @@ -4042,6 +4103,118 @@ class Incompatible(SupportsMethod[T_co]): raise NotImplementedError ``` +## Recursive protocol receiver binding during constructor inference + +Inferring a constructor's type argument from a protocol can recursively compare an explicitly typed +receiver with the same protocol. Constraints from another generic method must not make this cycle +alternate between equivalent representations. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class Container[T](Protocol): + value: T + + def replace[U](self, value: U) -> None: + pass + + def flatten[U](self: "Container[Container[U]]") -> None: + pass + +class Implementation[T](Container[T]): + pass + +value: Container[int] = Implementation() +reveal_type(value) # revealed: Implementation[int] +``` + +## Recursive protocol receiver binding with an overloaded class method + +Accessing an overloaded class method on a generic protocol can recursively bind the protocol's +receiver. The receiver-binding query must converge and preserve both overload signatures. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, overload + +class Container[T](Protocol): + def value(self) -> T: ... + @overload + @classmethod + def from_value[Self, S](cls: type[Self], value: S) -> object: ... + @overload + @classmethod + def from_value[Self, S](cls: type[Self], value: S, flag: bool) -> object: ... + +reveal_type(Container.from_value) # revealed: Overload[[S](value: S) -> object, [S](value: S, flag: bool) -> object] +``` + +## Recursive protocol receiver binding with a bounded type variable + +A class method can recursively bind a protocol receiver through a type variable with a declared +upper bound. Its declared bound is metadata, not another part of the receiver constraint. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T") +S = TypeVar("S", bound=object) + +class Box(Protocol[T]): + value: T + + @classmethod + def first(cls: type[S]) -> S: + return cls() + + def second(self) -> S: ... + @classmethod + def last(cls: type[S]) -> object: ... + +reveal_type(Box.first()) # revealed: Box[Unknown] +``` + +## Recursive protocol receiver binding with a defaulted type variable + +A method type variable with a declared default can also appear while recursively binding a protocol +receiver. Its default is declaration metadata, not a type-variable occurrence in the constraint. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Protocol + +class Box[T](Protocol): + value: T + + @classmethod + def first[S = int](cls: type[S]) -> S: + return cls() + + def second[S = int](self) -> S: ... + @classmethod + def last[S = int](cls: type[S]) -> object: ... + +reveal_type(Box.first()) # revealed: Box[Unknown] +``` + ## Subtyping of protocols with generic method members Protocol method members can be generic. They can have generic contexts scoped to the class: @@ -4060,7 +4233,7 @@ from ty_extensions._internal import is_equivalent_to, is_assignable_to, is_subty class NewStyleClassScoped[T](Protocol): def method(self, input: T) -> None: ... -S = TypeVar("S") +S = TypeVar("S", contravariant=True) class LegacyClassScoped(Protocol[S]): def method(self, input: S) -> None: ... @@ -4279,6 +4452,110 @@ static_assert(not is_assignable_to(BadReturnType, ShapeProtocolImplicitSelf)) static_assert(not is_assignable_to(BadReturnType, ShapeProtocolExplicitSelf)) ``` +## `Self` in generic type aliases during protocol matching + +`Self` in a protocol method's return type refers to the structural implementation, even when it is +wrapped in a generic type alias. The implementation can return plain `Self`: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, Self + +type Identity[T] = T + +class Cloneable(Protocol): + def clone(self) -> Identity[Self]: ... + +class DirectClone: + def clone(self) -> Self: + return self + +direct: Cloneable = DirectClone() +``` + +The same binding applies to a property return type: + +```py +class Current(Protocol): + @property + def current(self) -> Identity[Self]: ... + +class CurrentImpl: + @property + def current(self) -> Self: + return self + +current: Current = CurrentImpl() +``` + +## `Self`-returning instance methods in `ParamSpec` protocols + +Specializing a protocol's `ParamSpec` preserves the meaning of `Self`: the return type names the +structural implementation, even when the specialized parameter list is empty. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, Self + +class Copier[**P](Protocol): + def copy(self, *args: P.args, **kwargs: P.kwargs) -> Self: ... + +class Copyable: + def copy(self) -> Self: + return self + +copier: Copier[[]] = Copyable() +copier_class: type[Copier[[]]] = Copyable +``` + +## `Self`-returning class methods in `ParamSpec` protocols + +A class method returning `Self` also satisfies a specialized protocol, whether the implementation is +assigned as an instance or as a class object. The implementation does not need to inherit from the +protocol. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, Self + +class FactoryP[**P](Protocol): + @classmethod + def bind(cls, *args: P.args, **kwargs: P.kwargs) -> Self: ... + +class Factory: + @classmethod + def bind(cls, value: int) -> Self: + return cls() + +factory: FactoryP[[int]] = Factory() +factory_class: type[FactoryP[[int]]] = Factory +``` + +A matching parameter list is not enough: returning an unrelated type does not satisfy the protocol's +`Self` return type. + +```py +class BadFactory: + @classmethod + def bind(cls, value: int) -> int: + return value + +bad_factory: FactoryP[[int]] = BadFactory() # error: [invalid-assignment] +bad_factory_class: type[FactoryP[[int]]] = BadFactory # error: [invalid-assignment] +``` + ## Module objects with static-method protocol members Module objects implement protocols through their public interface. A module-level function can @@ -4314,6 +4591,86 @@ factory_object: FactoryObject = factory factory_module: FactoryModule = factory ``` +## Generic protocol inference from module objects + +A module attribute can determine a protocol's type argument when the module itself is passed to a +generic function. + +```toml +[environment] +python-version = "3.12" +``` + +`values.py`: + +```py +value: int = 1 +``` + +`main.py`: + +```py +from typing import Protocol + +import values + +class HasValue[T](Protocol): + value: T + +def get_value[T](obj: HasValue[T]) -> T: + return obj.value + +reveal_type(get_value(values)) # revealed: int +``` + +## Generic protocol inference through type aliases + +An alias for an instance type does not obscure the members that determine a protocol's type +arguments. Inference sees through these aliases and checks the bounds of these arguments. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class HasValue[T](Protocol): + def get(self) -> T: ... + +class Box[T]: + def get(self) -> T: + raise NotImplementedError + +type Alias[T] = Box[T] + +def get_value[T](value: HasValue[T]) -> T: + return value.get() + +def require_str[T: str](value: HasValue[T]) -> T: + return value.get() + +def check_alias(value: Alias[int]): + reveal_type(get_value(value)) # revealed: int + # error: [invalid-argument-type] "Argument type `int` does not satisfy upper bound `str` of type variable `T`" + require_str(value) +``` + +An equivalent generic alias constructed with `TypeAliasType` preserves the same information. + +```py +from typing import TypeAliasType, TypeVar + +U = TypeVar("U") +ConstructedAlias = TypeAliasType("ConstructedAlias", Box[U], type_params=(U,)) + +def check_constructed_alias(value: ConstructedAlias[int]): + reveal_type(get_value(value)) # revealed: int + # error: [invalid-argument-type] "Argument type `int` does not satisfy upper bound `str` of type variable `T`" + require_str(value) +``` + ## Class objects with class-method protocol members A class object implements a protocol when its directly accessible members have compatible types. The @@ -4334,41 +4691,151 @@ class IntParser: parser: Parser = IntParser ``` -## Class objects and `Self`-returning class-method protocol members +## Generic protocol inference from class attributes -When a class object is checked against a class-method protocol member, `Self` in the protocol -signature names the class object. A class method that returns `Self` returns an instance and cannot -satisfy that requirement; a class method that returns `type[Self]` can satisfy a `type[C]` -candidate: +A class object can supply the type argument of a protocol through an ordinary attribute. Passing the +class itself should infer the same type as passing an instance. + +```toml +[environment] +python-version = "3.12" +``` ```py -from typing import Protocol, TypeVar -from typing_extensions import Self -from ty_extensions import static_assert -from ty_extensions._internal import TypeOf, is_assignable_to +from typing import Protocol -class FactoryProtocol(Protocol): - @classmethod - def make(cls) -> Self: ... +class HasValue[T](Protocol): + value: T -class ExplicitReceiverFactoryProtocol(Protocol): - @classmethod - def make(cls: type[Self]) -> Self: ... +class IntValue: + value: int = 1 -class Factory: - @classmethod - def make(cls) -> Self: - return cls() +def get_value[T](obj: HasValue[T]) -> T: + return obj.value -class ExplicitReceiverFactory: - @classmethod - def make(cls: type[Self]) -> Self: - return cls() +reveal_type(get_value(IntValue)) # revealed: int +reveal_type(get_value(IntValue())) # revealed: int -class BadFactory: - @classmethod - def make(cls) -> int: - return 1 +def _(cls: type[IntValue]) -> None: + reveal_type(get_value(cls)) # revealed: int +``` + +## Generic protocol inference from class methods + +The return type of a class method can determine a protocol's type argument, including when the +argument is the class object itself. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class Factory[T](Protocol): + @classmethod + def make(cls) -> T: ... + +class Concrete: + @classmethod + def make(cls) -> "Concrete": + return cls() + +def from_protocol[T](factory: Factory[T]) -> T: + return factory.make() + +reveal_type(from_protocol(Concrete)) # revealed: Concrete +reveal_type(from_protocol(Concrete())) # revealed: Concrete + +bad: Factory[str] = Concrete # error: [invalid-assignment] +``` + +Specialized generic class objects and unions of class objects also contribute their method return +types to inference. + +```py +class GenericFactory[T]: + @classmethod + def make(cls) -> T: + raise NotImplementedError + +reveal_type(from_protocol(GenericFactory[int])) # revealed: int + +def _(cls: type[GenericFactory[int]] | type[GenericFactory[str]]) -> None: + reveal_type(from_protocol(cls)) # revealed: int | str +``` + +## Generic protocol inference from static methods + +A static method on a class object can satisfy either a static-method or an instance-method protocol +member. Both forms should contribute the method's return type to inference. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class StaticFactory[T](Protocol): + @staticmethod + def make() -> T: ... + +class InstanceFactory[T](Protocol): + def make(self) -> T: ... + +class IntFactory: + @staticmethod + def make() -> int: + return 1 + +def from_static[T](factory: StaticFactory[T]) -> T: + return factory.make() + +def from_instance[T](factory: InstanceFactory[T]) -> T: + return factory.make() + +reveal_type(from_static(IntFactory)) # revealed: int +reveal_type(from_instance(IntFactory)) # revealed: int +``` + +## Class objects and `Self`-returning class-method protocol members + +When a class object is checked against a class-method protocol member, `Self` in the protocol +signature names the class object. A class method that returns `Self` returns an instance and cannot +satisfy that requirement; a class method that returns `type[Self]` can satisfy a `type[C]` +candidate: + +```py +from typing import Protocol, TypeVar +from typing_extensions import Self +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_assignable_to + +class FactoryProtocol(Protocol): + @classmethod + def make(cls) -> Self: ... + +class ExplicitReceiverFactoryProtocol(Protocol): + @classmethod + def make(cls: type[Self]) -> Self: ... + +class Factory: + @classmethod + def make(cls) -> Self: + return cls() + +class ExplicitReceiverFactory: + @classmethod + def make(cls: type[Self]) -> Self: + return cls() + +class BadFactory: + @classmethod + def make(cls) -> int: + return 1 class ClassObjectFactory: @classmethod @@ -4603,6 +5070,44 @@ static_assert(not is_assignable_to(TypeOf[StringMembership], Container[int])) static_assert(not is_assignable_to(TypeOf[NonBooleanMembership], Container[int])) ``` +## Class objects with bounded type-variable receivers + +An iterable class combined with an empty fallback must contribute its member type to generic call +inference. A classmethod can therefore collect its own instances without losing `Self`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from collections.abc import Iterator +from typing import Self + +class IterableMeta(type): + def __iter__[T](self: type[T]) -> Iterator[T]: + raise NotImplementedError + +class Item(metaclass=IterableMeta): + @classmethod + def all(cls, enabled: bool) -> frozenset[Self]: + items = frozenset(cls if enabled else ()) + reveal_type(items) # revealed: frozenset[Self@all] + return items +``` + +## Generic inference from gradual class objects + +`type[Any]` can be assigned to an iterable protocol. A concrete fallback must still contribute its +element type to generic inference. + +```py +from typing import Any + +def collect(cls: type[Any], enabled: bool) -> None: + reveal_type(list(cls if enabled else (1,))) # revealed: list[int] +``` + ## Subtyping of protocols with `@classmethod` or `@staticmethod` members The typing spec states that protocols may have `@classmethod` or `@staticmethod` method members. @@ -5456,6 +5961,83 @@ class Constructor(Protocol): constructor: Constructor = Product ``` +## Generic constructor callback inference + +Passing `type[T]` to a generic callback protocol must preserve the type variable returned by the +class's constructor. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class Callback[T](Protocol): + def __call__(self) -> T: ... + +def invoke[T](callback: Callback[T]) -> T: + return callback() + +def create[T](cls: type[T]) -> T: + reveal_type(invoke(cls)) # revealed: T@create + return invoke(cls) +``` + +## Generic callback inference from other members + +A callable protocol can infer a type argument from another member. Comparing only its `__call__` +signature would miss the class attribute that determines `T` here. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class ConstructorWithValue[T](Protocol): + value: T + + def __call__(self) -> object: ... + +class Product: + value: int = 1 + +def get_value[T](factory: ConstructorWithValue[T]) -> T: + return factory.value + +reveal_type(get_value(Product)) # revealed: int +``` + +## Generic callback inference from function attributes + +A function object's attributes also contribute to protocol inference. Here, the type argument comes +from `__name__`, not the callback's return type. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol + +class NamedCallback[T](Protocol): + __name__: T + + def __call__(self) -> object: ... + +def get_name[T](callback: NamedCallback[T]) -> T: + return callback.__name__ + +def callback() -> None: ... + +reveal_type(get_name(callback)) # revealed: str +``` + ## Generic protocols and union arguments When a union is passed to a parameter annotated as a generic protocol, each union element can @@ -5466,7 +6048,7 @@ Other type variables in the same call are still inferred from their correspondin ```py from typing import Protocol, TypeVar -T = TypeVar("T") +T = TypeVar("T", covariant=True) U = TypeVar("U") class Box(Protocol[T]): @@ -5769,6 +6351,55 @@ static_assert(not is_subtype_of(LeftAlias[int], RightAlias[int])) # A conservative cycle fallback must not accept structurally different recursive protocols. static_assert(not is_subtype_of(LeftProtocol[int], DifferentProtocol[int])) +class ShiftingLeftProtocol[A, B, C](Protocol): + @property + def value(self) -> A: ... + @property + def child(self) -> ShiftingLeftProtocol[B, C, None]: ... + +class ShiftingRightProtocol[A, B, C](Protocol): + @property + def value(self) -> A: ... + @property + def child(self) -> ShiftingRightProtocol[B, C, None]: ... + +# These recursive specializations reach an exact repetition after shifting out every initial +# argument. +static_assert( + is_subtype_of( + ShiftingLeftProtocol[int, str, bytes], + ShiftingRightProtocol[int, str, bytes], + ) +) + +class SaturatingLeftProtocol[T](Protocol): + @property + def value(self) -> T: ... + @property + def child(self) -> SaturatingLeftProtocol[T | int]: ... + +class SaturatingRightProtocol[T](Protocol): + @property + def value(self) -> T: ... + @property + def child(self) -> SaturatingRightProtocol[T | int]: ... + +# Repeatedly adding the same union element also reaches an exact repetition. +static_assert(is_subtype_of(SaturatingLeftProtocol[str], SaturatingRightProtocol[str])) + +# A nested alias can capture the protocol argument while the recursive reference resets that +# argument to a constant. +class CapturedResetLeftProtocol[T](Protocol): + type Inner = tuple[T, CapturedResetLeftProtocol[int]] + value: Inner + +class CapturedResetRightProtocol[T](Protocol): + type Inner = tuple[T, CapturedResetRightProtocol[int]] + value: Inner + +# TODO: These structurally equivalent protocols should be recognized as subtypes. +static_assert(not is_subtype_of(CapturedResetLeftProtocol[str], CapturedResetRightProtocol[str])) + class FiniteLeft[T](Protocol): value: T @@ -6118,7 +6749,7 @@ y: A | Foo[A] # The same thing, but using the legacy syntax: -S = TypeVar("S") +S = TypeVar("S", covariant=True) class Bar(Protocol[S]): def x(self) -> "S | Bar[S]": ... @@ -6182,6 +6813,403 @@ def check(value: Left[int]) -> None: expect_right2(value) # error: [invalid-argument-type] ``` +Generic-call inference must also avoid expanding recursive protocol members when checking whether +its argument constraints are independent: + +```py +def accept[U, V](outer: U, recursive: V) -> None: ... +def infer[T](outer: T, recursive: C[int]) -> None: + accept(outer, recursive) +``` + +### Recursive protocol requirements after matching finite members + +Matching a finite member does not prove that the whole protocol is compatible. The recursive `split` +requirement also carries `T` in a tuple element, so these specializations are incompatible even +though their `marker` methods match. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to + +class Node[T](Protocol): + def marker(self) -> int: ... + def split(self) -> tuple[T, Node[T]]: ... + +static_assert(not is_assignable_to(Node[str], Node[int])) +``` + +### Nested protocol source members with finite targets + +A source specialization can contain its own protocol even when the target has no recursive +requirements. This must not activate a recursive matching shortcut or discard the source member. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_constraint_set_assignable_to + +class Consumer[T](Protocol): + def consume(self, value: T | int) -> None: ... + +static_assert(is_constraint_set_assignable_to(Consumer[Consumer[int]], Consumer[int])) +``` + +### Recursive members in the source specialization + +A protocol member can contain the same protocol in the source specialization but remain finite in +the target specialization. Its structural requirements must still contribute all valid solutions, +even when nominal inheritance alone would infer a narrower type. The same members also establish +assignability when no type variables need to be inferred. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to + +class Consumer[T](Protocol): + def consume(self, value: T | int) -> None: ... + @property + def child(self) -> Consumer[T]: ... + +static_assert(is_assignable_to(Consumer[Consumer[int]], Consumer[int])) + +def extract[T](consumer: Consumer[T]) -> T: + raise NotImplementedError + +def check(value: Consumer[Consumer[int]]) -> None: + reveal_type(extract(value)) # revealed: Consumer[int] | int +``` + +An explicit receiver annotation introduces constraints when comparing a bound method with a +callable. The return types still match structurally, so union simplification retains only the +callable. + +```py +class Receiver: + def method[S](self: S, value: S, /) -> Consumer[Consumer[int]]: + raise NotImplementedError + +def check_union(receiver: Receiver, callback: Callable[[object], Consumer[int]], flag: bool) -> None: + reveal_type(receiver.method if flag else callback) # revealed: (object, /) -> Consumer[int] +``` + +### Repeated applications of a nonrecursive alias + +Nested applications of the same finite alias do not create an alias cycle. Their protocol +requirement must still contribute its type-variable constraints. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol +from ty_extensions._internal import is_constraint_set_assignable_to + +type Identity[T] = T + +class Recursive[T](Protocol): + @property + def value(self) -> Identity[Identity[T]]: ... + @property + def child(self) -> Recursive[T]: ... + +def inspect[T]() -> None: + constraints = is_constraint_set_assignable_to(Recursive[int], Recursive[T]) + reveal_type(constraints.solutions_for(T, inferable=tuple[T])) # revealed: tuple[Solution[T=int]] +``` + +### Growing aliases in recursive protocol requirements + +A recursive alias can change its specialization every time its definition is expanded. Finite +protocol matching must detect the repeated alias definition instead of following the growing +specializations indefinitely. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol + +type GrowingAlias[T] = T | GrowingAlias[list[T]] # error: [cyclic-type-alias-definition] + +class Recursive[T](Protocol): + def consume(self, value: T) -> None: ... + def growing(self) -> GrowingAlias[T]: ... + def child(self) -> Recursive[T]: ... + +def check(value: Recursive[int]) -> None: + rejected: Recursive[str] = value # error: [invalid-assignment] +``` + +### Generic constructors inheriting recursive protocols + +A generic constructor can infer its specialization from an expected recursive protocol even when the +protocol includes a method with an explicitly constrained receiver. Invalid constructor arguments +are rejected. Without an expected type, the empty tuple is an `Iterable[Never]`, so the constructor +infers `T = Never` regardless of the protocol's variance. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol + +class Chain[T](Protocol): + def value(self) -> T: ... + def combine[S](self: Chain[S], pair: tuple[S, T]) -> Chain[T]: ... + +class Concrete[T](Chain[T]): + def __init__(self, values: Iterable[T]) -> None: ... + +contextual: Chain[int] = Concrete(()) +reveal_type(contextual) # revealed: Concrete[int] +wrong: Chain[int] = Concrete(("wrong",)) # error: [invalid-assignment] +reveal_type(Concrete(())) # revealed: Concrete[Never] + +def make() -> Chain[int]: + return Concrete(()) +``` + +### Specialized sources with constrained protocol receivers + +A concrete class can inherit recursive methods with explicitly constrained receivers. Concrete, +symbolic, and unknown specializations bind those receivers and preserve the corresponding return +types. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol + +class Chain[T](Protocol): + def value(self) -> T: ... + def accumulate[S](self: Chain[S]) -> Chain[S]: ... + +class Concrete[T](Chain[T]): ... + +def check[T](concrete: Concrete[int], symbolic: Concrete[T]) -> None: + reveal_type(concrete.accumulate()) # revealed: Chain[int] + reveal_type(symbolic.accumulate()) # revealed: Chain[T@check] + reveal_type(Concrete().accumulate()) # revealed: Chain[Never] +``` + +### Nested symbolic sources with constrained protocol receivers + +Type variables nested inside a concrete class's specialization still contribute constraints when +binding an inherited recursive protocol method. Tuple, union, and aliased specializations preserve +their element types in the result. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol + +class Chain[T](Protocol): + def value(self) -> T: ... + def accumulate[S](self: Chain[S]) -> Chain[S]: ... + +class Concrete[T](Chain[T]): ... + +type Wrapped[T] = tuple[T] + +def check[T](nested: Concrete[tuple[T]], union: Concrete[T | list[T]], aliased: Concrete[Wrapped[T]]) -> None: + reveal_type(nested.accumulate()) # revealed: Chain[tuple[T@check]] + reveal_type(union.accumulate()) # revealed: Chain[T@check | list[T@check]] + reveal_type(aliased.accumulate()) # revealed: Chain[Wrapped[T@check]] +``` + +### Incompatible explicit receivers on recursive protocols + +The `value` and `write` methods make `Chain` invariant. Calling `flatten` on `Chain[list[int]]` is +therefore invalid, even though `list[int]` is assignable to `Iterable[int]`. The incompatible +`write` requirement rejects the receiver without expanding the recursive specializations in +`combinations` and `product`. A receiver specialized with `Iterable[int]` remains valid and +preserves the element type. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from collections.abc import Iterable +from typing import Literal, Protocol, overload + +class Chain[T](Protocol): + def value(self) -> T: ... + @overload + def combinations(self, length: Literal[2]) -> Chain[tuple[T, T]]: ... + @overload + def combinations(self, length: Literal[3]) -> Chain[tuple[T, T, T]]: ... + @overload + def combinations(self, length: int) -> Chain[tuple[T, ...]]: ... + def product(self) -> Chain[tuple[T]]: ... + def flatten[U](self: Chain[Iterable[U]]) -> Chain[U]: ... + def write(self, items: Iterable[T]) -> None: ... + +def invalid(value: Chain[list[int]]) -> None: + value.flatten() # error: [invalid-argument-type] + +def valid(value: Chain[Iterable[int]]) -> None: + reveal_type(value.flatten()) # revealed: Chain[int] +``` + +### Explicit receivers on overloaded recursive protocol methods + +An overloaded method can constrain its receiver to a tuple specialization of the same recursive +protocol. Comparing the fixed-length overload with the gradual fallback terminates without +repeatedly expanding the recursive requirement, and the call preserves the callback's return type. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol, overload + +class Chain[T](Protocol): + def value(self) -> T: ... + def child(self) -> Chain[tuple[T]]: ... + @overload + def map_star[A, B, R](self: Chain[tuple[A, B]], callback: Callable[[A, B], R]) -> Chain[R]: ... + @overload + def map_star[R](self: Chain[tuple[Any, ...]], callback: Callable[..., R]) -> Chain[R]: ... + +def check(value: Chain[tuple[int, str]]) -> None: + reveal_type(value.map_star(lambda first, second: 1)) # revealed: Chain[Literal[1]] +``` + +### Structural inference from recursive protocol requirements + +An inherited protocol specialization can erase a class type parameter. A recursive member can still +recover that parameter structurally, so satisfying the nominal relation is not enough to stop +collecting protocol constraints. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol + +class Recursive[A, B](Protocol): + first: A + def value(self, child: Recursive[Any, Any]) -> B: ... + +class Erased[T, U](Recursive[T, Any]): + def value(self, child: Recursive[Any, Any]) -> U: + raise NotImplementedError + + def __init__(self, callback: Callable[[U], object]) -> None: ... + +pair: Recursive[int, str] = Erased(lambda value: reveal_type(value)) # revealed: str +``` + +The same inference is needed when an erased source argument contains a nested type variable. The +nominal relation constrains `T`, but only the recursive `value` member can infer `U` through the +tuple. + +```py +def make[T, U](callback: Callable[[U], object]) -> Erased[T, tuple[U]]: + raise NotImplementedError + +nested: Recursive[int, tuple[str]] = make(lambda value: reveal_type(value)) # revealed: str +``` + +### Overridden recursive protocol requirements + +Recursive requirements remain significant when a concrete class overrides them. Their constraints +reject incompatible assignments and preserve generic inference. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from __future__ import annotations + +from typing import Protocol + +class Node[T](Protocol): + @property + def value(self) -> T: ... + @property + def child(self) -> Node[T]: ... + +class Mismatched[T](Node[object]): + def __init__(self, value: T) -> None: ... + @property + def value(self) -> T: + raise NotImplementedError + + @property + def child(self) -> Node[str]: + raise NotImplementedError + +bad_assignment: Node[int] = Mismatched(1) # error: [invalid-assignment] + +def extract[U](node: Node[U]) -> U: + raise NotImplementedError + +reveal_type(extract(Mismatched(1))) # revealed: object +``` + ### Recursive legacy generic protocol ```py @@ -6839,8 +7867,8 @@ Protocols can have TypeVars with forward reference bounds that form cycles. ```py from typing import Any, Protocol, TypeVar -T1 = TypeVar("T1", bound="A2[Any]") -T2 = TypeVar("T2", bound="A1[Any]") +T1 = TypeVar("T1", bound="A2[Any]", covariant=True) +T2 = TypeVar("T2", bound="A1[Any]", covariant=True) T3 = TypeVar("T3", bound="B2[Any]") T4 = TypeVar("T4", bound="B1[Any]") @@ -6903,6 +7931,10 @@ visible. As of Python 3.13, it is necessary because structurally inferring throu `close() -> _ReturnT_co | None` can spuriously infer `None`. The latter workaround can be removed once [ty#3596](https://github.com/astral-sh/ty/issues/3596) is fixed. +The custom protocol below is invariant because its mutable list contains a generator with a +covariant return parameter. Variance validation respects that declaration even when the parameter is +not structurally visible. + ```toml [environment] python-version = "3.12" @@ -6913,7 +7945,7 @@ from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to, is_subtype_of, is_assignable_to from typing import Generator, Awaitable, Protocol, TypeVar, Any, Protocol -T_co = TypeVar("T_co", covariant=True) +T = TypeVar("T") class A: ... class B: ... @@ -6933,21 +7965,20 @@ static_assert(not is_equivalent_to(Awaitable[A], Awaitable[Any])) static_assert(not is_subtype_of(Awaitable[A], Awaitable[B])) static_assert(not is_assignable_to(Awaitable[A], Awaitable[B])) -# `Generator` is invariant in its return type here, which drags `T_co` to invariance too -# error: [invalid-generic-class] "Variance of type variable `T_co` is incompatible with its usage in `CustomCovariantProtocol`" -class CustomCovariantProtocol(Protocol[T_co]): - def foo(self) -> tuple[list[Generator[None, None, T_co]]]: ... +class CustomInvariantProtocol(Protocol[T]): + def foo(self) -> tuple[list[Generator[None, None, T]]]: ... -static_assert(not is_equivalent_to(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) -static_assert(not is_equivalent_to(CustomCovariantProtocol[A], CustomCovariantProtocol[Any])) -static_assert(not is_subtype_of(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) -static_assert(not is_assignable_to(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) +static_assert(not is_equivalent_to(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) +static_assert(not is_equivalent_to(CustomInvariantProtocol[A], CustomInvariantProtocol[Any])) +static_assert(not is_subtype_of(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) +static_assert(not is_assignable_to(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) ``` ## The `Generator` protocol's `_ReturnT_co` appears in `close` as of Python 3.13 The same test cases as above, but for Python 3.13 instead of 3.12. In this version `_ReturnT_co` -appears in `Generator`'s `close` method. +appears in `Generator`'s `close` method. The custom protocol is invariant because this return type +is exposed inside a mutable list. ```toml [environment] @@ -6959,7 +7990,7 @@ from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to, is_subtype_of, is_assignable_to from typing import Generator, Awaitable, TypeVar, Protocol, Any -T_co = TypeVar("T_co", covariant=True) +T = TypeVar("T") class A: ... class B: ... @@ -6977,15 +8008,13 @@ static_assert(not is_equivalent_to(Awaitable[A], Awaitable[Any])) static_assert(not is_subtype_of(Awaitable[A], Awaitable[B])) static_assert(not is_assignable_to(Awaitable[A], Awaitable[B])) -# `Generator` is invariant in its return type here, which drags `T_co` to invariance too -# error: [invalid-generic-class] "Variance of type variable `T_co` is incompatible with its usage in `CustomCovariantProtocol`" -class CustomCovariantProtocol(Protocol[T_co]): - def foo(self) -> tuple[list[Generator[None, None, T_co]]]: ... +class CustomInvariantProtocol(Protocol[T]): + def foo(self) -> tuple[list[Generator[None, None, T]]]: ... -static_assert(not is_equivalent_to(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) -static_assert(not is_equivalent_to(CustomCovariantProtocol[A], CustomCovariantProtocol[Any])) -static_assert(not is_subtype_of(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) -static_assert(not is_assignable_to(CustomCovariantProtocol[A], CustomCovariantProtocol[B])) +static_assert(not is_equivalent_to(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) +static_assert(not is_equivalent_to(CustomInvariantProtocol[A], CustomInvariantProtocol[Any])) +static_assert(not is_subtype_of(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) +static_assert(not is_assignable_to(CustomInvariantProtocol[A], CustomInvariantProtocol[B])) ``` ## Inferring async return contexts on Python 3.13 or newer diff --git a/crates/ty_python_semantic/resources/mdtest/pytest.md b/crates/ty_python_semantic/resources/mdtest/pytest.md index 54f864f6d9..7333a93c6d 100644 --- a/crates/ty_python_semantic/resources/mdtest/pytest.md +++ b/crates/ty_python_semantic/resources/mdtest/pytest.md @@ -403,12 +403,12 @@ import pytest def value() -> int: return 1 -@pytest.mark.parametrize("value", ["a", "b"]) +@pytest.mark.parametrize("value", ["a", "b"]) # error: [dynamic-function-decorator-return] "Decorator returns `Any`" def test_it(value) -> None: # supplied by the marker, so the same-named fixture does not apply reveal_type(value) # revealed: value@test_it -@pytest.mark.parametrize("other", ["a", "b"]) +@pytest.mark.parametrize("other", ["a", "b"]) # error: [dynamic-function-decorator-return] "Decorator returns `Any`" def test_mixed(other, value) -> None: reveal_type(other) # revealed: other@test_mixed reveal_type(value) # revealed: int diff --git a/crates/ty_python_semantic/resources/mdtest/redundant_condition.md b/crates/ty_python_semantic/resources/mdtest/redundant_condition.md new file mode 100644 index 0000000000..c762d663ba --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/redundant_condition.md @@ -0,0 +1,1787 @@ +# Detection of boolean tests that are always truthy or always falsy + +A common error in Python is to accidentally test truthiness of the wrong object: for example +`if func:` (which is always true) where `if func():` was intended, or `if coroutine():` where +`if await coroutine():` was intended. By default, ty alerts the user to these errors with the error +code `redundant-condition`, but only if the inferred type of the object is not assignable to `int`. +This heuristic catches the `if func` and `if coroutine()` cases, while avoiding false positives on +cases such as `if DEBUG:` where `DEBUG = 0` or `DEBUG = False` is a constant. + +The remaining cases -- where the inferred type is assignable to `int`, or only short-circuit +evaluation makes the condition's truthiness fixed -- are covered by a separate, stricter rule +(`redundant-condition-strict`). + +```toml +[environment] +python-version = "3.14" +python-platform = "linux" +``` + +## Basic cases + +We catch testing a function without calling it: + +```py +def func(): ... + +if func: # error: [redundant-condition] "This condition is always true" + pass +``` + +And testing a method without calling it: + +```py +class Foo: + def bar(self) -> bool: + return True + + def baz(self): + if self.bar: # error: [redundant-condition] "This condition is always true" + pass +``` + +And testing a generator expression without executing it: + +```py +def work(items: list[int]): + filtered = (item for item in items if item < 42) + if filtered: # error: [redundant-condition] "This condition is always true" + pass + assert filtered # error: [redundant-condition] "This condition is always true" +``` + +And testing an awaitable without awaiting it: + +```py +async def coroutine(): ... +async def main(): + if coroutine(): # TODO: should error + pass +``` + +And testing a tuple that is known to always be empty or non-empty: + +```py +class Foo: + def __init__(self): + self.two_element_tuple: tuple[int, int] = (423, 432) + self.at_least_one_element: tuple[int, *tuple[int, ...]] = (42,) + self.at_least_two_elements: tuple[int, int, *tuple[int, ...]] = (42, 42) + self.no_elements: tuple[()] = () + + def other_method(self): + if self.two_element_tuple: # error: [redundant-condition] "This condition is always true" + pass + if self.at_least_one_element: # error: [redundant-condition] "This condition is always true" + pass + if self.at_least_two_elements: # error: [redundant-condition] "This condition is always true" + pass + if self.no_elements: # error: [redundant-condition] "This condition is always false" + pass + + # TODO: should error + assert self.at_least_one_element # error: [redundant-condition] "This condition is always true" + # TODO: should error + assert self.at_least_two_elements # error: [redundant-condition] "This condition is always true" +``` + +And testing `None`: + +```py +X = None + +if X: # error: [redundant-condition] "This condition is always false" + pass +``` + +And testing a string that is known to always be truthy or always be falsy: + +```py +x = "foo" +y = "" + +if x: # error: [redundant-condition] "This condition is always true" + pass + +if y: # error: [redundant-condition] "This condition is always false" + pass +``` + +or even a union of strings that is known to always be truthy: + +```py +from typing import Literal + +def f(x: Literal["a", "b"]): + if x: # error: [redundant-condition] "This condition is always true" + pass +``` + +and testing a `TypedDict` that is known to always be truthy: + +```py +from typing import TypedDict, NotRequired, Required + +class NeverEmpty(TypedDict): + x: int + y: str + +class AlsoNeverEmpty(TypedDict, total=False): + x: Required[int] + +class SometimesEmpty(TypedDict): + x: NotRequired[int] + +class AlsoSometimesEmpty(TypedDict, total=False): + x: int + +def test( + never_empty: NeverEmpty, + also_never_empty: AlsoNeverEmpty, + sometimes_empty: SometimesEmpty, + also_sometimes_empty: AlsoSometimesEmpty, +): + if never_empty: # error: [redundant-condition] "This condition is always true" + pass + + if also_never_empty: # error: [redundant-condition] "This condition is always true" + pass + + if sometimes_empty: # no diagnostic + pass + + if also_sometimes_empty: # no diagnostic + pass + + assert never_empty # error: [redundant-condition] "This condition is always true" + assert also_never_empty # error: [redundant-condition] "This condition is always true" + assert sometimes_empty # no diagnostic + assert also_sometimes_empty # no diagnostic +``` + +and testing an object that is known to be always truthy due to it being `@final` and not defining +`__bool__` or `__len__`: + +```py +from re import Pattern + +def f(x: Pattern[str]): + if x: # TODO: should error + pass +``` + +## Required keys established by narrowing + +A `TypedDict` with no declared required keys can be empty. After a key-presence check establishes +that a key is present, the dictionary is always truthy, so a subsequent truthiness check is +redundant. + +```py +from typing import TypedDict + +class Record(TypedDict): + pass + +def check(value: Record): + if "x" in value: + if value: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Enum instances + +An enum with members is implicitly final, so its instances are always truthy if the enum defines +neither `__bool__` nor `__len__`. + +```py +from enum import Enum + +class Choice(Enum): + FIRST = 1 + SECOND = 2 + +def f(choice: Choice): + if choice: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Other boolean contexts + +Redundant conditions are not merely detected in `if`-statement tests. They are also detected in +unary `not` operations, `while` loops, `assert` statements, `if` expressions, `match` guards, and +comprehension `if` tests. When an `and` or `or` expression is used as a condition, each operand is +checked. + +An `and` or `or` expression used to compute a value is exempt. The assignments to `b` and `c` below +therefore produce no diagnostic, while the corresponding `if` conditions do. + +```py +def coinflip() -> bool: + return True + +def func(): ... + +if not func: # error: [redundant-condition] "This condition is always false" + pass + +if not not func: # error: [redundant-condition] "This condition is always true" + pass + +a = True if func else False # error: [redundant-condition] "This condition is always true" + +if coinflip() if func else False: # error: [redundant-condition] "This condition is always true" + pass + +b = func and coinflip() # no diagnostic + +if func and coinflip(): # error: [redundant-condition] "This condition is always true" + pass + +c = func or coinflip() # no diagnostic + +if func or coinflip(): # error: [redundant-condition] "This condition is always true" + pass + +[x for x in range(3) if func] # error: [redundant-condition] "This condition is always true" + +def function(flag: bool): + if flag: + pass + elif func: # error: [redundant-condition] "This condition is always true" + pass + +def _(): + assert func # error: [redundant-condition] "This condition is always true" + +def _(): + while func and coinflip(): # error: [redundant-condition] "This condition is always true" + pass + +def _(): + while not (func and coinflip()): # error: [redundant-condition] "This condition is always false" + pass + +def f(x: str | int): + match x: + case str() if func: # error: [redundant-condition] "This condition is always true" + pass + +def _(): + while func: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Always truthy values appearing later in compound conditions + +A subexpression in a compound condition can be inferred as always truthy or always falsy even if the +condition overall is inferred as having ambiguous truthiness. We still report these subexpressions: + +```py +def func(): ... +def compound_statement_conditions(flag: bool, other: bool): + if flag and func: # error: [redundant-condition] "This condition is always true" + pass + + if other: + pass + elif flag and func: # error: [redundant-condition] "This condition is always true" + pass + + while flag and func: # error: [redundant-condition] "This condition is always true" + break + + match flag: + case bool() if flag and func: # error: [redundant-condition] "This condition is always true" + pass + +def compound_expression_conditions(flag: bool): + selected = True if flag and func else False # error: [redundant-condition] "This condition is always true" + filtered = [value for value in range(1) if flag and func] # error: [redundant-condition] "This condition is always true" + result = flag and func + +def compound_assertion_condition(flag: bool): + assert flag and func # error: [redundant-condition] "This condition is always true" +``` + +## Chained comparison conditions + +A comparison chain used directly as a condition is always false if any comparison is always false, +even when an earlier comparison returns an object with mutable truthiness. The condition below +always fails because `1 < 0` is false. + +```py +class Comparable: + def __lt__(self, other: int) -> object: ... + +def direct_condition(value: Comparable): + reveal_type(value < 1 < 0) # revealed: ~AlwaysTruthy + reveal_type(bool(value < 1 < 0)) # revealed: bool + + # Short-circuiting makes the direct condition always false, despite the standalone types above. + if value < 1 < 0: # TODO: should flag `value < 1 < 0` + pass + +def negated_condition(value: Comparable): + reveal_type(not (value < 1 < 0)) # revealed: bool + reveal_type(bool(not (value < 1 < 0))) # revealed: bool + + # Short-circuiting makes the direct condition always true, despite the standalone types above. + if not (value < 1 < 0): # TODO: should flag `not (value < 1 < 0)` + pass +``` + +An always-false condition is exempt when its body raises an exception, since this can be a +deliberate defensive check. This exemption also applies when the condition is always false because +of short-circuit evaluation. + +```py +def defensive_condition(value: Comparable): + if value < 1 < 0: # no diagnostic + raise ValueError +``` + +Saving the chain's result, or negating it outside a condition, can cause an intermediate object's +truthiness to be tested twice. Its truthiness can change between those tests, so neither test below +has fixed truthiness. + +```py +def saved_condition(value: Comparable): + saved = value < 1 < 0 + reveal_type(saved) # revealed: ~AlwaysTruthy + reveal_type(bool(saved)) # revealed: bool + + if saved: # no diagnostic + pass + return not (value < 1 < 0) # no diagnostic +``` + +## Conditional expressions used as conditions + +Using `a if flag else b` as a condition tests the truthiness of `a` when `flag` is true, or `b` +otherwise. We report uncalled functions in either position, even when the complete condition has +ambiguous truthiness. Reporting an uncalled function in a subexpression suppresses a second +diagnostic on the complete condition. + +```py +def ready() -> bool: + return False + +def uncalled_functions(flag: bool): + if ready if flag else False: # TODO: should flag `ready` + pass + if False if flag else ready: # TODO: should flag `ready` + pass + if ready if flag else True: # TODO: should flag `ready` + pass + assert ready if flag else False # TODO: should flag `ready` +``` + +The `not` operator also tests truthiness, so we report the uncalled function in +`not (ready if flag else False)`. `not` expressions are flagged in all contexts, not just +`if`/`elif`/`while`/`assert` tests: + +```py +def negated_expression(flag: bool) -> bool: + return not (ready if flag else False) # TODO: should flag `ready` +``` + +Passing a function as an argument does not test its truthiness. Here, `callable()` checks whether +`ready` or `None` can be called, so there is no redundant truthiness test of `ready`: + +```py +def callable_check(flag: bool): + if callable(ready if flag else None): # no diagnostic + pass +``` + +Boolean branches inside an assertion remain exempt, since the assertion can defend against +incorrectly typed runtime values. Outside assertions, an always-true conditional expression is +reported as a whole: + +```py +def boolean_branches(value: int, flag: bool): + assert isinstance(value, int) if flag else True # no diagnostic + + # TODO: should flag `isinstance(value, int) if flag else True` + if isinstance(value, int) if flag else True: + pass +``` + +Both branches of this conditional expression are truthy when evaluated directly as conditions. Even +if `value` has mutable truthiness, `value or True` short-circuits directly to the loop body when +`value` is truthy and evaluates `True` otherwise. + +```py +def conditional_expression(value: object, flag: bool): + # TODO: should flag `True if flag else (value or True)` + while True if flag else (value or True): + break +``` + +## Edge cases + +A nonempty tuple subclass can still be falsy if it overrides `__bool__`: + +```py +from typing import Any, Literal, Never +from types import CoroutineType + +async def coroutine(): ... + +class FalsyTuple(tuple[int, int]): + def __bool__(self) -> Literal[False]: + return False + +def check_falsy_tuple(value: FalsyTuple): + if value: # error: [redundant-condition] "This condition is always false" + pass +``` + +## Strict version + +Our stricter `redundant-condition-strict` rule extends this logic to boolean and integer tests: + +```py +from typing import Literal + +def f(x: Literal[1, 2]): + if x > 5: # TODO: should error + pass + + if x: # error: [redundant-condition] "This condition is always true" + pass + +def g(flag: bool, some_bytes: bytes): + if flag: + pass + elif some_bytes[0] == b"\x1e": # TODO: should error + pass + +def falsy(flag: bool): + if flag: + pass + elif "foo" == b"foo": # TODO: should error + pass +``` + +`redundant-condition-strict` is also emitted on negated conditions where the negated condition is +inferred as an instance of `bool`: + +```py +def negated_conditions(): + if not 1 > 2: # TODO: should error + pass + + if not 1 < 2: # TODO: should error + pass + + if not 0 == 1: # TODO: should error + pass + + if not 1 == 1: # TODO: should error + pass + + if not not 1 == 1: # TODO: should error + pass + +def negated_conditional_contexts(flag: bool): + if flag: + pass + elif not 1 == 0: # TODO: should error + pass + + while not 1 == 0: # TODO: should error + break +``` + +Outside a statement condition, a `not` expression still tests its operand's truthiness. The strict +rule reports redundant boolean and integer operands in assignments and return expressions: + +```py +def negated_boolean_assignment(value: str): + result = not isinstance(value, str) # TODO: should error + +def negated_integer_return(value: Literal[1, 2]) -> bool: + return not value # TODO: should error +``` + +When the strict rule can report that a complete compound condition is always true or always false, +it reports that condition instead of its operands. Only a single diagnostic is emitted on each of +these: + +```py +def compound_truthy(x: str): + if isinstance(x, str) and isinstance(x, str): # TODO: should error + pass + + while isinstance(x, str) and isinstance(x, str): # TODO: should error + break + + match x: + case str() if isinstance(x, str) and isinstance(x, str): # TODO: should error + pass +``` + +## Redundant boolean operands in ambiguous conditions + +When a condition's outcome is unknown, the strict rule reports individual operands with fixed +truthiness. These checks do not affect the outcome: `value is not None` is always true given the +annotation, while `value is None` is always false. The result depends on `enabled` in either case: + +```py +def check(value: int, enabled: bool): + if enabled and value is not None: # TODO: should flag `value is not None` + print(value) + if value is not None and enabled: # TODO: should flag `value is not None` + print(value) + if enabled or value is None: # TODO: should flag `value is None` + print(value) + if value is None or enabled: # TODO: should flag `value is None` + print(value) +``` + +The same operand checks apply to loops, match guards, conditional expressions, and comprehension +filters: + +```py +def condition_contexts(value: int, enabled: bool): + while enabled and value is not None: # TODO: should flag `value is not None` + break + + match value: + # TODO: should flag `value is not None` + case _ if enabled and value is not None: + pass + + # TODO: should flag `value is not None` + selected = value if enabled and value is not None else 0 + + # TODO: should flag `item is not None` + filtered = [item for item in range(3) if enabled and item is not None] +``` + +Nested conditions are reported at the largest expression with fixed truthiness. Negation does not +hide a redundant operand when the complete condition still has unknown truthiness: + +```py +def nested(value: int, enabled: bool): + # TODO: should flag `value is not None and isinstance(value, int)` + if enabled and (value is not None and isinstance(value, int)): + print(value) + if not (enabled or value is None): # TODO: should flag `value is None` + print(value) + # TODO: should flag `(enabled and value is not None) or True` + if (enabled and value is not None) or True: + print(value) +``` + +When separate operands are redundant, both are reported. An always-true operand later in an `and` +expression does not replace a diagnostic on an earlier operand: + +```py +def separate_operands(value: int, text: str, enabled: bool): + if ( + value is not None # TODO: should flag `value is not None` + and enabled + and isinstance(text, str) # TODO: should flag `isinstance(text, str)` + ): + print(value) +``` + +An operand can have fixed truthiness due to short-circuit evaluation, even when its value type does +not guarantee that truthiness: + +```py +def short_circuit_operands(value: object, enabled: bool): + if enabled and (value or True): # TODO: should flag `value or True` + pass + if enabled or (value and False): # TODO: should flag `value and False` + pass +``` + +The strict rule also checks the body and `else` expression of a conditional expression used as a +condition. Here, `value is not None` is always true, even though the complete condition can be false +when it evaluates to `enabled`: + +```py +def conditional_branch(value: int, select: bool, enabled: bool): + # TODO: should flag `value is not None` + if value is not None if select else enabled: + print(value) +``` + +## Compound conditions with mixed value types + +Reporting a subexpression under `redundant-condition` takes precedence over reporting the complete +condition under `redundant-condition-strict`. Negating the condition does not add a second +diagnostic for the same subexpression. + +```py +def func(): ... +def mixed_operands(value: object): + if func and False: # error: [redundant-condition] "This condition is always true" + pass + + if not (value or func): # error: [redundant-condition] "This condition is always false" + pass +``` + +When neither operand is reported, the strict rule can report a fixed outcome established by +short-circuit evaluation, even if the expression's value type has ambiguous truthiness. + +```py +def short_circuit(value: object): + reveal_type(value and False) # revealed: ~AlwaysTruthy + reveal_type(bool(value and False)) # revealed: bool + + # Short-circuiting means this body is never reached, despite the standalone types above. + if value and False: # TODO: should flag `value and False` + pass +``` + +## Boolean tests inside value expressions + +A call's arguments compute values, but can contain their own boolean tests. Those tests are checked +even when the call itself has ambiguous truthiness. + +```py +def func(): ... +def accepts(value: object) -> bool: + return bool(value) + +def nested_tests(): + if accepts(not func): # TODO: should error + pass +``` + +`lambda` bodies and comprehension filters have their own scopes. `lambda` defaults and a +comprehension's first iterable are evaluated in the enclosing scope. Each nested boolean test is +reported once in either case. + +```py +def nested_scopes(): + if accepts(lambda: not func): # TODO: should error + pass + if accepts(lambda value=not func: value): # TODO: should error + pass + if accepts([item for item in (not func,)]): # TODO: should error + pass + if accepts([item for item in range(2) if not func]): # error: [redundant-condition] "This condition is always false" + pass +``` + +Compound conditions in conditional expressions and comprehension filters also report the complete +condition once, rather than both the condition and its negated operand. + +```py +def compound_expression_tests(): + selected = 1 if not not (1 == 1) else 0 # TODO: should flag `not not (1 == 1)` + filtered = [ + item + for item in range(2) + # TODO: should flag `not not (1 == 1)` + if not not (1 == 1) + ] +``` + +Each branch of a conditional expression can contain its own boolean test. Both `not func` +expressions are redundant, regardless of which one runs: + +```py +def selected_values(flag: bool): + # TODO: should flag both uses of `func` + selected = not func if flag else not func +``` + +## Redundant boolean tests in call arguments + +Boolean tests in call arguments are independent of the enclosing condition's truthiness: + +```py +def accepts(value: bool) -> bool: + return value + +def nested_boolean_test(value: int, enabled: bool): + # TODO: should flag `value is None` + if enabled and accepts(not (value is None)): + pass +``` + +## `if` and `while` conditions that use AST literal bools or ints + +We maintain a special case for `while` loops, since `while True:` and `while 1:` are common idioms +used to create infinite loops in Python code. Complaining that the conditions `True` and `1` are +"always truthy" in these contexts would obviously be absurd. + +```py +def _(): + while True: # no error + pass + +def _(): + while 1: + pass # no error +``` + +Similarly, some projects use literal `if False:` or `if 0:` in their source code, to mark a region +that is intentionally unreachable, but which could be enabled for debugging purposes. If we see an +*AST literal* used as a condition, rather than a place that is inferred as having a literal *type*, +we suppress the diagnostic: it is assumed that this region is deliberately unreachable. + +```py +if False: # no diagnostic + pass + +if 0: # no diagnostic + pass +``` + +For consistency, we do the same for `if True:`, `if 1:`, `if 2:`, etc.: + +```py +if 1: # no diagnostic + pass + +if True: # no diagnostic + pass + +if 2: # no diagnostic + pass +``` + +## Defensive assertions + +The rules are only applied to tests in `assert` statements (and any subexpressions within those +tests) if the inferred type of the `assert` test is not inferred as being a subtype of `bool` or +`int`. This is to prevent false positives on defensive assertions such as the following, which are +common in well written Python code: + +```py +def f(x: str, y: str | int, z: str | int | bytes): + assert isinstance(x, str) + assert isinstance(y, str) or isinstance(y, int) + assert isinstance(z, str) or isinstance(z, int) or isinstance(z, bytes) + assert isinstance(x, str) and isinstance(y, (str, int)) + assert not not isinstance(x, str) + assert isinstance(x, str) and (isinstance(y, str) or isinstance(y, int)) + assert (isinstance(y, str) or isinstance(y, int)) and not not isinstance(x, str) +``` + +The ordinary rule still applies inside assertion tests. An assertion message computes a value, so +neither rule checks its `and` or `or` operands: + +```py +def func(): ... +def assertion_boundaries(x: str, flag: bool): + assert func and isinstance(x, str) # error: [redundant-condition] "This condition is always true" + + # no diagnostic: `and` is used as a value expression here, not as a condition. + assert flag, isinstance(x, str) and flag +``` + +Boolean and short-circuit operands within assertions remain exempt when the complete assertion has +unknown truthiness. This includes boolean tests nested inside call arguments: + +```py +def accepts(value: bool) -> bool: + return value + +def ambiguous_boolean_and(value: int, flag: bool): + assert flag and value is not None # no diagnostic + +def ambiguous_boolean_or(value: int, flag: bool): + assert flag or value is None # no diagnostic + +def ambiguous_short_circuit(other: object, flag: bool): + assert flag and (other or True) # no diagnostic + +def nested_boolean_assertion(value: int, flag: bool): + assert flag and accepts(not (value is None)) # no diagnostic +``` + +Short-circuit conditions remain exempt when they are the complete assertion, whether they always +succeed or always fail: + +```py +def short_circuit_assertion(value: object): + assert value or True # no diagnostic + assert value and False # no diagnostic +``` + +The strict rule can still fire in assertion tests if the assertion test uses a walrus expression +(since tests that use walrus expressions are never flagged with `redundant-condition`, only ever +with `redundant-condition-strict`): + +```py +# TODO: should error +assert (value := "foo") +``` + +## `sys.version_info` checks, `sys.platform` checks, `os.name` checks, `if TYPE_CHECKING` checks + +Certain stdlib constants are heavily special-cased by ty, leading us to infer that certain `if` +tests involving these constants will always be truthy or always be falsy. Since the branches of code +here are deliberately unreachable, we try to avoid emitting false-positive diagnostics on these as +well: + +`a.py`: + +```py +import sys +import os +import typing +from typing import TYPE_CHECKING + +def coinflip() -> bool: + return False + +reveal_type(sys.version_info >= (3, 14)) # revealed: Literal[True] +reveal_type(sys.version_info < (3, 15)) # revealed: Literal[True] + +if sys.version_info >= (3, 14): # no diagnostic + pass + +if coinflip(): + pass +elif sys.version_info < (3, 15): # no diagnostic + pass + +if os.name == "posix": # no diagnostic + pass + +if coinflip(): + pass +elif os.name == "nt": # no diagnostic + pass + +reveal_type(TYPE_CHECKING) # revealed: Literal[True] + +if TYPE_CHECKING: # no diagnostic + pass + +reveal_type(typing.TYPE_CHECKING) # revealed: Literal[True] + +if not typing.TYPE_CHECKING: # no diagnostic + pass + +if sys.version_info < (3, 15): + pass +elif (3, 12) <= sys.version_info < (3, 13): # no diagnostic + pass + +if os.name == "posix": + pass +elif os.name == "nt": # no diagnostic + pass +``` + +This also applies to the enabled-by-default `redundant-condition` rule, which only applies when +checking a condition that is not inferred as being assignable to `int`. A value that depends on an +environment guard is exempt whether it is assigned using a conditional expression or an `if` +statement: + +`b.py`: + +```py +import sys + +catch_exe_failure = "\n" if sys.platform == "win32" else "" + +reveal_type(catch_exe_failure) # revealed: Literal[""] + +if catch_exe_failure: # no diagnostic + pass + +if sys.platform == "win32": + line_prefix = "\n" +else: + line_prefix = "" + +reveal_type(line_prefix) # revealed: Literal[""] + +if line_prefix: # no diagnostic + pass +``` + +This even applies to cases where the value of one of these constants is aliased to a variable in the +module namespace: + +`c.py`: + +```py +import os +import sys +from os import name as os_name +from typing import TYPE_CHECKING +from typing_extensions import TYPE_CHECKING as TYPE_CHECKINGGGGG +from sys import version_info as foo, platform as sys_platform + +PLATFORM = sys.platform + +if PLATFORM == "linux": # no diagnostic + pass + +PLATFORM_ALIAS = PLATFORM + +if PLATFORM_ALIAS == "linux": # no diagnostic + pass + +OS_MODULE = os +OPERATING_SYSTEM = OS_MODULE.name + +if OPERATING_SYSTEM == "posix": # no diagnostic + pass + +IS_PY314 = sys.version_info >= (3, 14) +reveal_type(IS_PY314) # revealed: Literal[True] + +if IS_PY314: # no diagnostic + pass + +if not IS_PY314: # no diagnostic + pass + +VERSION_INFO = sys.version_info + +if VERSION_INFO >= (3, 14): # no diagnostic + pass + +CHECKING = TYPE_CHECKING + +if CHECKING: # no diagnostic + pass + +ORDINARY_CONSTANT = 1 == 1 + +if ORDINARY_CONSTANT: # error: [redundant-condition] "This condition is always true" + pass + +BAR = foo + +reveal_type(BAR >= (3, 14)) # revealed: Literal[True] + +if BAR >= (3, 14): # no diagnostic + pass + +reveal_type(TYPE_CHECKINGGGGG) # revealed: Literal[True] + +if TYPE_CHECKINGGGGG: # error: [redundant-condition] "This condition is always true" + pass + +reveal_type(sys_platform) # revealed: Literal["linux"] + +if sys_platform == "linux": # no diagnostic + pass + +reveal_type(os_name) # revealed: Literal["posix"] + +if os_name == "posix": # no diagnostic + pass +``` + +And even in other imported modules: + +`d.py`: + +```py +import c +from b import line_prefix +from c import IS_PY314, PLATFORM, BAR + +if line_prefix: # no diagnostic + pass + +if PLATFORM == "linux": # no diagnostic + pass + +if c.PLATFORM_ALIAS == "linux": # no diagnostic + pass + +if IS_PY314: # no diagnostic + pass + +reveal_type(BAR >= (3, 14)) # revealed: Literal[True] + +if BAR >= (3, 14): # no diagnostic + pass +``` + +Attribute aliases retain their environment-dependent origin. Different members of the same receiver +can have different origins, and rebinding or narrowing the receiver can change which definition an +attribute refers to. + +`attribute_aliases.py`: + +```py +import sys +from typing import Final + +class PlatformConfig: + enabled: Final = sys.platform == "linux" + fixed: Final = True + +class FixedConfig: + enabled: Final = True + +def rebound_receiver(): + config = PlatformConfig() + if config.enabled: # no diagnostic + pass + if config.fixed: # error: [redundant-condition] "This condition is always true" + pass + + config = FixedConfig() + if config.enabled: # error: [redundant-condition] "This condition is always true" + pass + +def narrowed_receiver(config: PlatformConfig | FixedConfig): + if config.enabled: # no diagnostic + pass + + if isinstance(config, FixedConfig): + if config.enabled: # error: [redundant-condition] "This condition is always true" + pass + else: + if config.enabled: # no diagnostic + pass +``` + +Named expressions and unpacked assignments preserve the same environment-dependent origin as +ordinary assignments. Their aliases remain exempt when tested later. + +`assignment_forms.py`: + +```py +import sys + +if windows := sys.platform == "win32": # no diagnostic + pass +if windows: # no diagnostic + pass + +unix, version = sys.platform != "win32", sys.version_info +if unix: # no diagnostic + pass +if version >= (3, 14): # no diagnostic + pass + +def local_aliases(): + if is_windows := sys.platform == "win32": # no diagnostic + pass + if is_windows: # no diagnostic + pass + + is_unix, major = sys.platform != "win32", sys.version_info.major + if is_unix: # no diagnostic + pass + if major >= 3: # no diagnostic + pass + +if ordinary := 1 == 1: # TODO: should error + pass +if ordinary: # error: [redundant-condition] "This condition is always true" + pass +``` + +Augmented assignments also preserve the environment-dependent origin of their right-hand side. + +`augmented_assignment.py`: + +```py +import sys + +platform = "" +platform += sys.platform +if platform == "win32": # no diagnostic + pass + +fixed = "" +fixed += "linux" +if fixed == "win32": # TODO: should error + pass +``` + +Following aliases also terminates when assignments form a cycle. An ordinary cycle does not make an +always-truthy condition environment-dependent, whether the aliases are names or instance attributes. + +`cyclic_aliases.py`: + +```py +def plain_cycle(flag: bool): + first = second = "ready" + while flag: + first = second + second = first + if first: # error: [redundant-condition] "This condition is always true" + pass + +class AttributeCycle: + def check(self, flag: bool): + self.first = self.second = "ready" + while flag: + self.first = self.second + self.second = self.first + if self.first: # TODO: should error + pass +``` + +An environment-dependent assignment is still recognized after following a cycle of +instance-attribute aliases. + +```py +import sys + +class PlatformAttributeCycle: + def check(self, flag: bool): + self.first = self.second = "ready" + while flag: + self.first = self.second + self.second = self.first + self.second = sys.platform + reveal_type(bool(self.first)) # revealed: Literal[True] + if self.first: + pass +``` + +## Environment-dependent assignment guards + +An assignment can depend on nested conditions or aliases of environment guards. The assigned value +remains exempt when tested inside a function: + +```py +import sys + +WINDOWS = sys.platform == "win32" + +def nested_guards(enabled: bool): + if enabled: + if WINDOWS: # no diagnostic + prefix = "\n" + else: + prefix = "" + reveal_type(prefix) # revealed: Literal[""] + if prefix: # no diagnostic + pass +``` + +Boolean values assigned under compound environment guards are also exempt, although they would +otherwise be reported by the strict rule: + +```py +import os +from typing import TYPE_CHECKING + +if os.name == "posix" and TYPE_CHECKING: + enabled = True +else: + enabled = False + +reveal_type(enabled) # revealed: Literal[True] +if enabled: # no diagnostic + pass +``` + +Assignments in `match` cases depend on the subject being matched, just as assignments in an `if` +statement depend on its condition: + +```py +match sys.platform: + case "win32": + marker = ">" + case _: + marker = "" + +reveal_type(marker) # revealed: Literal[""] +if marker: # no diagnostic + pass +``` + +Ordinary predicates do not exempt assignments. A predicate can itself refer to the variable being +assigned without making it environment-dependent: + +```py +def ordinary_guard(flag: bool): + if flag: + value = "ready" + else: + value = "ready" + if value: # error: [redundant-condition] "This condition is always true" + pass + +def recursive_guard(): + value = "ready" + if value: # error: [redundant-condition] "This condition is always true" + value = "still ready" +``` + +A completed environment-dependent branch or a call that merely reads an environment constant does +not make subsequent assignments environment-dependent: + +```py +if sys.platform == "win32": + pass + +print(sys.platform) +fixed = "ready" +if fixed: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Environment-dependent loop targets + +Loop targets inherit the environment-dependent origin of their iterable, including when the target +is unpacked or an alias is tested inside the loop. + +```py +import sys + +for is_windows in (sys.platform == "win32",): + if is_windows: # no diagnostic + pass + +for platform, version in ((sys.platform, sys.version_info),): + alias = platform + if alias == "win32": # no diagnostic + pass + if version >= (3, 14): # no diagnostic + pass +``` + +Comprehension targets follow the same rule. The first iterable is evaluated in the enclosing scope; +later iterables are evaluated in the comprehension's scope. + +```py +[flag for flag in (sys.platform == "win32",) if flag] # no diagnostic +[flag for _ in range(1) for flag in (sys.platform == "win32",) if flag] # no diagnostic +[flag for flag, _ in ((sys.platform == "win32", 0),) if flag] # no diagnostic +``` + +Loop and comprehension targets without an environment-dependent source still produce diagnostics. + +```py +for fixed in (True,): + if fixed: # error: [redundant-condition] "This condition is always true" + pass + +[fixed for fixed in (True,) if fixed] # error: [redundant-condition] "This condition is always true" +``` + +## Environment-dependent pattern captures + +Pattern captures inherit the environment-dependent origin of the match subject. This applies to +simple captures, unpacked captures, and aliases used in case guards. + +```py +import sys + +match sys.platform: + case platform: + if platform == "win32": # no diagnostic + pass + +match (sys.platform, sys.version_info): + case (platform, version): + if platform == "win32": # no diagnostic + pass + if version >= (3, 14): # no diagnostic + pass + +match sys.platform == "win32": + case is_windows if is_windows: # no diagnostic + pass +``` + +A capture of an ordinary constant is not exempt. + +```py +match True: + case fixed: + if fixed: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Environment-dependent context manager bindings + +A `with` target can also inherit an environment-dependent value from its context expression. + +```py +import sys +from contextlib import nullcontext + +with nullcontext(sys.version_info) as version: + if version >= (3, 14): # no diagnostic + pass + +with nullcontext((1,)) as fixed: + if fixed: # error: [redundant-condition] "This condition is always true" + pass +``` + +## Environment references in called lambdas and consumed generators + +Calls can execute lambda bodies or consume generator expressions. Environment references inside +those bodies exempt the enclosing condition from both rules, including when the call's result is a +non-boolean object whose truthiness is known. + +```py +import sys + +if (lambda: sys.version_info >= (3, 12))(): # no diagnostic + pass +if next(sys.platform == "linux" for _ in range(1)): # no diagnostic + pass + +if (lambda: sys.platform)(): # no diagnostic + pass +if next(sys.version_info for _ in range(1)): # no diagnostic + pass +``` + +The exemption also follows assignments and aliases, including when a named generator is consumed. + +```py +platform = (lambda: sys.platform)() +if platform: # no diagnostic + pass + +platforms = (sys.platform for _ in range(1)) +alias = platforms +if next(alias): # no diagnostic + pass +``` + +## Deliberately exhaustive `if` statements + +A common pattern is to have an `if` condition that is deliberately always true or false, so that the +user can assert exhaustiveness explicitly. We detect these cases and avoid emitting diagnostics on +them. + +```py +import sys +from typing_extensions import assert_never + +def f1(x: int | str): + if isinstance(x, int): + pass + # always True, but no diagnostic emitted: the `else` block following only contains `raise` statements + elif isinstance(x, str): + pass + else: + raise AssertionError + +def f2(x: int | str): + if isinstance(x, int): + pass + # always False, but no diagnostic emitted: the block only contains `raise` statements + elif not isinstance(x, str): + raise AssertionError + +def f3(x: int | str): + if isinstance(x, int): + pass + # always True, but no diagnostic emitted: the `else` block following only contains `assert` statements + elif isinstance(x, str): + pass + else: + assert False, "unreachable" + +def f4(x: int | str): + if isinstance(x, int): + pass + # always True, but no diagnostic emitted: the `else` block following only contains calls that return `Never` + elif isinstance(x, str): + pass + else: + assert_never(x) + +def f5(x: int | str): + if isinstance(x, int): + pass + # always True, but no diagnostic emitted: the `else` block following only contains calls that return `Never` + elif isinstance(x, str): + pass + else: + "Some documentation as a standalone string, weirdly" + sys.exit("This should never happen??") + +def f6(x: int): + # always True, but no diagnostic emitted: the block inside the `if` only contains `raise` statements + if not isinstance(x, int): + raise TypeError + +def f7(x: int | str): + if isinstance(x, int): + pass + # always True, but no diagnostic emitted: the `else` block following only contains `raise` statements + elif isinstance(x, str) and not isinstance(x, int): + pass + else: + raise AssertionError + +def f8(x: int | str): + if isinstance(x, int): + pass + # always False, but no diagnostic emitted: the block only contains `raise` statements + elif not isinstance(x, str) or isinstance(x, int): + raise AssertionError + +def f9(x: str): + # always False, but no diagnostic emitted: the block only contains `raise` statements + if isinstance(x, str) and not isinstance(x, str): + raise AssertionError + +def f10(x: str): + # always False, but no diagnostic emitted: the block only contains `raise` statements + if not (isinstance(x, str) and isinstance(x, str)): + raise TypeError + +def coinflip() -> bool: + return True + +def f11(x: str): + # always True, but no diagnostic emitted: every control flow path can be easily determined + # to end in a terminal statement + if not isinstance(x, str): + if coinflip(): + message = "seems bad" + raise TypeError(message) + else: + assert False, "oh no" +``` + +We also avoid emitting the diagnostic if the exhaustiveness check just follows the `if` check, and +is not in an `else` branch: + +```py +def g(x: int | str): + if isinstance(x, int): + return + + # always True, but no diagnostic emitted: the code following only contains `raise` statements + if isinstance(x, str): + return + + raise AssertionError + +def g2(x: int | str): + if isinstance(x, int): + return + # always True, but no diagnostic emitted: the code following only contains `assert` statements + elif isinstance(x, str): + return + + assert False, "unreachable" +``` + +This also works if the entire block is nested: + +```py +def unrelated_condition() -> bool: + return False + +def h(x: int | str): + if unrelated_condition(): + if isinstance(x, int): + return + + # always True, but no diagnostic emitted: the code following only contains `raise` statements + if isinstance(x, str): + return + + raise AssertionError + # do other things that aren't raises or assertions: + x = 1 +``` + +An assertion that always succeeds does not establish exhaustiveness, whether it appears in the +conditional body, an `else` block, or immediately after the statement: + +```py +def successful_assertion_in_body(value: int): + if value is None: # TODO: should error + assert True + +def successful_assertion_in_else(value: int): + if value is not None: # TODO: should error + pass + else: + assert True + +def successful_assertion_after_if(value: int): + if value is not None: # TODO: should error + pass + assert True +``` + +A nested conditional is only a defensive exit if its initial `if` body and every `elif` and `else` +body end in defensive exits. A body that falls through does not establish exhaustiveness. + +```py +def nested_fallthrough(value: int, flag: bool): + if value is None: # TODO: should error + if flag: + print(value) + else: + raise AssertionError + +def nested_without_else(value: int, flag: bool): + if value is None: # TODO: should error + if flag: + raise AssertionError +``` + +The first condition's type does not affect whether a later boolean condition is recognized as a +defensive check. Non-boolean conditions still produce the ordinary diagnostic, even when followed by +a defensive exit and the strict rule is enabled. + +```py +def defensive_elif(items: list[int], value: int): + if items: + pass + elif value is None: + raise AssertionError + +def predicate() -> bool: + return False + +def uncalled_function(flag: bool): + if flag: + pass + elif predicate: # error: [redundant-condition] "This condition is always true" + pass + else: + raise AssertionError +``` + +## Defensive operands in ambiguous conditions + +Type annotations are not enforced at runtime, and not all users run type checkers on their code. +Defensive runtime type checks are therefore common in well-written Python code. + +In these examples, a caller could pass `None` despite the `int` annotation. We report no diagnostic +on the redundant condition because it can help reject that input: `value is not None` would be false +and lead to the `else` branch, while `value is None` would be true and enter the raising body: + +```py +def defensive_else(value: int, enabled: bool): + # no diagnostic: `value is not None` is always true, but the `else` branch + # contains a defensive exit. + if enabled and value is not None: + print(value) + else: + raise TypeError + +def defensive_body(value: int, enabled: bool): + # no diagnostic: `value is None` is always false, but the `if` branch + # contains a defensive exit. + if enabled or value is None: + raise TypeError +``` + +Negation reverses which branch an operand's truthiness contributes to. Defensive exits following an +early return also exempt the condition from being reported by either rule: + +```py +def negated_defensive_body(value: int, enabled: bool): + if not (enabled and value is not None): # no diagnostic + raise TypeError + +def defensive_fallthrough(value: int, enabled: bool): + if enabled and value is not None: # no diagnostic + return value + raise TypeError +``` + +A defensive exit does not exempt an operand whose opposite truthiness would contribute to taking the +other branch. For example, a false result for `value is not None` below would skip the `raise` +rather than reach it: + +```py +def nondefensive_operand(value: int, enabled: bool): + if enabled and value is not None: # TODO: should flag `value is not None` + raise TypeError + +def negated_nondefensive_operand(value: int, enabled: bool): + if not (enabled or value is None): # TODO: should flag `value is None` + raise TypeError +``` + +Tests inside call arguments are independent of the enclosing condition's branches, so they do not +inherit its defensive-exit exemption: + +```py +def accepts(value: bool) -> bool: + return value + +def independent_test(value: int): + if accepts(not (value is None)): # TODO: should flag `value is None` + raise TypeError +``` + +## Implicit `else` branches + +When an `if` body exits and the `if` statement has no explicit `else` branch, the following +statements act as an implicit `else`. Defensive checks in these implicit `else` branches are +recognised in the same way as defensive checks in explicit `else` branches. Ordinary fallthrough, +however, does not establish an implicit `else`. + +For example, an unrelated assertion after an `if` does not suppress a redundant-condition diagnostic +when the `if` body ends in an ordinary call: + +```py +def fallthrough(value: int, limit: int): + if value is not None: # TODO: should flag `value is not None` + print(value) + assert limit > 0 +``` + +The same applies to a final `elif` whose body falls through: + +```py +def fallthrough_elif(value: int | str): + if isinstance(value, int): + return + elif isinstance(value, str): # TODO: should flag `isinstance(value, str)` + print(value) + raise TypeError +``` + +We recognize an implicit `else` when the preceding `if` or `elif` branch ends in a `return`, a +`raise`, a call returning `Never`, or a potentially failing assertion. A nested `if` must have an +explicit `else`, and every branch must end in one of these statements. These exits can be mixed +within the nested conditional: + +```py +from typing import Never + +def stop() -> Never: + raise RuntimeError + +def nested_exits(value: int, choice: int, valid: bool): + if value is not None: + if choice == 0: + return value + elif choice == 1: + raise ValueError + elif choice == 2: + stop() + elif choice == 3: + assert False + else: + assert valid + raise TypeError +``` + +Potentially failing assertions count as exits even when they might succeed, because this heuristic +prioritises minimising false positives over catching every possible error. An assertion that always +succeeds does not count as an exit: + +```py +def successful_assertion(value: int): + if value is not None: # TODO: should flag `value is not None` + assert True + raise TypeError +``` + +A nested conditional that has a branch that falls through, or lacks an explicit `else`, does not +establish an implicit `else` after the outer `if`: + +```py +def nested_fallthrough(value: int, flag: bool): + if value is not None: # TODO: should flag `value is not None` + if flag: + return value + else: + print(value) + raise TypeError + +def nested_without_else(value: int, flag: bool): + if value is not None: # TODO: should flag `value is not None` + if flag: + return value + raise TypeError +``` + +An ordinary return in the implicit `else` is not a defensive exit, so it does not establish +exhaustiveness: + +```py +def ordinary_return(value: int): + if value is not None: # TODO: should flag `value is not None` + return value + return 0 +``` + +## Dunder methods that return `NotImplemented` + +In dunder methods, it is usually more idiomatic to `return NotImplemented` rather than `raise` if +you're writing code with defensive runtime checks. We support this pattern too: + +```py +class Foo: + def __add__(self, other: "Foo") -> "Foo": + # no diagnostic, even though this is inferred as always `True`! + if not isinstance(other, Foo): + return NotImplemented + return self +``` + +## Tests that include walrus expressions + +Walrus expressions always have side effects, so an always-true walrus expression may not always be +redundant. Examples of this can be found in CPython's scripts, where deliberately true walrus +expressions are used to continue the boolean-expression chain: + +- + +It is arguably always possible to write this kind of code in a clearer, more obvious way, so we +still emit a diagnostic on code like this, even though it may be deliberate. However, we use the +`redundant-condition-strict` rule for these patterns, so that the rule that is enabled by default is +unopinionated: + +```py +def coinflip1() -> bool: + return True + +def coinflip2() -> bool: + return True + +foo = ("foo",) + +# the always-truthy item is a `tuple[Literal["bar"]]`, +# so this would normally trigger `redundant-condition`, +# but the presence of the walrus expression means we use +# the disabled-by-default error code. +if coinflip1() and (foo := ("bar",)) and coinflip2(): # TODO: should error + ... +``` + +Walruses in lambda defaults or eager comprehensions can run while the condition is evaluated. These +conditions also use the strict rule. + +```py +def eager_walruses(items: list[int]): + if ((lambda value=(saved := 1): value),): # TODO: should error + pass + if ([saved := item for item in items],): # TODO: should error + pass + if ({saved := item for item in items},): # TODO: should error + pass + if ({item: (saved := item) for item in items},): # TODO: should error + pass +``` + +## Walrus expressions in called lambdas and consumed generators + +Calling a lambda or consuming a generator can evaluate a walrus in its body. The nonempty tuples +returned here are always truthy, but the assignments run when evaluating the conditions. These +conditions therefore use only the strict rule. + +```py +if (lambda: (value := (1,)))(): # TODO: should error + pass +if next((value := (1,)) for _ in range(1)): # TODO: should error + pass +if next((1,) for item in range(3) if (value := item > 0)): # TODO: should error + pass +``` diff --git a/crates/ty_python_semantic/resources/mdtest/regression/2799_constraint_correlation.md b/crates/ty_python_semantic/resources/mdtest/regression/2799_constraint_correlation.md index f6aec51e4e..986be2ae06 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/2799_constraint_correlation.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/2799_constraint_correlation.md @@ -17,7 +17,7 @@ python-version = "3.13" from typing import Generic, Protocol, TypeVar, overload T = TypeVar("T") -T_contra = TypeVar("T_contra") +T_contra = TypeVar("T_contra", contravariant=True) S2 = TypeVar("S2") class ElementOpsMixin(Generic[S2]): diff --git a/crates/ty_python_semantic/resources/mdtest/regression/3812_cyclic_generic_alias_base.md b/crates/ty_python_semantic/resources/mdtest/regression/3812_cyclic_generic_alias_base.md index 60a3f50eff..73886ad86d 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/3812_cyclic_generic_alias_base.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/3812_cyclic_generic_alias_base.md @@ -17,7 +17,7 @@ from typing_extensions import TypeVar if TYPE_CHECKING: from .message import UserMessage -T = TypeVar("T") +T = TypeVar("T", covariant=True) class Messageable(Protocol[T]): ... class WrapsUser(Messageable["UserMessage"]): ... diff --git a/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md b/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md index 1ae6e2a0ef..fe82314e2d 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md @@ -145,15 +145,15 @@ def negated_alternative[T, U]() -> None: reveal_type(constraints.solutions(inferable=tuple[T, U])) ``` -## Derived solution element order +## Independent concrete solutions are stable -Constructing the constraints in the opposite source order makes the derived union observable. Its -elements should not be reordered merely because the TDD-variable order changes. +Independent type variables with concrete bounds should not acquire relationships merely because +their bounds contain the same concrete type. ```py from ty_extensions._internal import ConstraintSet -def derived_solution[U, T]() -> None: +def independent_solution[U, T]() -> None: # (U ≤ int) ∧ (int ≤ T) ∧ ((T ≤ int) | (T ≤ str)) constraints = ( ConstraintSet.upper_bound(U, int) @@ -161,15 +161,10 @@ def derived_solution[U, T]() -> None: & (ConstraintSet.upper_bound(T, int) | ConstraintSet.upper_bound(T, str)) ) - # TODO: The derived relationship should not leave an inferable `U` in the solution for `T`. - # TODO: revealed: tuple[Solution[T=int]] - # TODO: sometimes: revealed tuple[Solution[T=int | U@derived_solution]] - # revealed: tuple[Solution[T=U@derived_solution | int]] + # revealed: tuple[Solution[T=int]] reveal_type(constraints.solutions_for(T, inferable=tuple[T, U])) - # TODO: The derived relationship should not leave an inferable `T` in the solution for `U`. - # TODO: revealed: tuple[Solution[U=int]] - # revealed: tuple[Solution[U=int & T@derived_solution]] + # revealed: tuple[Solution[U=int]] reveal_type(constraints.solutions_for(U, inferable=tuple[T, U])) ``` @@ -308,6 +303,29 @@ reveal_type(infer_from_callbacks(accepts_p, accepts_q)) reveal_type(infer_from_callbacks(accepts_q, accepts_p)) ``` +## Generic callback inference through a type alias + +Relating a generic function to a generic callback consistently infers the same union, but the +union's displayed element order currently depends on the constraint ordering. + +```py +from collections.abc import Callable + +type Items = tuple[int] | tuple[str] + +def identity[T](value: T) -> T: + return value + +def extract[T](callback: Callable[[Items], tuple[T]]) -> T: + raise NotImplementedError + +result = extract(identity) + +# TODO: sometimes: revealed int | str +# revealed: str | int +reveal_type(result) +``` + ## Generic-callable and protocol relation constraints Relations can introduce fresh typevars and nested invariant constraints before those typevars are @@ -344,8 +362,8 @@ def get_value(value: GetValue[ConstrainedValue]) -> ConstrainedValue: raise NotImplementedError def typed_dict_union(value: ValueA | ValueB) -> None: - # TODO: sometimes: revealed object - # revealed: int + # TODO: revealed int + # revealed: object reveal_type(get_value(value)) ``` @@ -375,7 +393,7 @@ class Concrete[T]: return "" def convert[T](value: Concrete[T]) -> Array: - return cast(Array, value) + return cast(Array, value) # error: [disjoint-cast] # error: [invalid-assignment] invalid: Array = Concrete[int]() @@ -389,7 +407,6 @@ truncated diagnostic display must not depend on which implications were encounte ```py from typing import Literal -from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet def high_fanout[ @@ -499,6 +516,7 @@ def high_fanout[ reveal_type(result) impossible = constraints & ConstraintSet.upper_bound(R11, Literal[0]) - # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" - static_assert(not impossible.satisfied_by_all_typevars(inferable=inferable)) + # TODO: sometimes: revealed tuple[Solution[R11=P@high_fanout]] + # revealed: None + reveal_type(impossible.solutions_for(R11, inferable=inferable)) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md index fc4f2052c3..b8823b57c3 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md @@ -55,7 +55,7 @@ class Concrete[T]: return "" def convert[T](value: Concrete[T]) -> Array: - return cast(Array, value) + return cast(Array, value) # error: [disjoint-cast] invalid: Array = Concrete[int]() # error: [invalid-assignment] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/regression/paramspec_on_python39.md b/crates/ty_python_semantic/resources/mdtest/regression/paramspec_on_python39.md index f0dd802adb..b147118f06 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/paramspec_on_python39.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/paramspec_on_python39.md @@ -12,7 +12,7 @@ diagnostic message for `invalid-exception-caught` expects to construct `typing.P # error: [invalid-syntax] def foo[**P]() -> None: try: - pass + raise Exception # error: [invalid-exception-caught] "Invalid object caught in an exception handler: Object has type `ParamSpec`" except P: pass diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/global.md b/crates/ty_python_semantic/resources/mdtest/scopes/global.md index 8772480298..3e724cad2c 100644 --- a/crates/ty_python_semantic/resources/mdtest/scopes/global.md +++ b/crates/ty_python_semantic/resources/mdtest/scopes/global.md @@ -299,6 +299,53 @@ def factory(): reveal_type(x) # revealed: Literal[1] ``` +An explicit module-level binding remains visible when the enclosing function only conditionally +rebinds that global: + +```py +value = 0 + +def conditional_global_factory(flag: bool): + global value + if flag: + value = "updated" + + class Nested: + reveal_type(value) # revealed: Literal["updated", 0] +``` + +If the condition is known to be false, the nested class should see only the original module-level +binding and should not report an unresolved reference: + +```py +from typing import Literal + +known_false_value = 0 + +def known_false_global_factory(flag: Literal[False]): + global known_false_value + if flag: # error: [redundant-condition] "This condition is always false" + known_false_value = "updated" + + class Nested: + reveal_type(known_false_value) # revealed: Literal[0] +``` + +A module-level declaration also remains visible when the enclosing function only conditionally binds +that global: + +```py +declared_value: int + +def conditional_declared_global_factory(flag: bool): + global declared_value + if flag: + declared_value = 1 + + class Nested: + reveal_type(declared_value) # revealed: int +``` + If the rebinding is conditional, an unbound enclosing snapshot continues to the implicit global: ```py @@ -323,6 +370,22 @@ def conditional_builtin_factory(flag: bool): reveal_type(len) # revealed: Literal[1] | (def len(obj: Sized, /) -> int) ``` +## Comprehension after global rebinding + +A comprehension is also an eager nested scope, so it should see both the original module-level +binding and a conditional global rebinding: + +```py +value = 0 + +def factory(flag: bool): + global value + if flag: + value = "updated" + + [reveal_type(value) for _ in [0]] # revealed: Literal["updated", 0] +``` + ## References to variables before they are defined within a class scope are considered global If we try to access a variable in a class before it has been defined, the lookup will fall back to diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md b/crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md index 3b2ea8bd13..7171c357dd 100644 --- a/crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md +++ b/crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md @@ -774,6 +774,18 @@ def f(flag: bool): flag and (x := 38), flag and (x := 36), flag and (x := 36), + flag and (x := 39), + flag and (x := 40), + flag and (x := 41), + flag and (x := 42), + flag and (x := 43), + flag and (x := 44), + flag and (x := 45), + flag and (x := 46), + flag and (x := 47), + flag and (x := 48), + flag and (x := 49), + flag and (x := 50), ) # Normally this `nonlocal` write would make us infer `int` for `y`, but now we ignore it. diff --git a/crates/ty_python_semantic/resources/mdtest/scripts.md b/crates/ty_python_semantic/resources/mdtest/scripts.md index abffa906eb..64c3ca9173 100644 --- a/crates/ty_python_semantic/resources/mdtest/scripts.md +++ b/crates/ty_python_semantic/resources/mdtest/scripts.md @@ -1,6 +1,6 @@ -Scripts with PEP 723 metadata are considered single-file projects. For now, they can configure -`rules` and `analysis`, but we plan to also support dependencies and changing `environment` -settings. +Scripts with PEP 723 metadata are considered single-file projects. They can configure `rules`, +`analysis`, and their Python environment independently of the enclosing project. Dependencies are +resolved from existing Python environments; ty does not install them. ```toml [environment] @@ -15,10 +15,10 @@ respect-type-ignore-comments = false # Inline settings -A script can change its `rules` and `analysis` settings. In the future, it can also change its -`environment` settings. A script is standalone, it does not inherit any settings from the project -(that's not entirely true today, because scripts still inherit `environment` settings but it's our -end goal). +A script can change its `rules`, `analysis`, and `environment` settings. A script does not inherit +the enclosing project's configuration or Python environment, but can use an activated or explicitly +configured environment. First-party imports require explicitly configured source roots or extra +search paths. ```py # /// script diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Invalid_exception_ha\342\200\246_(d394c561bdd35078).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Invalid_exception_ha\342\200\246_(d394c561bdd35078).snap" index 2f433a830b..7c1a9c44e8 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Invalid_exception_ha\342\200\246_(d394c561bdd35078).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Invalid_exception_ha\342\200\246_(d394c561bdd35078).snap" @@ -14,13 +14,13 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/exception/basic.md ``` 1 | try: - 2 | pass + 2 | raise Exception 3 | # error: [invalid-exception-caught] 4 | except 3 as e: 5 | reveal_type(e) # revealed: Unknown 6 | 7 | try: - 8 | pass + 8 | raise Exception 9 | # error: [invalid-exception-caught] 10 | except (ValueError, OSError, "foo", b"bar") as e: 11 | reveal_type(e) # revealed: ValueError | OSError | Unknown diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Special-cased_diagno\342\200\246_(a97274530a7f61c1).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Special-cased_diagno\342\200\246_(a97274530a7f61c1).snap" index 3984c55bae..d1355a60c5 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Special-cased_diagno\342\200\246_(a97274530a7f61c1).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Exception_Handling_-_Special-cased_diagno\342\200\246_(a97274530a7f61c1).snap" @@ -34,6 +34,14 @@ error[invalid-raise]: Cannot raise `NotImplemented` 4 | raise NotImplemented from NotImplemented | ^^^^^^^^^^^^^^ Did you mean `NotImplementedError`? info: Can only raise an instance or subclass of `BaseException` +help: Use `NotImplementedError` instead + | +3 | # error: [invalid-raise] + - raise NotImplemented from NotImplemented +4 + raise NotImplementedError from NotImplemented +5 | # error: [invalid-exception-caught] + | +note: This is an unsafe fix and may change runtime behavior ``` @@ -43,7 +51,15 @@ error[invalid-raise]: Cannot use `NotImplemented` as an exception cause | 4 | raise NotImplemented from NotImplemented | ^^^^^^^^^^^^^^ Did you mean `NotImplementedError`? +help: Use `NotImplementedError` instead info: An exception cause must be an instance of `BaseException`, subclass of `BaseException`, or `None` + | +3 | # error: [invalid-raise] + - raise NotImplemented from NotImplemented +4 + raise NotImplemented from NotImplementedError +5 | # error: [invalid-exception-caught] + | +note: This is an unsafe fix and may change runtime behavior ``` @@ -53,7 +69,15 @@ error[invalid-exception-caught]: Cannot catch `NotImplemented` in an exception h | 6 | except NotImplemented: | ^^^^^^^^^^^^^^ Did you mean `NotImplementedError`? +help: Use `NotImplementedError` instead info: Can only catch a subclass of `BaseException` or tuple of `BaseException` subclasses + | +5 | # error: [invalid-exception-caught] + - except NotImplemented: +6 + except NotImplementedError: +7 | pass + | +note: This is an unsafe fix and may change runtime behavior ``` @@ -66,6 +90,14 @@ error[invalid-exception-caught]: Invalid tuple caught in an exception handler | | | Invalid element of type `NotImplementedType` | Did you mean `NotImplementedError`? +help: Use `NotImplementedError` instead info: Can only catch a subclass of `BaseException` or tuple of `BaseException` subclasses + | +8 | # error: [invalid-exception-caught] + - except (TypeError, NotImplemented): +9 + except (TypeError, NotImplementedError): +10 | pass + | +note: This is an unsafe fix and may change runtime behavior ``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Last_argument_must_b\342\200\246_(dc429fc3e8c18eaf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Last_argument_must_b\342\200\246_(dc429fc3e8c18eaf).snap" index 9b98036efb..b6c50d4c06 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Last_argument_must_b\342\200\246_(dc429fc3e8c18eaf).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Last_argument_must_b\342\200\246_(dc429fc3e8c18eaf).snap" @@ -22,19 +22,19 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/concaten 7 | def _(c: Callable[Concatenate[int, str], bool]): ... 8 | 9 | # error: [invalid-type-arguments] "The last argument to `typing.Concatenate` must be either `...` or a `ParamSpec` type variable: Got `str`" -10 | reveal_type(Foo[Concatenate[int, str]].attr) # revealed: (...) -> None +10 | reveal_type(Foo[Concatenate[int, str]]().attr) # revealed: (...) -> None 11 | 12 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -13 | reveal_type(Foo[Concatenate[int, Concatenate]].attr) # revealed: (...) -> None +13 | reveal_type(Foo[Concatenate[int, Concatenate]]().attr) # revealed: (...) -> None 14 | 15 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -16 | reveal_type(Foo[Concatenate[int, Concatenate[()]]].attr) # revealed: (...) -> None +16 | reveal_type(Foo[Concatenate[int, Concatenate[()]]]().attr) # revealed: (...) -> None 17 | 18 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -19 | reveal_type(Foo[Concatenate[int, Concatenate[int]]].attr) # revealed: (...) -> None +19 | reveal_type(Foo[Concatenate[int, Concatenate[int]]]().attr) # revealed: (...) -> None 20 | 21 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -22 | reveal_type(Foo[Concatenate[int, Concatenate[int, str]]].attr) # revealed: (...) -> None +22 | reveal_type(Foo[Concatenate[int, Concatenate[int, str]]]().attr) # revealed: (...) -> None ``` # Diagnostics @@ -52,7 +52,7 @@ error[invalid-type-arguments]: The last argument to `typing.Concatenate` must be error[invalid-type-arguments]: The last argument to `typing.Concatenate` must be either `...` or a `ParamSpec` type variable --> src/mdtest_snippet.py:10:34 | -10 | reveal_type(Foo[Concatenate[int, str]].attr) # revealed: (...) -> None +10 | reveal_type(Foo[Concatenate[int, str]]().attr) # revealed: (...) -> None | ^^^ Got `str` ``` @@ -61,7 +61,7 @@ error[invalid-type-arguments]: The last argument to `typing.Concatenate` must be error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:13:34 | -13 | reveal_type(Foo[Concatenate[int, Concatenate]].attr) # revealed: (...) -> None +13 | reveal_type(Foo[Concatenate[int, Concatenate]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -73,7 +73,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:16:34 | -16 | reveal_type(Foo[Concatenate[int, Concatenate[()]]].attr) # revealed: (...) -> None +16 | reveal_type(Foo[Concatenate[int, Concatenate[()]]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -85,7 +85,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:19:34 | -19 | reveal_type(Foo[Concatenate[int, Concatenate[int]]].attr) # revealed: (...) -> None +19 | reveal_type(Foo[Concatenate[int, Concatenate[int]]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -97,7 +97,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:22:34 | -22 | reveal_type(Foo[Concatenate[int, Concatenate[int, str]]].attr) # revealed: (...) -> None +22 | reveal_type(Foo[Concatenate[int, Concatenate[int, str]]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Too_few_arguments_(efcf77cdbde3ff86).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Too_few_arguments_(efcf77cdbde3ff86).snap" index b39470307c..000cf41437 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Too_few_arguments_(efcf77cdbde3ff86).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/concatenate.md_-_`typing.Concatenate`_-_Invalid_uses_of_`Con\342\200\246_-_Too_few_arguments_(efcf77cdbde3ff86).snap" @@ -33,24 +33,24 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/concaten 18 | reveal_type(c) # revealed: (...) -> int 19 | 20 | # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 0)" -21 | reveal_type(Foo[Concatenate[()]].attr) # revealed: (...) -> None +21 | reveal_type(Foo[Concatenate[()]]().attr) # revealed: (...) -> None 22 | # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1)" -23 | reveal_type(Foo[Concatenate[int]].attr) # revealed: (...) -> None +23 | reveal_type(Foo[Concatenate[int]]().attr) # revealed: (...) -> None 24 | # error: [invalid-type-form] "`typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1)" -25 | reveal_type(Foo[Concatenate[(int,)]].attr) # revealed: (...) -> None +25 | reveal_type(Foo[Concatenate[(int,)]]().attr) # revealed: (...) -> None 26 | # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" -27 | reveal_type(Foo[Concatenate].attr) # revealed: (...) -> None +27 | reveal_type(Foo[Concatenate]().attr) # revealed: (...) -> None 28 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -29 | reveal_type(Foo[[Concatenate]].attr) # revealed: (Unknown, /) -> None +29 | reveal_type(Foo[[Concatenate]]().attr) # revealed: (Unknown, /) -> None 30 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -31 | reveal_type(Foo[[Concatenate, int]].attr) # revealed: (Unknown, int, /) -> None +31 | reveal_type(Foo[[Concatenate, int]]().attr) # revealed: (Unknown, int, /) -> None 32 | 33 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -34 | reveal_type(Foo[[Concatenate[int], str]].attr) # revealed: (Unknown, str, /) -> None +34 | reveal_type(Foo[[Concatenate[int], str]]().attr) # revealed: (Unknown, str, /) -> None 35 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -36 | reveal_type(Foo[[Concatenate[int, str], str]].attr) # revealed: (Unknown, str, /) -> None +36 | reveal_type(Foo[[Concatenate[int, str], str]]().attr) # revealed: (Unknown, str, /) -> None 37 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -38 | reveal_type(Foo[[Concatenate[()], str]].attr) # revealed: (Unknown, str, /) -> None +38 | reveal_type(Foo[[Concatenate[()], str]]().attr) # revealed: (Unknown, str, /) -> None 39 | 40 | # Subscripting a class that does not have "exactly one paramspec" takes a different code path; 41 | # these tests exercise that code path @@ -60,10 +60,10 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/concaten 45 | 46 | # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" 47 | # error: [invalid-type-form] "`typing.Concatenate` requires at least two arguments when used in a type expression" -48 | reveal_type(Bar[Concatenate, Concatenate].a) # revealed: (...) -> int +48 | reveal_type(Bar[Concatenate, Concatenate]().a) # revealed: (...) -> int 49 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" 50 | # error: [invalid-type-form] "`typing.Concatenate` is not allowed in this context in a type expression" -51 | reveal_type(Bar[[Concatenate], [Concatenate]].a) # revealed: (Unknown, /) -> int +51 | reveal_type(Bar[[Concatenate], [Concatenate]]().a) # revealed: (Unknown, /) -> int ``` # Diagnostics @@ -108,7 +108,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least two arguments w error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments when used in a type expression (got 0) --> src/mdtest_snippet.py:21:17 | -21 | reveal_type(Foo[Concatenate[()]].attr) # revealed: (...) -> None +21 | reveal_type(Foo[Concatenate[()]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^ ``` @@ -117,7 +117,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments whe error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1) --> src/mdtest_snippet.py:23:17 | -23 | reveal_type(Foo[Concatenate[int]].attr) # revealed: (...) -> None +23 | reveal_type(Foo[Concatenate[int]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^^ ``` @@ -126,7 +126,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments whe error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments when used in a type expression (got 1) --> src/mdtest_snippet.py:25:17 | -25 | reveal_type(Foo[Concatenate[(int,)]].attr) # revealed: (...) -> None +25 | reveal_type(Foo[Concatenate[(int,)]]().attr) # revealed: (...) -> None | ^^^^^^^^^^^^^^^^^^^ ``` @@ -135,7 +135,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least 2 arguments whe error[invalid-type-form]: `typing.Concatenate` requires at least two arguments when used in a type expression --> src/mdtest_snippet.py:27:17 | -27 | reveal_type(Foo[Concatenate].attr) # revealed: (...) -> None +27 | reveal_type(Foo[Concatenate]().attr) # revealed: (...) -> None | ^^^^^^^^^^^ ``` @@ -144,7 +144,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least two arguments w error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:29:18 | -29 | reveal_type(Foo[[Concatenate]].attr) # revealed: (Unknown, /) -> None +29 | reveal_type(Foo[[Concatenate]]().attr) # revealed: (Unknown, /) -> None | ^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -156,7 +156,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:31:18 | -31 | reveal_type(Foo[[Concatenate, int]].attr) # revealed: (Unknown, int, /) -> None +31 | reveal_type(Foo[[Concatenate, int]]().attr) # revealed: (Unknown, int, /) -> None | ^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -168,7 +168,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:34:18 | -34 | reveal_type(Foo[[Concatenate[int], str]].attr) # revealed: (Unknown, str, /) -> None +34 | reveal_type(Foo[[Concatenate[int], str]]().attr) # revealed: (Unknown, str, /) -> None | ^^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -180,7 +180,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:36:18 | -36 | reveal_type(Foo[[Concatenate[int, str], str]].attr) # revealed: (Unknown, str, /) -> None +36 | reveal_type(Foo[[Concatenate[int, str], str]]().attr) # revealed: (Unknown, str, /) -> None | ^^^^^^^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -192,7 +192,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:38:18 | -38 | reveal_type(Foo[[Concatenate[()], str]].attr) # revealed: (Unknown, str, /) -> None +38 | reveal_type(Foo[[Concatenate[()], str]]().attr) # revealed: (Unknown, str, /) -> None | ^^^^^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -204,7 +204,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` requires at least two arguments when used in a type expression --> src/mdtest_snippet.py:48:17 | -48 | reveal_type(Bar[Concatenate, Concatenate].a) # revealed: (...) -> int +48 | reveal_type(Bar[Concatenate, Concatenate]().a) # revealed: (...) -> int | ^^^^^^^^^^^ ``` @@ -213,7 +213,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least two arguments w error[invalid-type-form]: `typing.Concatenate` requires at least two arguments when used in a type expression --> src/mdtest_snippet.py:48:30 | -48 | reveal_type(Bar[Concatenate, Concatenate].a) # revealed: (...) -> int +48 | reveal_type(Bar[Concatenate, Concatenate]().a) # revealed: (...) -> int | ^^^^^^^^^^^ ``` @@ -222,7 +222,7 @@ error[invalid-type-form]: `typing.Concatenate` requires at least two arguments w error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:51:18 | -51 | reveal_type(Bar[[Concatenate], [Concatenate]].a) # revealed: (Unknown, /) -> int +51 | reveal_type(Bar[[Concatenate], [Concatenate]]().a) # revealed: (Unknown, /) -> int | ^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` @@ -234,7 +234,7 @@ info: - as a type argument for a `ParamSpec` parameter error[invalid-type-form]: `typing.Concatenate` is not allowed in this context in a type expression --> src/mdtest_snippet.py:51:33 | -51 | reveal_type(Bar[[Concatenate], [Concatenate]].a) # revealed: (Unknown, /) -> int +51 | reveal_type(Bar[[Concatenate], [Concatenate]]().a) # revealed: (Unknown, /) -> int | ^^^^^^^^^^^ info: `typing.Concatenate` is only valid: info: - as the first argument to `Callable` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Dict-literal_or_set-\342\200\246_(15737b0beb194b0e).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Dict-literal_or_set-\342\200\246_(15737b0beb194b0e).snap" deleted file mode 100644 index b0bbb1b6f8..0000000000 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Dict-literal_or_set-\342\200\246_(15737b0beb194b0e).snap" +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: crates/mdtest/src/lib.rs -expression: snapshot ---- - ---- -mdtest name: invalid.md - Tests for invalid types in type expressions - Diagnostics for common errors - Dict-literal or set-literal when you meant to use `dict[]`/`set[]` -mdtest path: crates/ty_python_semantic/resources/mdtest/annotations/invalid.md ---- - -# Python source files - -## mdtest_snippet.py - -``` -1 | def _( -2 | x: {int: str}, # error: [invalid-type-form] -3 | y: {str}, # error: [invalid-type-form] -4 | ): ... -``` - -# Diagnostics - -``` -error[invalid-type-form]: Dict literals are not allowed in parameter annotations - --> src/mdtest_snippet.py:2:8 - | -2 | x: {int: str}, # error: [invalid-type-form] - | ^^^^^^^^^^ Did you mean `dict[int, str]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Set literals are not allowed in parameter annotations - --> src/mdtest_snippet.py:3:8 - | -3 | y: {str}, # error: [invalid-type-form] - | ^^^^^ Did you mean `set[str]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_List-literal_used_wh\342\200\246_(ba5cb09eaa3715d8).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_List-literal_used_wh\342\200\246_(ba5cb09eaa3715d8).snap" deleted file mode 100644 index db6a0ecbd5..0000000000 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_List-literal_used_wh\342\200\246_(ba5cb09eaa3715d8).snap" +++ /dev/null @@ -1,72 +0,0 @@ ---- -source: crates/mdtest/src/lib.rs -expression: snapshot ---- - ---- -mdtest name: invalid.md - Tests for invalid types in type expressions - Diagnostics for common errors - List-literal used when you meant to use a list -mdtest path: crates/ty_python_semantic/resources/mdtest/annotations/invalid.md ---- - -# Python source files - -## mdtest_snippet.py - -``` - 1 | def _( - 2 | x: [int], # error: [invalid-type-form] - 3 | ) -> [int]: # error: [invalid-type-form] - 4 | return x - 5 | - 6 | # No special hints for these: it's unclear what the user meant: - 7 | def _( - 8 | x: [int, str], # error: [invalid-type-form] - 9 | ) -> [int, str]: # error: [invalid-type-form] -10 | return x -``` - -# Diagnostics - -``` -error[invalid-type-form]: List literals are not allowed in this context in a parameter annotation - --> src/mdtest_snippet.py:2:8 - | -2 | x: [int], # error: [invalid-type-form] - | ^^^^^ Did you mean `list[int]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: List literals are not allowed in this context in a return type annotation - --> src/mdtest_snippet.py:3:6 - | -3 | ) -> [int]: # error: [invalid-type-form] - | ^^^^^ Did you mean `list[int]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: List literals are not allowed in this context in a parameter annotation - --> src/mdtest_snippet.py:8:8 - | -8 | x: [int, str], # error: [invalid-type-form] - | ^^^^^^^^^^ -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: List literals are not allowed in this context in a return type annotation - --> src/mdtest_snippet.py:9:6 - | -9 | ) -> [int, str]: # error: [invalid-type-form] - | ^^^^^^^^^^ -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Tuple-literal_used_w\342\200\246_(f61204fc81905069).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Tuple-literal_used_w\342\200\246_(f61204fc81905069).snap" deleted file mode 100644 index 68d261cc83..0000000000 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid.md_-_Tests_for_invalid_ty\342\200\246_-_Diagnostics_for_comm\342\200\246_-_Tuple-literal_used_w\342\200\246_(f61204fc81905069).snap" +++ /dev/null @@ -1,96 +0,0 @@ ---- -source: crates/mdtest/src/lib.rs -expression: snapshot ---- - ---- -mdtest name: invalid.md - Tests for invalid types in type expressions - Diagnostics for common errors - Tuple-literal used when you meant to use a tuple -mdtest path: crates/ty_python_semantic/resources/mdtest/annotations/invalid.md ---- - -# Python source files - -## mdtest_snippet.py - -``` - 1 | def _( - 2 | x: (), # error: [invalid-type-form] - 3 | ) -> (): # error: [invalid-type-form] - 4 | return x - 5 | def _( - 6 | x: (int,), # error: [invalid-type-form] - 7 | ) -> (int,): # error: [invalid-type-form] - 8 | return x - 9 | def _( -10 | x: (int, str), # error: [invalid-type-form] -11 | ) -> (int, str): # error: [invalid-type-form] -12 | return x -``` - -# Diagnostics - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation - --> src/mdtest_snippet.py:2:8 - | -2 | x: (), # error: [invalid-type-form] - | ^^ Did you mean `tuple[()]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation - --> src/mdtest_snippet.py:3:6 - | -3 | ) -> (): # error: [invalid-type-form] - | ^^ Did you mean `tuple[()]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation - --> src/mdtest_snippet.py:6:8 - | -6 | x: (int,), # error: [invalid-type-form] - | ^^^^^^ Did you mean `tuple[int]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation - --> src/mdtest_snippet.py:7:6 - | -7 | ) -> (int,): # error: [invalid-type-form] - | ^^^^^^ Did you mean `tuple[int]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a parameter annotation - --> src/mdtest_snippet.py:10:8 - | -10 | x: (int, str), # error: [invalid-type-form] - | ^^^^^^^^^^ Did you mean `tuple[int, str]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` - -``` -error[invalid-type-form]: Tuple literals are not allowed in this context in a return type annotation - --> src/mdtest_snippet.py:11:6 - | -11 | ) -> (int, str): # error: [invalid-type-form] - | ^^^^^^^^^^ Did you mean `tuple[int, str]`? -info: See the following page for a reference on valid type expressions: -info: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions - -``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or\342\200\246_-_Unresolvable_MROs_in\342\200\246_(e2b355c09a967862).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or\342\200\246_-_Unresolvable_MROs_in\342\200\246_(e2b355c09a967862).snap" index 353c2c160a..ebe06f5abe 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or\342\200\246_-_Unresolvable_MROs_in\342\200\246_(e2b355c09a967862).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or\342\200\246_-_Unresolvable_MROs_in\342\200\246_(e2b355c09a967862).snap" @@ -15,7 +15,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md ``` 1 | from typing_extensions import Protocol, TypeVar, Generic 2 | -3 | T = TypeVar("T") +3 | T = TypeVar("T", covariant=True) 4 | 5 | class Foo(Protocol): ... 6 | class Bar(Protocol[T]): ... diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" index 6d5aa76712..e07962d4a6 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Inconsistent_decorat\342\200\246_-_`@classmethod`_(aaa04d4cfa3adaba).snap" @@ -69,26 +69,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/overloads.md 54 | if isinstance(x, int): 55 | return cls(x) 56 | return None -57 | -58 | class Base: -59 | @overload -60 | @classmethod -61 | def from_value(cls: type[Base], x: int) -> int: ... -62 | @overload -63 | @classmethod -64 | def from_value(cls: type[Child], x: str) -> str: ... -65 | @classmethod -66 | def from_value(cls, x: int | str) -> int | str: -67 | return x -68 | -69 | class Child(Base): ... -70 | -71 | reveal_type(Base.from_value) # revealed: bound method .from_value(x: int) -> int -72 | reveal_type(Child.from_value) # revealed: Overload[(x: int) -> int, (x: str) -> str] -73 | -74 | good: Callable[[int], int] = Base.from_value -75 | # error: [invalid-assignment] -76 | bad: Callable[[str], str] = Base.from_value +57 | instance = CheckClassMethod(1) +58 | reveal_type(instance.try_from1("a")) # revealed: None +59 | reveal_type(instance.try_from2(1)) # revealed: CheckClassMethod +60 | reveal_type(CheckClassMethod.try_from3(CheckClassMethod, 1)) # revealed: CheckClassMethod +61 | reveal_type(CheckClassMethod.try_from1(instance, "a")) # revealed: None +62 | CheckClassMethod.try_from1(1) # error: [no-matching-overload] +63 | +64 | reveal_type(CheckClassMethod.try_from4(1)) # revealed: CheckClassMethod +65 | class Base: +66 | @overload +67 | @classmethod +68 | def from_value(cls: type[Base], x: int) -> int: ... +69 | @overload +70 | @classmethod +71 | def from_value(cls: type[Child], x: str) -> str: ... +72 | @classmethod +73 | def from_value(cls, x: int | str) -> int | str: +74 | return x +75 | +76 | class Child(Base): ... +77 | +78 | reveal_type(Base.from_value) # revealed: bound method .from_value(x: int) -> int +79 | reveal_type(Child.from_value) # revealed: Overload[(x: int) -> int, (x: str) -> str] +80 | +81 | good: Callable[[int], int] = Base.from_value +82 | # error: [invalid-assignment] +83 | bad: Callable[[str], str] = Base.from_value ``` # Diagnostics @@ -144,11 +151,35 @@ error[call-non-callable]: Object of type `CheckClassMethod` is not callable ``` +``` +error[no-matching-overload]: No overload of function `CheckClassMethod.try_from1` matches arguments + --> src/mdtest_snippet.py:62:1 + | +62 | CheckClassMethod.try_from1(1) # error: [no-matching-overload] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +info: First overload defined here + --> src/mdtest_snippet.py:9:5 + | + 9 | / @overload +10 | | @classmethod +11 | | def try_from1(cls, x: int) -> CheckClassMethod: ... + | |_______________________________________________________^ First overload defined here +info: Possible overloads for function `try_from1`: +info: (cls, x: int) -> CheckClassMethod +info: (cls, x: str) -> None +info: Overload implementation defined here + --> src/mdtest_snippet.py:16:9 + | +16 | def try_from1(cls, x: int | str) -> CheckClassMethod | None: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``` + ``` error[invalid-assignment]: Object of type `bound method .from_value(x: int) -> int` is not assignable to `(str, /) -> str` - --> src/mdtest_snippet.py:76:29 + --> src/mdtest_snippet.py:83:29 | -76 | bad: Callable[[str], str] = Base.from_value +83 | bad: Callable[[str], str] = Base.from_value | -------------------- ^^^^^^^^^^^^^^^ Incompatible value of type `bound method .from_value(x: int) -> int` | | | Declared type diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f\342\200\246_-_Try_to_cover_all_pos\342\200\246_-_Attribute_access_on_\342\200\246_(7bdb97302c27c412).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f\342\200\246_-_Try_to_cover_all_pos\342\200\246_-_Attribute_access_on_\342\200\246_(7bdb97302c27c412).snap" index 7d4a0a1fe8..616b17adcf 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f\342\200\246_-_Try_to_cover_all_pos\342\200\246_-_Attribute_access_on_\342\200\246_(7bdb97302c27c412).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f\342\200\246_-_Try_to_cover_all_pos\342\200\246_-_Attribute_access_on_\342\200\246_(7bdb97302c27c412).snap" @@ -27,47 +27,23 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.m 12 | 13 | def _(x: T, y: int) -> T: 14 | # error: [invalid-argument-type] -15 | # error: [invalid-argument-type] -16 | # error: [invalid-argument-type] -17 | return x.foo(y) +15 | return x.foo(y) ``` # Diagnostics -``` -error[invalid-argument-type]: Argument to bound method `A.foo` is incorrect - --> src/mdtest_snippet.py:17:12 - | -17 | return x.foo(y) - | ^^^^^^^^ Argument type `T@_` does not satisfy upper bound `A` of type variable `Self` -info: Union variant `bound method T@_.foo(x: int) -> T@_` is incompatible with this call site -info: Attempted to call union type `(bound method T@_.foo(x: int) -> T@_) | (bound method T@_.foo(x: str) -> T@_)` - -``` - -``` -error[invalid-argument-type]: Argument to bound method `B.foo` is incorrect - --> src/mdtest_snippet.py:17:12 - | -17 | return x.foo(y) - | ^^^^^^^^ Argument type `T@_` does not satisfy upper bound `B` of type variable `Self` -info: Union variant `bound method T@_.foo(x: str) -> T@_` is incompatible with this call site -info: Attempted to call union type `(bound method T@_.foo(x: int) -> T@_) | (bound method T@_.foo(x: str) -> T@_)` - -``` - ``` error[invalid-argument-type]: Argument to bound method `B.foo` is incorrect - --> src/mdtest_snippet.py:17:18 + --> src/mdtest_snippet.py:15:18 | -17 | return x.foo(y) +15 | return x.foo(y) | ^ Expected `str`, found `int` info: Method defined here --> src/mdtest_snippet.py:8:9 | 8 | def foo(self, x: str) -> Self: | ^^^ ------ Parameter declared here -info: Union variant `bound method T@_.foo(x: str) -> T@_` is incompatible with this call site -info: Attempted to call union type `(bound method T@_.foo(x: int) -> T@_) | (bound method T@_.foo(x: str) -> T@_)` +info: Union variant `bound method T@_ when B.foo(x: str) -> T@_` is incompatible with this call site +info: Attempted to call union type `(bound method T@_ when A.foo(x: int) -> T@_) | (bound method T@_ when B.foo(x: str) -> T@_)` ``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_reference\342\200\246_-_Diagnostics_for_unre\342\200\246_-_Typing_builtin_has_I\342\200\246_-_Info_present_in_Pyth\342\200\246_(1028a80959504fc9).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_reference\342\200\246_-_Diagnostics_for_unre\342\200\246_-_Typing_builtin_has_I\342\200\246_-_Info_present_in_Pyth\342\200\246_(1028a80959504fc9).snap" deleted file mode 100644 index cc2b3758fd..0000000000 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_reference\342\200\246_-_Diagnostics_for_unre\342\200\246_-_Typing_builtin_has_I\342\200\246_-_Info_present_in_Pyth\342\200\246_(1028a80959504fc9).snap" +++ /dev/null @@ -1,38 +0,0 @@ ---- -source: crates/mdtest/src/lib.rs -expression: snapshot ---- - ---- -mdtest name: unresolved_reference.md - Diagnostics for unresolved references - Typing builtin has Info help - Info present in Python 3.9+ -mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/unresolved_reference.md ---- - -# Python source files - -## mdtest_snippet.py - -``` -1 | foo: List[int] # error: [unresolved-reference] -2 | bar: Type # error: [unresolved-reference] -``` - -# Diagnostics - -``` -error[unresolved-reference]: Name `List` used when not defined - --> src/mdtest_snippet.py:1:6 - | -1 | foo: List[int] # error: [unresolved-reference] - | ^^^^ Did you mean `list`? - -``` - -``` -error[unresolved-reference]: Name `Type` used when not defined - --> src/mdtest_snippet.py:2:6 - | -2 | bar: Type # error: [unresolved-reference] - | ^^^^ Did you mean `type`? - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/no_type_check.md b/crates/ty_python_semantic/resources/mdtest/suppressions/no_type_check.md index 2f52b2bc57..2e30bccf8d 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/no_type_check.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/no_type_check.md @@ -38,24 +38,40 @@ def test() -> int: return a + 5 ``` -## Error in preceding decorator +## Errors in decorator applications -Don't suppress diagnostics for decorators appearing before the `no_type_check` decorator. +We currently suppress all decorator-application errors on a function decorated with `no_type_check`, +regardless of whether those errors occur in applying decorators appearing before or after +`@no_type_check`. TODO: it would be more intuitive and consistent with our behavior for +decorator-expression errors (see below) if we only suppressed these for decorators located after +`@no_type_check` in source order. ```py from typing import no_type_check -@unknown_decorator # error: [unresolved-reference] +def takes_int(value: int) -> int: + return value + +# TODO this should be an error: +@takes_int @no_type_check -def test() -> int: - return a + 5 +def before() -> None: ... + +# no error, swallowed by `no_type_check`: +@no_type_check +@takes_int +def after() -> None: ... + +# error: [invalid-argument-type] +@takes_int +def checked() -> None: ... ``` -## Error in following decorator +## Error in following decorator expression -Unlike Pyright and mypy, suppress diagnostics appearing after the `no_type_check` decorator. We do -this because it more closely matches Python's runtime semantics of decorators. For more details, see -the discussion on the +Unlike Pyright and mypy, we also suppress diagnostics in decorator expressions appearing after the +`no_type_check` decorator. We do this because it more closely matches Python's runtime semantics of +decorators. For more details, see the discussion on the [PR adding `@no_type_check` support](https://github.com/astral-sh/ruff/pull/15122#discussion_r1896869411). ```py @@ -67,6 +83,20 @@ def test() -> int: return a + 5 ``` +## Error in preceding decorator expression + +We don't suppress diagnostics for decorator expressions appearing before the `no_type_check` +decorator. + +```py +from typing import no_type_check + +@unknown_decorator # error: [unresolved-reference] +@no_type_check +def test() -> int: + return a + 5 +``` + ## Error in default value ```py diff --git a/crates/ty_python_semantic/resources/mdtest/terminal_statements.md b/crates/ty_python_semantic/resources/mdtest/terminal_statements.md index 01b76b7397..e89d378e85 100644 --- a/crates/ty_python_semantic/resources/mdtest/terminal_statements.md +++ b/crates/ty_python_semantic/resources/mdtest/terminal_statements.md @@ -77,17 +77,18 @@ def return_in_both_branches(cond: bool): def return_in_try(cond: bool): x = "before" try: - if cond: + if cond is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" x = "test" return except: - # TODO: Literal["before"] - reveal_type(x) # revealed: Literal["before", "test"] + reveal_type(x) # revealed: Never else: reveal_type(x) # revealed: Literal["before"] finally: - reveal_type(x) # revealed: Literal["before", "test"] - reveal_type(x) # revealed: Literal["before", "test"] + # TODO: should include `Literal["test"]` when the return passes through `finally` + # https://github.com/astral-sh/ty/issues/233 + reveal_type(x) # revealed: Literal["before"] + reveal_type(x) # revealed: Literal["before"] def return_in_nested_then_branch(cond1: bool, cond2: bool): if cond1: @@ -361,186 +362,85 @@ def break_in_both_nested_branches(cond1: bool, cond2: bool, i: int): ## `raise` -A `raise` statement is terminal. If it occurs in a lexically containing `try` statement, it will -jump to one of the `except` clauses (if it matches the value being raised), or to the `else` clause -(if none match). Currently, we assume definitions from before the `raise` are visible in all -`except` and `else` clauses. (In the future, we might analyze the `except` clauses to see which ones -match the value being raised, and limit visibility to those clauses.) Definitions from before the -`raise` are not visible in any `else` clause, but are visible in `except` clauses or after the -containing `try` statement (since control flow may have passed through an `except`). +A `raise` statement is terminal. Inside a `try` statement, it jumps to a matching `except` clause or +propagates out of the statement. We do not yet determine which typed handler matches the exception, +so every handler sees the same possible values. -Currently we assume that an exception could be raised anywhere within a `try` block. We may want to -implement a more precise understanding of where exceptions (barring `KeyboardInterrupt` and -`MemoryError`) can and cannot actually be raised. +When only one branch raises, the exception handler sees only the value assigned in that branch: ```py def raise_in_then_branch(cond: bool): x = "before" try: - if cond: + if cond is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" x = "raise" - reveal_type(x) # revealed: Literal["raise"] raise ValueError - else: - x = "else" - reveal_type(x) # revealed: Literal["else"] - reveal_type(x) # revealed: Literal["else"] + x = "else" except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise", "else"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise", "else"] + reveal_type(x) # revealed: Literal["raise"] else: reveal_type(x) # revealed: Literal["else"] - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise", "else"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise", "else"] + reveal_type(x) # revealed: Literal["raise", "else"] +``` -def raise_in_else_branch(cond: bool): - x = "before" - try: - if cond: - x = "else" - reveal_type(x) # revealed: Literal["else"] - else: - x = "raise" - reveal_type(x) # revealed: Literal["raise"] - raise ValueError - reveal_type(x) # revealed: Literal["else"] - except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise"] - else: - reveal_type(x) # revealed: Literal["else"] - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise"] +If both branches raise, the handler sees either value and the `else` clause cannot run: +```py def raise_in_both_branches(cond: bool): x = "before" try: - if cond: + if cond is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" x = "raise1" - reveal_type(x) # revealed: Literal["raise1"] raise ValueError else: x = "raise2" - reveal_type(x) # revealed: Literal["raise2"] raise ValueError except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise1", "raise2"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise1", "raise2"] + reveal_type(x) # revealed: Literal["raise1", "raise2"] else: - # This branch is unreachable, since all control flows in the `try` clause raise exceptions. - # As a result, this binding should never be reachable, since new bindings are visible only - # when they are reachable. x = "unreachable" - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise1", "raise2"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "raise1", "raise2"] + reveal_type(x) # revealed: Literal["raise1", "raise2"] +``` -def raise_in_nested_then_branch(cond1: bool, cond2: bool): - x = "before" - try: - if cond1: - x = "else1" - reveal_type(x) # revealed: Literal["else1"] - else: - if cond2: - x = "raise" - reveal_type(x) # revealed: Literal["raise"] - raise ValueError - else: - x = "else2" - reveal_type(x) # revealed: Literal["else2"] - reveal_type(x) # revealed: Literal["else2"] - reveal_type(x) # revealed: Literal["else1", "else2"] - except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "raise", "else2"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "raise", "else2"] - else: - reveal_type(x) # revealed: Literal["else1", "else2"] - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "raise", "else2"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "raise", "else2"] +Nested conditions do not make values from non-raising branches visible to the exception handler: -def raise_in_nested_else_branch(cond1: bool, cond2: bool): +```py +def raise_in_nested_branch(cond1: bool, cond2: bool): x = "before" try: - if cond1: + if cond1 is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" x = "else1" - reveal_type(x) # revealed: Literal["else1"] + elif cond2 is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" + x = "raise" + raise ValueError else: - if cond2: - x = "else2" - reveal_type(x) # revealed: Literal["else2"] - else: - x = "raise" - reveal_type(x) # revealed: Literal["raise"] - raise ValueError - reveal_type(x) # revealed: Literal["else2"] - reveal_type(x) # revealed: Literal["else1", "else2"] + x = "else2" except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "else2", "raise"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "else2", "raise"] + reveal_type(x) # revealed: Literal["raise"] else: reveal_type(x) # revealed: Literal["else1", "else2"] - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "else2", "raise"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else1", "else2", "raise"] + reveal_type(x) # revealed: Literal["else1", "raise", "else2"] +``` + +Multiple raising branches inside a nested condition remain visible to the handler: +```py def raise_in_both_nested_branches(cond1: bool, cond2: bool): x = "before" try: - if cond1: + if cond1 is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" x = "else" - reveal_type(x) # revealed: Literal["else"] + elif cond2 is True: # error: [redundant-boolean-comparison] "Comparison of a `bool` with `True` is redundant" + x = "raise1" + raise ValueError else: - if cond2: - x = "raise1" - reveal_type(x) # revealed: Literal["raise1"] - raise ValueError - else: - x = "raise2" - reveal_type(x) # revealed: Literal["raise2"] - raise ValueError - reveal_type(x) # revealed: Literal["else"] + x = "raise2" + raise ValueError except ValueError: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise1", "raise2"] - except: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise1", "raise2"] + reveal_type(x) # revealed: Literal["raise1", "raise2"] else: reveal_type(x) # revealed: Literal["else"] - finally: - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise1", "raise2"] - # Exceptions can occur anywhere, so "before" and "raise" are valid possibilities - reveal_type(x) # revealed: Literal["before", "else", "raise1", "raise2"] + reveal_type(x) # revealed: Literal["else", "raise1", "raise2"] ``` ## Terminal in `try` with `finally` clause @@ -550,7 +450,8 @@ clause before it terminates the current scope or jumps to its final destination normal paths into the `finally` block. TODO: we don't yet consider both normal and terminal entry states when checking a `finally` block -that has a mix of normal and terminal entry paths. +that has a mix of normal and terminal entry paths. See +[ty#233](https://github.com/astral-sh/ty/issues/233). ```py def finally_runs_after_return(): @@ -587,7 +488,7 @@ def finally_runs_after_except_and_else_are_terminal(): x = "else-return" return finally: - reveal_type(x) # revealed: Literal["except-return", "else-return"] + reveal_type(x) # revealed: Literal["else-return"] def finally_runs_after_mixed_except_paths(cond: bool): x = "before" @@ -677,6 +578,399 @@ def finally_assignment_runs_before_break(): reveal_type(x) # revealed: Literal[1] ``` +## Returning from a context manager inside `try` + +A context manager cannot prevent a `return` from reaching the enclosing `finally` block. The block +still sees assignments made before the return. + +```py +from contextlib import suppress + +def returns_through_finally() -> None: + value = "before" + try: + with suppress(ValueError): + value = "returned" + return + finally: + reveal_type(value) # revealed: Literal["returned"] +``` + +## Continuing after a suppressing context manager inside `try` + +When a context manager suppresses an exception, a later assignment determines the value observed by +the `finally` block: + +```py +from contextlib import suppress + +value = "before" +try: + with suppress(ValueError): + raise ValueError + value = "continuing" +finally: + reveal_type(value) # revealed: Literal["continuing"] +``` + +## Continuing after a suppressing context manager and `finally` + +After an exception is suppressed, assignments in the `finally` block remain visible on the +continuing path: + +```py +from contextlib import suppress + +def continues_after_finally() -> str: + try: + with suppress(ValueError): + raise ValueError + finally: + value = "cleanup" + reveal_type(value) # revealed: Literal["cleanup"] + return value +``` + +## Raising from a context manager inside `try` + +A `finally` block remains reachable when a context manager propagates an exception: + +```py +from contextlib import nullcontext + +try: + with nullcontext(): + raise ValueError +finally: + # The diagnostic confirms that `finally` is reachable. + missing_name # error: [unresolved-reference] +``` + +## Unreachable bindings after a context manager inside `try` + +Assignments and imports after the propagating context manager cannot make the `finally` block +unreachable: + +```py +from contextlib import nullcontext + +try: + with nullcontext(): + raise ValueError + unreachable = 1 + import sys +finally: + # The diagnostic confirms that `finally` is reachable. + missing_after_unreachable_bindings # error: [unresolved-reference] +``` + +## Code after a terminal context manager and `finally` + +A non-suppressing manager does not allow a raised exception to continue past `finally` or implicitly +return from an annotated function: + +```py +from contextlib import nullcontext + +def does_not_continue() -> int: + try: + with nullcontext(): + raise ValueError + finally: + pass + # The absence of a diagnostic confirms that this code is unreachable. + missing_after_finally +``` + +## Narrowing after a terminal context manager and `finally` + +A branch that raises through a non-suppressing manager remains terminal after its cleanup: + +```py +from contextlib import nullcontext + +def narrows_after_finally(value: str | None) -> None: + if value is None: + try: + with nullcontext(): + raise ValueError + finally: + pass + reveal_type(value) # revealed: str +``` + +## Loop control after a terminal context manager and `finally` + +A `break` through a non-suppressing manager and its enclosing cleanup cannot reach a later +assignment in the loop: + +```py +from contextlib import nullcontext + +for _ in [1]: + try: + with nullcontext(): + break + finally: + pass + after_break = 1 + +after_break # error: [unresolved-reference] +``` + +The same applies to `continue`: + +```py +for _ in [1]: + try: + with nullcontext(): + continue + finally: + pass + after_continue = 1 + +after_continue # error: [unresolved-reference] +``` + +## Nested `finally` suites after a terminal context manager + +The outer cleanup observes assignments made in the inner cleanup, but execution does not continue +after either suite: + +```py +from contextlib import nullcontext + +def nested_cleanup() -> None: + try: + try: + with nullcontext(): + raise ValueError + finally: + value = "cleanup" + finally: + reveal_type(value) # revealed: Literal["cleanup"] + # The absence of a diagnostic confirms that this code is unreachable. + missing_after_nested_finally +``` + +## Terminal `except` branches after a context manager + +An `except` branch that assigns a value before returning still contributes that value to the +`finally` block: + +```py +from contextlib import nullcontext + +def unknown_exception() -> Exception: + return ValueError() + +def handler_returns() -> None: + value = "before" + try: + with nullcontext(): + raise unknown_exception() + except ValueError: + value = "returned" + return + finally: + reveal_type(value) # revealed: Literal["before", "returned"] +``` + +## Named `except` branches after a context manager + +Binding an exception does not make a terminal `except` branch a continuing entry into `finally`: + +```py +from contextlib import nullcontext + +def unknown_exception() -> Exception: + return ValueError() + +def named_handler() -> None: + value = "before" + try: + with nullcontext(): + raise unknown_exception() + except ValueError as error: + value = error + return + finally: + reveal_type(value) # revealed: Literal["before"] | ValueError +``` + +## Multiple terminal `except` branches after a context manager + +Every terminal `except` branch contributes its assignment to the `finally` block: + +```py +from contextlib import nullcontext + +def unknown_exception() -> Exception: + return ValueError() + +def multiple_handlers() -> None: + value = "before" + try: + with nullcontext(): + raise unknown_exception() + except ValueError: + value = "value-error" + return + except TypeError: + value = "type-error" + raise RuntimeError + finally: + reveal_type(value) # revealed: Literal["before", "value-error", "type-error"] +``` + +## `except` branches without terminal statements after a context manager + +An `except` branch with no terminal statements determines the value observed by `finally`: + +```py +from contextlib import nullcontext + +value = "before" +try: + with nullcontext(): + raise ValueError +except ValueError: + value = "continuing" +finally: + reveal_type(value) # revealed: Literal["continuing"] +``` + +## Unreachable assignments after a context manager inside `except` + +A context manager propagates an exception from an `except` branch even when an unreachable +assignment follows: + +```py +from contextlib import nullcontext + +try: + raise ValueError +except ValueError: + with nullcontext(): + raise RuntimeError + unreachable = 1 +finally: + # The diagnostic confirms that `finally` is reachable. + missing_after_unreachable_handler_assignment # error: [unresolved-reference] +``` + +## Raising from a context manager inside a named `except` branch + +Clearing a named exception does not hide the terminal path from `finally`: + +```py +from contextlib import nullcontext + +try: + raise ValueError +except ValueError as error: + with nullcontext(): + raise RuntimeError +finally: + # The diagnostic confirms that `finally` is reachable. + missing_name # error: [unresolved-reference] +``` + +## Terminal nested `except` branches without their own `finally` + +An unreachable assignment and a binding in the terminal inner `except` branch do not prevent the +path from reaching the outer `finally` block: + +```py +from contextlib import nullcontext + +def nested_unreachable_assignment() -> None: + try: + try: + with nullcontext(): + raise ValueError + unreachable = 1 + except ValueError: + local = 1 + return + finally: + # The diagnostic confirms that `finally` is reachable. + missing_after_nested_unreachable_assignment # error: [unresolved-reference] +``` + +A `break` through an inner `except` branch also reaches the outer `finally` block: + +```py +for _ in [1]: + try: + try: + with nullcontext(): + raise ValueError + except ValueError: + break + finally: + # The diagnostic confirms that `finally` is reachable. + missing_name # error: [unresolved-reference] +``` + +## Unreachable assignments after a context manager inside `else` + +A context manager propagates an exception from `else` even when an unreachable assignment follows: + +```py +from contextlib import nullcontext + +try: + pass +except ValueError: + pass +else: + with nullcontext(): + raise RuntimeError + unreachable = 1 +finally: + # The diagnostic confirms that `finally` is reachable. + missing_after_unreachable_else_assignment # error: [unresolved-reference] +``` + +## Possibly unbound names in `finally` after a context manager + +When an assignment raises before binding a name, a `finally` block can observe that the name is +undefined: + +```py +from contextlib import nullcontext + +def may_raise() -> str: + raise RuntimeError + +def without_context_manager() -> str | None: + try: + value = may_raise() + return may_raise() + except ValueError: + return None + finally: + # error: [possibly-unresolved-reference] + reveal_type(value) # revealed: str +``` + +A non-suppressing context manager does not prevent the `finally` block from observing that the name +may remain undefined. + +```py +def with_context_manager() -> str | None: + try: + value = may_raise() + with nullcontext(): + return may_raise() + except ValueError: + return None + finally: + # error: [possibly-unresolved-reference] + reveal_type(value) # revealed: str +``` + ## Calls to functions returning `Never` / `NoReturn` These calls should be treated as terminal statements. @@ -728,6 +1022,261 @@ def g(x: int | None): reveal_type(x) # revealed: int ``` +### Module scope + +A terminal call at module scope removes a binding from its branch even when the branch condition +does not narrow that binding. + +```py +from typing import NoReturn + +def stop() -> NoReturn: + raise RuntimeError + +def continue_normally() -> None: + pass + +flag: bool = bool(input()) +value = 1 + +if flag: + value = "unreachable" + stop() + +reveal_type(value) # revealed: Literal[1] +``` + +A call that returns normally must retain the binding from its branch. + +```py +continuing_value = 1 +other_flag: bool = bool(input()) + +if other_flag: + continuing_value = "reachable" + continue_normally() + +reveal_type(continuing_value) # revealed: Literal[1, "reachable"] +``` + +An unconditional terminal call eliminates the remaining bindings. + +```py +stop() +reveal_type(continuing_value) # revealed: Never +``` + +### Class scope + +Terminal and non-terminal calls have the same effect on class-body bindings as they do on +module-level bindings. + +```py +from typing import NoReturn + +def stop() -> NoReturn: + raise RuntimeError + +def continue_normally() -> None: + pass + +flag: bool = bool(input()) +other_flag: bool = bool(input()) + +class Example: + value = 1 + + if flag: + value = "unreachable" + stop() + + reveal_type(value) # revealed: Literal[1] + + continuing_value = 1 + + if other_flag: + continuing_value = "reachable" + continue_normally() + + reveal_type(continuing_value) # revealed: Literal[1, "reachable"] + + stop() + reveal_type(continuing_value) # revealed: Never +``` + +### Statically known branches in module and class scopes + +A terminal call in the reachable branch of a statically known condition removes its module-level +binding, even though the condition does not directly narrow that binding. + +```py +from typing import NoReturn + +def stop() -> NoReturn: + raise RuntimeError + +flag: bool = bool(input()) +module_value = 1 + +if flag: + module_value = "unreachable" + + if 1 + 1 == 2: + stop() + else: + pass + +reveal_type(module_value) # revealed: Literal[1] +module_value.bit_count() +``` + +The same statically known condition also removes the unreachable binding from a class body. + +```py +class Example: + value = 1 + + if flag: # error: [redundant-condition] "This condition is always false" + value = "unreachable" + + if 1 + 1 == 2: + stop() + else: + pass + + reveal_type(value) # revealed: Literal[1] + value.bit_count() +``` + +### Generic calls in module and class scopes + +A generic call is terminal when its argument specializes the return type to `Never`. + +```py +from typing import NoReturn, TypeVar, cast + +T = TypeVar("T") + +def identity(argument: T) -> T: + return argument + +def stop() -> NoReturn: + raise RuntimeError + +module_value = 1 + +if bool(input()): + module_value = "unreachable" + identity(stop()) + +reveal_type(module_value) # revealed: Literal[1] +``` + +Generic specialization also works when its terminal argument is not a simple call. + +```py +cast_value = 1 + +if bool(input()): + cast_value = "unreachable" + identity(cast(NoReturn, None)) + +reveal_type(cast_value) # revealed: Literal[1] +``` + +The same terminal call also narrows bindings in a class body. + +```py +class Example: + value = 1 + + if bool(input()): + value = "unreachable" + identity(stop()) + + reveal_type(value) # revealed: Literal[1] +``` + +### Overloads in module scope + +When only one overload returns `Never`, select the matching overload before deciding whether its +branch terminates. + +```py +from typing import NoReturn, overload + +@overload +def stop_if_int(argument: int) -> NoReturn: ... +@overload +def stop_if_int(argument: str) -> int: ... +def stop_if_int(argument: int | str) -> int: + if isinstance(argument, int): + raise RuntimeError + return 1 + +flag: bool = bool(input()) +value = 1 + +if flag: + value = "unreachable" + stop_if_int(1) + +reveal_type(value) # revealed: Literal[1] +``` + +A local argument that requires inference must still select its terminal overload. + +```py +local_argument: int = int(input()) +local_value = 1 + +if bool(input()): + local_value = "unreachable" + stop_if_int(local_argument) + +reveal_type(local_value) # revealed: Literal[1] +``` + +The overload that returns normally must not remove its branch. + +```py +other_flag: bool = bool(input()) +continuing_value = 1 + +if other_flag: + continuing_value = "reachable" + stop_if_int("safe") + +reveal_type(continuing_value) # revealed: Literal[1, "reachable"] +``` + +### Calls with no applicable bound overloads + +A bound method with no applicable overloads is invalid, but its call can still return at runtime. It +must not hide bindings from its branch or subsequent diagnostics. + +```py +from __future__ import annotations +from typing import overload + +class Example: + @overload + def method(self: str) -> None: ... + @overload + def method(self: bytes) -> None: ... + def method(self: Example | str | bytes) -> None: + pass + +value = 1 + +if bool(input()): + value = "reachable" + Example().method() # error: [no-matching-overload] + +reveal_type(value) # revealed: Literal[1, "reachable"] +value.bit_count() # error: [unresolved-attribute] +``` + ### Possibly unresolved diagnostics If the codepath on which a variable is not defined eventually returns `Never`, use of the variable @@ -947,6 +1496,22 @@ async def main(flag: bool): reveal_type(x) # revealed: Literal["test"] ``` +### Calls before loop-back imports + +Inference converges when a call's target is imported later in the same loop, even if the imported +function never returns. The import does not guarantee that `sys` is defined at the call. The +function definition before the call also requires looking for previous definitions of its name +across loop iterations. + +```py +for i in range(1): + def f(): + pass + + sys.exit() # error: [possibly-unresolved-reference] + import sys +``` + ## Nested functions Free references inside of a function body refer to variables defined in the containing scope. diff --git a/crates/ty_python_semantic/resources/mdtest/ty_extensions.md b/crates/ty_python_semantic/resources/mdtest/ty_extensions.md index bf7e637a13..8c3b9e6e79 100644 --- a/crates/ty_python_semantic/resources/mdtest/ty_extensions.md +++ b/crates/ty_python_semantic/resources/mdtest/ty_extensions.md @@ -359,7 +359,7 @@ from ty_extensions._internal import is_equivalent_to from typing_extensions import Never, Union static_assert(is_equivalent_to(type, type[object])) -static_assert(is_equivalent_to(tuple[int, Never], Never)) +static_assert(is_equivalent_to(tuple[Never, ...], tuple[()])) static_assert(is_equivalent_to(int | str, Union[int, str])) static_assert(not is_equivalent_to(int, str)) diff --git a/crates/ty_python_semantic/resources/mdtest/type_compendium/never.md b/crates/ty_python_semantic/resources/mdtest/type_compendium/never.md index 917b2ec2eb..bfef09447b 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_compendium/never.md +++ b/crates/ty_python_semantic/resources/mdtest/type_compendium/never.md @@ -171,18 +171,19 @@ x: list[Never] = [] ## Tuples involving `Never` -A type like `tuple[int, Never]` has no inhabitants, and so it is equivalent to `Never`: +A type like `tuple[int, Never]` remains distinct from `Never`. A tuple annotation can describe +user-defined subclasses, so its element types remain part of the type: ```py from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to from typing_extensions import Never -static_assert(is_equivalent_to(tuple[int, Never], Never)) +static_assert(not is_equivalent_to(tuple[int, Never], Never)) ``` -Note that this is not the case for the homogenous tuple type `tuple[Never, ...]` though, because -that type is inhabited by the empty tuple: +The homogeneous tuple type `tuple[Never, ...]` is also distinct from `Never`: it is inhabited by the +empty tuple. ```py static_assert(not is_equivalent_to(tuple[Never, ...], Never)) diff --git a/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md b/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md index a80fcfe227..171b13ddb9 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md @@ -227,17 +227,26 @@ static_assert(not is_singleton(tuple[None])) python-version = "3.11" ``` -The `Never` type contains no inhabitants, so a tuple type that contains `Never` as a mandatory -element also contains no inhabitants. +The `Never` type contains no inhabitants, but a tuple annotation can also describe user-defined +subclasses. A tuple type containing `Never` as a mandatory element therefore retains its shape +instead of simplifying to `Never`. ```py from typing import Never from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to -static_assert(is_equivalent_to(tuple[Never], Never)) -static_assert(is_equivalent_to(tuple[int, Never], Never)) -static_assert(is_equivalent_to(tuple[Never, *tuple[int, ...]], Never)) +static_assert(not is_equivalent_to(tuple[Never], Never)) +static_assert(not is_equivalent_to(tuple[int, Never], Never)) +static_assert(not is_equivalent_to(tuple[Never, *tuple[int, ...]], Never)) +``` + +Tuple expressions also preserve their element types when an element has type `Never`. + +```py +def tuple_from_never(value: Never) -> None: + reveal_type((value,)) # revealed: tuple[Never] + reveal_type((1, value)) # revealed: tuple[Literal[1], Never] ``` If the variable-length portion of a tuple is `Never`, then that portion of the tuple must always be @@ -573,6 +582,62 @@ def f(x: list[int]): reveal_type((42, 56, *x, 97)) # revealed: tuple[Literal[42], Literal[56], *tuple[int, ...], Literal[97]] ``` +## Tuples constructed from list literals + +Expanding a list literal into a tuple preserves the type and position of each element. Unpacking the +resulting tuple therefore gives the same types as unpacking the literal directly. + +```py +source = (*[1, "two"],) +reveal_type(source) # revealed: tuple[Literal[1], Literal["two"]] + +first, second = source +reveal_type(first) # revealed: Literal[1] +reveal_type(second) # revealed: Literal["two"] +``` + +Literal expansions can be nested, including through assignment expressions. Each assignment still +binds the type of its own container. + +```py +source = (0, *[1, *(pair := ("two", *[False]))]) +reveal_type(source) # revealed: tuple[Literal[0], Literal[1], Literal["two"], Literal[False]] +reveal_type(pair) # revealed: tuple[Literal["two"], Literal[False]] + +source = (*[(first := 1), first], (last := "two"), last) +reveal_type(source) # revealed: tuple[Literal[1], Literal[1], Literal["two"], Literal["two"]] +``` + +An invalid expansion reports its iteration error once, while the surrounding literal elements retain +their positions. + +```py +# error: [not-iterable] "Object of type `Literal[2]` is not iterable" +source = (*[1, *2, "two"],) +reveal_type(source) # revealed: tuple[Literal[1], *tuple[Unknown, ...], Literal["two"]] +``` + +## Literal expansions containing variable-length tuples + +A list literal can contain an expansion whose length is unknown. Converting that literal to a tuple +preserves the fixed elements on either side of the expansion, including when the variable portion is +a symbolic `TypeVarTuple`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +def homogeneous(values: tuple[float, ...]): + source = (*[1, *values, "two"],) + reveal_type(source) # revealed: tuple[Literal[1], *tuple[int | float, ...], Literal["two"]] + +def symbolic[*Ts](values: tuple[*Ts]): + source = (*[1, *values, "two"],) + reveal_type(source) # revealed: tuple[Literal[1], *Ts@symbolic, Literal["two"]] +``` + ## `Literal` promotion for large unannotated tuples We infer `Literal` types for a tuple's elements only if it has \<=64 elements. For larger tuples, if @@ -634,4 +699,42 @@ reveal_type(annotated_tuple_with_65) # fmt: on ``` +The limit counts elements introduced by list and tuple literal expansions, including elements from +several smaller literals. A 64-element expansion retains its literals; adding one more element +widens the whole tuple. Empty expansions do not consume this budget. + +```py +# fmt: off +expanded_64 = (*[], *[ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, 62, 63, +], *()) +expanded_65 = (*[ + 0, (1,), 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, +], *( + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, 62, 63, +), 64) +# fmt: on +reveal_type(expanded_64[0]) # revealed: Literal[0] +reveal_type(expanded_64[-1]) # revealed: Literal[63] +reveal_type(expanded_65[0]) # revealed: int +reveal_type(expanded_65[-1]) # revealed: int +``` + +Widening also applies inside the nested tuple in `expanded_65`. + +```py +reveal_type(expanded_65[1]) # revealed: tuple[int] +``` + [not a singleton type]: https://discuss.python.org/t/should-we-specify-in-the-language-reference-that-the-empty-tuple-is-a-singleton/67957 diff --git a/crates/ty_python_semantic/resources/mdtest/type_display/callable.md b/crates/ty_python_semantic/resources/mdtest/type_display/callable.md index 6c50af8725..f6a57be6f3 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_display/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/type_display/callable.md @@ -77,6 +77,29 @@ def _(x: object): reveal_type(c) # revealed: C[Top[(...)]] ``` +## Unpacked variadic signatures + +Display an unpacked variadic as one parameter, including its fixed prefix and required suffix. + +```toml +[environment] +python-version = "3.12" +``` + +```py +def mixed( + prefix: bytes, + /, + label: str, + *args: *tuple[bool, *tuple[int, ...], bytes, str], + flag: bool = False, + **kwargs: bytes, +) -> None: ... + +# revealed: def mixed(prefix: bytes, /, label: str, *args: *tuple[bool, *tuple[int, ...], bytes, str], flag: bool = False, **kwargs: bytes) +reveal_type(mixed) +``` + ## Type aliases are not expanded unless necessary ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/type_form.md b/crates/ty_python_semantic/resources/mdtest/type_form.md index 444b33d5b8..94b0a35318 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_form.md +++ b/crates/ty_python_semantic/resources/mdtest/type_form.md @@ -169,7 +169,7 @@ def use_existing_union(form: TypeForm[int] | TypeForm[str]) -> None: def use_existing_alias(form: FormUnion, value: int | str) -> None: assert_type(value, form) -type RecursiveForm = RecursiveForm | TypeForm[int] +type RecursiveForm = RecursiveForm | TypeForm[int] # error: [cyclic-type-alias-definition] def use_recursive_alias(form: RecursiveForm) -> None: reveal_type(cast(form, object())) # revealed: int diff --git a/crates/ty_python_semantic/resources/mdtest/type_of/typing_dot_Type.md b/crates/ty_python_semantic/resources/mdtest/type_of/typing_dot_Type.md index 9ac805b325..6378168f96 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_of/typing_dot_Type.md +++ b/crates/ty_python_semantic/resources/mdtest/type_of/typing_dot_Type.md @@ -16,6 +16,53 @@ def _(c: Type, d: Type[A]): d = c # fine ``` +## Legacy generic aliases + +Legacy generic aliases nested inside `type[...]` are not fully supported yet. They retain the same +fallback in evaluated and string annotations. + +```py +from typing import Set, Tuple, Type + +def f(a: Type[Set[int]], b: type[Tuple[int]], c: "type[Tuple[int]]"): + reveal_type(a) # revealed: @Todo(unsupported nested subscript in type[X]) + reveal_type(b) # revealed: @Todo(unsupported nested subscript in type[X]) + reveal_type(c) # revealed: @Todo(unsupported nested subscript in type[X]) +``` + +## Invalid arguments in unsupported string annotations + +Unsupported `type[...]` arguments are still checked as type expressions. Missing names and invalid +calls are reported instead of silently accepting the annotation. + +`runtime.py`: + +```py +from typing import Any, Tuple + +# error: [unresolved-reference] "Name `missing_alias` used when not defined" +alias: "type[Tuple[missing_alias]]" +# error: [invalid-type-form] "Function calls are not allowed" +call: "type[missing_call()]" +# error: [unresolved-reference] "Name `missing_any` used when not defined" +any_annotation: "type[Any[missing_any]]" +``` + +Stub files retain the same diagnostics. + +`stub.pyi`: + +```pyi +from typing import Any, Tuple + +# error: [unresolved-reference] "Name `missing_alias` used when not defined" +alias: "type[Tuple[missing_alias]]" +# error: [invalid-type-form] "Function calls are not allowed" +call: "type[missing_call()]" +# error: [unresolved-reference] "Name `missing_any` used when not defined" +any_annotation: "type[Any[missing_any]]" +``` + ## Inheritance Inheriting from `Type` results in a MRO with `builtins.type` and `typing.Generic`. `Type` itself is diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index eca4a0a144..6791c13f0c 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -33,7 +33,7 @@ typevar can only specialize to a type that is a supertype of the lower bound, an upper bound. ```py -from typing import Any, final, Never, Sequence +from typing import Any, Callable, final, Never, Sequence from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet @@ -86,6 +86,13 @@ def _[T]() -> None: static_assert(not ConstraintSet.range(Base, T, Unrelated)) ``` +Ordinary TypeVar bounds compare whole callables, so incompatible returns make a range unsatisfiable. + +```py +def callable_returns[T]() -> None: + static_assert(ConstraintSet.range(Callable[[int], int], T, Callable[[int], str]) == ConstraintSet.never()) +``` + When the lower and upper bounds are the same type, `equality` requires the typevar to specialize to that specific type. @@ -131,6 +138,16 @@ def _[T]() -> None: static_assert(ConstraintSet.lower_bound(int, T) == expected) ``` +Ordinary TypeVar bounds retain callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.lower_bound(Callable[[int], int], T) + static_assert(constraints != ConstraintSet.lower_bound(Callable[[int], str], T)) +``` + ### Upper bound An upper-bound constraint requires the type variable to be a subtype of its bound without providing @@ -145,6 +162,16 @@ def _[T]() -> None: static_assert(ConstraintSet.upper_bound(T, int) == expected) ``` +Upper bounds likewise retain ordinary callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.upper_bound(T, Callable[[int], int]) + static_assert(constraints != ConstraintSet.upper_bound(T, Callable[[int], str])) +``` + Unlike an explicit two-sided range, an upper-bound constraint does not supply `Never` as lower-bound inference evidence. @@ -176,6 +203,16 @@ def _[T]() -> None: reveal_type(equality.solutions_for(T, inferable=tuple[T])) ``` +Equality of ordinary types also includes callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.equality(T, Callable[[int], int]) + static_assert(constraints != ConstraintSet.equality(T, Callable[[int], str])) +``` + ### Negated range A _negated range_ constraint is the opposite of a range constraint: it requires the typevar to _not_ @@ -273,6 +310,152 @@ def _[T]() -> None: static_assert(negated_type != negated_constraint) ``` +## Constraints from materialized types + +### Invariant classes + +Assignability between fully static specializations of an invariant class determines the type +variable exactly. + +```py +from typing import Any +from ty_extensions import Bottom, Top +from ty_extensions._internal import is_constraint_set_assignable_to + +class Invariant[T]: + value: T + +def inspect_exact[T]() -> None: + exact = is_constraint_set_assignable_to(Top[Invariant[str]], Top[Invariant[T]]) + reveal_type(exact.solutions_for(T, inferable=tuple[T])) # revealed: tuple[Solution[T=str]] +``` + +The top materialization of `Invariant[Any]` covers every static specialization represented by `Any`. +No single fully static specialization of `T` can cover that range. The reverse +bottom-materialization comparison is impossible for the same reason. Constraint inference preserves +both ends of those ranges, so neither comparison has a solution. + +```py +def inspect_gradual[T]() -> None: + top = is_constraint_set_assignable_to(Top[Invariant[Any]], Top[Invariant[T]]) + reveal_type(top.solutions_for(T, inferable=tuple[T])) # revealed: None + + bottom = is_constraint_set_assignable_to(Bottom[Invariant[T]], Bottom[Invariant[Any]]) + reveal_type(bottom.solutions_for(T, inferable=tuple[T])) # revealed: None +``` + +### Recursive consuming methods + +A recursive consuming method imposes the opposite constraint from a covariant property. Seeing the +type variable in the property's constraints is not enough to omit that method: both bounds are +needed to establish the invariant specialization. The `Any` marker keeps the protocol gradual even +when its type argument is fully static. + +```py +from __future__ import annotations + +from typing import Any, Protocol +from ty_extensions import Top, static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +class RecursiveInvariant[T](Protocol): + marker: Any + + @property + def value(self) -> T: ... + def consume(self, other: RecursiveInvariant[T]) -> None: ... + +def inspect[T]() -> None: + constraints = is_constraint_set_assignable_to(Top[RecursiveInvariant[str]], Top[RecursiveInvariant[T]]) + static_assert(constraints == ConstraintSet.equality(T, str)) +``` + +A top-materialized `Any` also contributes both bounds. Its recursive consuming requirement makes the +relation impossible for every fully static specialization of `T`. + +```py +def inspect_gradual[T]() -> None: + constraints = is_constraint_set_assignable_to(Top[RecursiveInvariant[Any]], Top[RecursiveInvariant[T]]) + reveal_type(constraints.solutions_for(T, inferable=tuple[T])) # revealed: None +``` + +### Constraints introduced by recursive properties + +A recursive property can introduce constraints that the outer properties do not impose. Here, the +outer `value` properties both return `str | int`, but the children return `bytes | int` and +`T | int`. The child therefore contributes the lower bound `bytes <: T`. Its specialization stays +unchanged on subsequent recursive steps. + +```py +from __future__ import annotations + +from typing import Protocol +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +class Recursive[A, B](Protocol): + @property + def value(self) -> A | int: ... + @property + def child(self) -> Recursive[B, B]: ... + +def materialized_source[T]() -> None: + top = is_constraint_set_assignable_to(Top[Recursive[str | int, bytes]], Recursive[str, T]) + static_assert(top == ConstraintSet.lower_bound(bytes, T)) + reveal_type(top.solutions_for(T, inferable=tuple[T])) # revealed: tuple[Solution[T=bytes]] + + bottom = is_constraint_set_assignable_to(Bottom[Recursive[str | int, bytes]], Recursive[str, T]) + static_assert(bottom == ConstraintSet.lower_bound(bytes, T)) +``` + +Materializing the target instead preserves the same bound. These specializations contain no gradual +types, so neither materialization changes their requirements. + +```py +def materialized_target[T]() -> None: + top = is_constraint_set_assignable_to(Recursive[str | int, bytes], Top[Recursive[str, T]]) + static_assert(top == ConstraintSet.lower_bound(bytes, T)) + + bottom = is_constraint_set_assignable_to(Recursive[str | int, bytes], Bottom[Recursive[str, T]]) + static_assert(bottom == ConstraintSet.lower_bound(bytes, T)) +``` + +The bound also survives opposite materializations. Combining it with an incompatible upper bound has +no solution; the recursive comparison does not merely succeed without constraining `T`. + +```py +def incompatible_bound[T]() -> None: + constraints = is_constraint_set_assignable_to(Top[Recursive[str | int, bytes]], Bottom[Recursive[str, T]]) + static_assert(constraints == ConstraintSet.lower_bound(bytes, T)) + + incompatible = constraints & ConstraintSet.upper_bound(T, str) + reveal_type(incompatible.solutions_for(T, inferable=tuple[T])) # revealed: None +``` + +### Opposite materializations of recursive protocols + +A fixed `Any` in a recursive method changes independently of the protocol's type parameter. The +top-materialized return type `object` cannot satisfy the bottom-materialized return requirement +`Never`. The matching nonrecursive property can constrain `T`, but no specialization satisfies the +complete protocol. + +```py +from __future__ import annotations + +from typing import Any, Protocol +from ty_extensions import Bottom, Top +from ty_extensions._internal import is_constraint_set_assignable_to + +class RecursiveValue[T](Protocol): + @property + def value(self) -> T: ... + def consume(self, child: RecursiveValue[Any]) -> Any: ... + +def inspect[T]() -> None: + constraints = is_constraint_set_assignable_to(Top[RecursiveValue[str]], Bottom[RecursiveValue[T]]) + reveal_type(constraints.solutions_for(T, inferable=tuple[T])) # revealed: None +``` + ## Intersection The intersection of two constraint sets requires that the constraints in both sets hold. In many @@ -372,40 +555,49 @@ def lower_bounds[T](): ### Intersection of two equality constraints -A type variable cannot be exactly equal to two non-equivalent types. This is stronger than checking -whether the types are disjoint: two classes can have a common subclass, which makes their -upper-bound constraints compatible, but that subclass is not exactly equal to either class. +A type variable cannot be exactly equal to two non-equivalent fully static types. This is stronger +than checking whether the types are disjoint: two classes can have a common subclass, which makes +their upper-bound constraints compatible, but that subclass is not exactly equal to either class. + +Gradual bounds cannot prove this incompatibility. Sequent maps derive facts via transitivity, but +gradual assignability is not transitive. That means equality constraints containing dynamic types +remain conservatively satisfiable. Type variables nested inside a bound are treated as opaque +symbolic atoms; their declared bounds do not make an otherwise static proof gradual. ```py from typing import Any from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet -class Row: ... -class RowTuple(Row, tuple[Any, ...]): ... +class Left: ... +class Right: ... +class Both(Left, Right): ... -def _[T, U, V]() -> None: - row = ConstraintSet.equality(T, Row) - tuple_ = ConstraintSet.equality(T, tuple[Any, ...]) - static_assert(~(row & tuple_)) +def _[T, U: Any, V]() -> None: + left = ConstraintSet.equality(T, Left) + right = ConstraintSet.equality(T, Right) + static_assert(~(left & right)) - equivalent = row & row - static_assert(equivalent == row) + equivalent = left & left + static_assert(equivalent == left) - upper_bounds = ConstraintSet.upper_bound(T, Row) & ConstraintSet.upper_bound(T, tuple[Any, ...]) + upper_bounds = ConstraintSet.upper_bound(T, Left) & ConstraintSet.upper_bound(T, Right) static_assert(not ~upper_bounds) - row_tuple = ConstraintSet.equality(T, RowTuple) - static_assert(row_tuple & upper_bounds == row_tuple) + both = ConstraintSet.equality(T, Both) + static_assert(both & upper_bounds == both) + + symbolic_static_mismatch = ConstraintSet.equality(T, tuple[U, int]) & ConstraintSet.equality(T, tuple[U, str]) + static_assert(~symbolic_static_mismatch) gradual_mismatch = ConstraintSet.equality(T, list[Any]) & ConstraintSet.equality(T, list[int]) - static_assert(~gradual_mismatch) + static_assert(not ~gradual_mismatch) any_mismatch = ConstraintSet.equality(T, Any) & ConstraintSet.equality(T, int) - static_assert(~any_mismatch) + static_assert(not ~any_mismatch) - symbolic_mismatch = ConstraintSet.equality(T, tuple[U, Any]) & ConstraintSet.equality(T, tuple[U, int]) - static_assert(~symbolic_mismatch) + symbolic_gradual_mismatch = ConstraintSet.equality(T, tuple[U, Any]) & ConstraintSet.equality(T, tuple[U, int]) + static_assert(not ~symbolic_gradual_mismatch) symbolic_match = ConstraintSet.equality(T, list[U]) & ConstraintSet.equality(T, list[V]) static_assert(not ~symbolic_match) @@ -951,6 +1143,26 @@ def same_typevar[T](): static_assert(constraints == expected) ``` +Constraining a ParamSpec with itself leaves every parameter list possible. + +```pyi +from typing import Callable +from ty_extensions import Bottom, Top + +def same_paramspec[**P]() -> None: + constraints = ConstraintSet.upper_bound(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) + static_assert(constraints == expected) + + constraints = ConstraintSet.lower_bound(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) + static_assert(constraints == expected) + + constraints = ConstraintSet.equality(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) + static_assert(constraints == expected) +``` + ## Existential quantification Existential quantification removes the listed typevars from a constraint set. Any constraints that @@ -1027,6 +1239,424 @@ def quantifier_order[S, T]() -> None: static_assert(exists_source_forall_target == ConstraintSet.never()) ``` +## ParamSpec + +A ParamSpec constraint describes parameter lists; callable returns are ignored. + +### Construction + +Legacy ParamSpecs work with every constructor. + +```py +from typing import Callable, ParamSpec +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +P = ParamSpec("P") + +def legacy_range(callback: Callable[P, None]) -> None: + constraints = ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None]) + different_returns = ConstraintSet.range(Callable[[int, str], int], P, Callable[[int, str], str]) + static_assert(constraints == different_returns) + +def legacy_lower_bound(callback: Callable[P, None]) -> None: + expected = is_constraint_set_assignable_to(Callable[[int, str], None], Callable[P, None]) + static_assert(ConstraintSet.lower_bound(Callable[[int, str], int], P) == expected) + +def legacy_upper_bound(callback: Callable[P, None]) -> None: + expected = is_constraint_set_assignable_to(Callable[P, None], Callable[[int, str], None]) + static_assert(ConstraintSet.upper_bound(P, Callable[[int, str], str]) == expected) + +def legacy_equality(callback: Callable[P, None]) -> None: + equality = ConstraintSet.equality(P, Callable[[int, str], bytes]) + static_assert(equality == ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None])) +``` + +An empty parameter list is an exact bound, distinct from a one-parameter list. + +```py +def empty[**P]() -> None: + constraints = ConstraintSet.range(Callable[[], None], P, Callable[[], None]) + static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) +``` + +An alias of a known constructor retains its ParamSpec argument rules. + +```py +def aliased_constructor[**P]() -> None: + equals = ConstraintSet.equality + constraints = equals(P, Callable[[int], None]) + static_assert(constraints == ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) +``` + +### Callable aliases + +Specialized callable aliases have the same bounds as their expanded parameter lists. + +```py +from typing import Callable, Concatenate +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +type Callback[**Q, R] = Callable[Q, R] + +def aliases[**P]() -> None: + constraints = ConstraintSet.range(Callback[[int, str, bool], int], P, Callback[[int, str, bool], str]) + expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) + static_assert(constraints == expected) +``` + +Fully specializing a `Concatenate` alias preserves every prefix parameter and the concrete tail. + +```py +type Prefixed[**Q, R] = Callable[Concatenate[int, str, Q], R] + +def concatenate[**P]() -> None: + constraints = ConstraintSet.range(Prefixed[[bool], int], P, Prefixed[[bool], str]) + expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) + static_assert(constraints == expected) +``` + +### Two-sided bounds + +A callable accepting `Super` and a consumer passing a `Sub` give `(Super, /) ≤ P ≤ (Sub, /)`. + +```py +from typing import Callable, final +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +class Super: ... +class Base(Super): ... +class Sub(Base): ... + +@final +class Unrelated: ... + +def two_sided[**P]() -> None: + constraints = ConstraintSet.range(Callable[[Super], None], P, Callable[[Sub], None]) + lower = ConstraintSet.lower_bound(Callable[[Super], int], P) + upper = ConstraintSet.upper_bound(P, Callable[[Sub], str]) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +Inverted or incomparable bounds are unsatisfiable. + +```py +def incompatible[**P]() -> None: + inverted = ConstraintSet.range(Callable[[Sub], None], P, Callable[[Super], None]) + static_assert(inverted == ConstraintSet.never()) + incomparable = ConstraintSet.range(Callable[[Base], None], P, Callable[[Unrelated], None]) + static_assert(incomparable == ConstraintSet.never()) +``` + +Individually satisfiable lower and upper bounds can have an empty intersection. + +```py +def incompatible_intersection[**P]() -> None: + lower = ConstraintSet.lower_bound(Callable[[Sub], None], P) + upper = ConstraintSet.upper_bound(P, Callable[[Super], None]) + static_assert((lower & upper) == ConstraintSet.never()) +``` + +### Symbolic bounds + +Two ParamSpecs can be constrained to the same parameter list, in either order. + +```py +from typing import Any, Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +def equality[**P, **Q]() -> None: + constraints = ConstraintSet.equality(P, Q) + expected = is_constraint_set_assignable_to(Callable[P, Any], Callable[Q, Any]) + static_assert(constraints == expected) + static_assert(ConstraintSet.equality(Q, P) == constraints) +``` + +Each endpoint is retained when a symbolic lower bound is combined with a concrete upper bound. + +```py +def symbolic_lower[**P, **Q]() -> None: + constraints = ConstraintSet.range(Q, P, Callable[[int], None]) + lower = ConstraintSet.lower_bound(Q, P) + upper = ConstraintSet.upper_bound(P, Callable[[int], None]) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +Symbolic upper bounds likewise retain their concrete lower bound. + +```py +def symbolic_upper[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[[int], None], P, Q) + lower = ConstraintSet.lower_bound(Callable[[int], None], P) + upper = ConstraintSet.upper_bound(P, Q) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +Three ParamSpecs form a two-sided range. + +```py +def symbolic_range[**P, **Q, **R]() -> None: + constraints = ConstraintSet.range(Q, P, R) + lower = ConstraintSet.lower_bound(Q, P) + upper = ConstraintSet.upper_bound(P, R) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +### Symbolic callable bounds + +An unprefixed callable bound describes the same parameter list as its bare ParamSpec. + +```py +from typing import Any, Callable, Concatenate +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +def unprefixed[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[Q, int], P, Callable[Q, str]) + static_assert(constraints == ConstraintSet.range(Q, P, Q)) +``` + +A `Concatenate` bound preserves its prefix and symbolic tail while erasing the return. + +```py +def prefixed[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[Concatenate[int, Q], int], P, Callable[Concatenate[int, Q], str]) + expected = is_constraint_set_assignable_to(Callable[Concatenate[int, Q], int], Callable[P, Any]) + expected &= is_constraint_set_assignable_to(Callable[P, Any], Callable[Concatenate[int, Q], str]) + static_assert(constraints == expected) + static_assert(constraints != ConstraintSet.range(Q, P, Q)) + different_prefix = ConstraintSet.range(Callable[Concatenate[str, Q], None], P, Callable[Concatenate[str, Q], None]) + static_assert(constraints != different_prefix) +``` + +### Signature preservation + +Named parameters accept positional-only calls; the reverse range is invalid. + +```pyi +from typing import Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf + +def named(value: int) -> None: ... +def positional_only[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[named], P, Callable[[int], None]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(Callable[[int], None], P, RegularCallableTypeOf[named]) + static_assert(reverse == ConstraintSet.never()) +``` + +Named parameters also accept keyword-only calls; the reverse range is invalid. + +```pyi +def keyword(*, value: int) -> None: ... +def keyword_only[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[named], P, RegularCallableTypeOf[keyword]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[named]) + static_assert(reverse == ConstraintSet.never()) +``` + +An optional parameter accepts every call to a required parameter, but not the reverse. + +```pyi +def optional(value: int = ...) -> None: ... +def defaults[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[optional], P, RegularCallableTypeOf[named]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(RegularCallableTypeOf[named], P, RegularCallableTypeOf[optional]) + static_assert(reverse == ConstraintSet.never()) +``` + +Variadic positional parameters accept fixed positional lists, but not the reverse. + +```pyi +def args(*args: int) -> None: ... +def positional_variadics[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[args], P, Callable[[int, int], None]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(Callable[[int, int], None], P, RegularCallableTypeOf[args]) + static_assert(reverse == ConstraintSet.never()) +``` + +Variadic keyword parameters likewise accept a fixed keyword-only parameter, but not the reverse. + +```pyi +def kwargs(**kwargs: int) -> None: ... +def keyword_variadics[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[kwargs], P, RegularCallableTypeOf[keyword]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[kwargs]) + static_assert(reverse == ConstraintSet.never()) +``` + +### Overloaded bounds + +Return types are erased in every overload, without keeping only the first or last parameter list. + +```pyi +from typing import Callable, overload +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf + +@overload +def overloaded(value: int, /) -> int: ... +@overload +def overloaded(*, value: str) -> str: ... +@overload +def swapped_returns(value: int, /) -> str: ... +@overload +def swapped_returns(*, value: str) -> int: ... +def keyword(*, value: str) -> None: ... +def overloads[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[swapped_returns]) + static_assert(constraints == ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[overloaded])) + static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) + static_assert(constraints != ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[keyword])) +``` + +An overloaded lower bound can satisfy a single signature; an overloaded upper bound requires both. + +```pyi +def asymmetric[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, Callable[[int], None]) + static_assert(constraints != ConstraintSet.never()) + reverse = ConstraintSet.range(Callable[[int], None], P, RegularCallableTypeOf[overloaded]) + static_assert(reverse == ConstraintSet.never()) +``` + +The string overload accepts only a keyword argument, not a positional argument. + +```pyi +def parameter_kinds[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[keyword]) + static_assert(constraints != ConstraintSet.never()) + positional = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, Callable[[str], None]) + static_assert(positional == ConstraintSet.never()) +``` + +### Gradual parameter lists + +An empty parameter list is compatible with ellipsis, but not with one required `Any` parameter. + +```py +from typing import Any, Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def empty[**P]() -> None: + static_assert(ConstraintSet.range(Callable[..., None], P, Callable[[], None]) != ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[], None], P, Callable[..., None]) != ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[Any], None], P, Callable[[], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[], None], P, Callable[[Any], None]) == ConstraintSet.never()) +``` + +### Missing bounds + +A missing lower bound is equivalent to the bottom signature, which accepts all arguments. + +```py +from typing import Callable, Never +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import ConstraintSet + +def missing_lower_bound[**P]() -> None: + constraints = ConstraintSet.upper_bound(P, Callable[[int], int]) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Callable[[int], int]) + static_assert(constraints == expected) +``` + +A missing upper bound is equivalent to the top signature, which accepts no calls. + +```py +def missing_upper_bound[**P]() -> None: + constraints = ConstraintSet.lower_bound(Callable[[int], int], P) + expected = ConstraintSet.range(Callable[[int], int], P, Top[Callable[..., object]]) + static_assert(constraints == expected) +``` + +### Invalid forms and preservation controls + +An ordinary type is not a parameter list, so it makes a ParamSpec constraint unsatisfiable. + +```py +from typing import Callable, Never, TypeVarTuple +from typing_extensions import TypeForm +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def invalid_bounds[**P]() -> None: + static_assert(ConstraintSet.range(int, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, int) == ConstraintSet.never()) + static_assert(ConstraintSet.lower_bound(int, P) == ConstraintSet.never()) + static_assert(ConstraintSet.upper_bound(P, object) == ConstraintSet.never()) + static_assert(ConstraintSet.equality(P, Never) == ConstraintSet.never()) +``` + +An ordinary TypeVar or ParamSpec component is not a complete parameter list. + +```py +def invalid_typevar_bounds[**P, **Q, T]() -> None: + static_assert(ConstraintSet.range(T, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, Q.args) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Q.kwargs, P, Callable[[int], None]) == ConstraintSet.never()) +``` + +Bare TypeVarTuples remain invalid bounds. + +```py +def typevartuple_bounds[**P, *Us]() -> None: + ConstraintSet.range(Us, P, Callable[[int], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.range(Callable[[int], None], P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" +``` + +Nested callable annotations and unrelated TypeForm calls retain normal ParamSpec validation. + +```py +def accepts_type_form(form: TypeForm[object]) -> TypeForm[object]: + return form + +def invalid_forms[**P]() -> None: + ConstraintSet.equality(P, Callable[[P], None]) # error: [invalid-type-form] + ConstraintSet.equality(P, Callable[..., P]) # error: [invalid-type-form] + ConstraintSet.equality(P, accepts_type_form(P)) # error: [invalid-type-form] + ConstraintSet.equality(P, Callable[[int], None]) + accepts_type_form(P) # error: [invalid-type-form] +``` + +ParamSpec components keep their ordinary bounds. + +```py +def components[**P]() -> None: + args = ConstraintSet.range(tuple[int], P.args, tuple[object, ...]) + kwargs = ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object]) + static_assert(args != ConstraintSet.never()) + static_assert(kwargs != ConstraintSet.never()) +``` + +Bare TypeVarTuples remain invalid subjects. + +```py +Ts = TypeVarTuple("Ts") + +def legacy_typevartuple_subject(value: tuple[*Ts]) -> None: + ConstraintSet.range(Callable[[int], None], Ts, Callable[[int], None]) # error: [invalid-type-form] + +def typevartuple_subject[*Us]() -> None: + ConstraintSet.range(Callable[[int], None], Us, Callable[[int], None]) # error: [invalid-type-form] +``` + ## Displaying constraints The `with_detailed_display` method can be used to print out the boolean formula that a constraint @@ -1036,7 +1666,7 @@ out all of the different kinds of constraints described above. Here we just test exists, and provides more detail than otherwise. ```py -from ty_extensions._internal import ConstraintSet +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf class Super: ... class Base(Super): ... @@ -1050,3 +1680,64 @@ def _[T]() -> None: # revealed: ConstraintSet[(Sub ≤ T@_ ≤ Super)] reveal_type(ConstraintSet.range(Sub, T, Super).with_detailed_display()) ``` + +Explicit bottom and top parameter-list bounds are shown in the constraint. + +```py +from typing import Any, Callable, Never +from ty_extensions import Bottom, Top + +def explicit_bounds[**P]() -> None: + lower = ConstraintSet.range(Bottom[Callable[..., Never]], P, Callable[[int], int]) + # revealed: ConstraintSet[((*args: object, **kwargs: object) ≤ P@explicit_bounds ≤ (int, /))] + reveal_type(lower.with_detailed_display()) + upper = ConstraintSet.range(Callable[[int], int], P, Top[Callable[..., object]]) + # revealed: ConstraintSet[((int, /) ≤ P@explicit_bounds ≤ Top[(...)])] + reveal_type(upper.with_detailed_display()) +``` + +ParamSpec bounds display the full parameter list without the callable return type. + +```py +def complete(value: int, /, text: str = "", *args: float, flag: bool = False, **kwargs: bytes) -> int: + return 0 + +def signature[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[complete], P, RegularCallableTypeOf[complete]) + # revealed: ConstraintSet[(P@signature = (value: int, /, text: str = "", *args: int | float, flag: bool = False, **kwargs: bytes))] + reveal_type(constraints.with_detailed_display()) +``` + +Generic callable bounds keep their own ParamSpec binder. + +```py +def callback[**Q](*args: Q.args, **kwargs: Q.kwargs) -> None: ... +def generic_signature[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[callback], P, RegularCallableTypeOf[callback]) + # revealed: ConstraintSet[(P@generic_signature = (**Q@callback))] + reveal_type(constraints.with_detailed_display()) +``` + +The display distinguishes gradual parameter lists from one required `Any` parameter. + +```py +def gradual[**P]() -> None: + ellipsis = ConstraintSet.range(Callable[..., int], P, Callable[..., str]) + reveal_type(ellipsis.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (...))] + any_parameter = ConstraintSet.range(Callable[[Any], int], P, Callable[[Any], str]) + reveal_type(any_parameter.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (Any, /))] +``` + +Omitted bounds stay absent; explicit `...` bounds remain visible. + +```py +def missing_bounds[**P]() -> None: + # revealed: ConstraintSet[((int, /) ≤ P@missing_bounds)] + reveal_type(ConstraintSet.lower_bound(Callable[[int], None], P).with_detailed_display()) + # revealed: ConstraintSet[((int, /) ≤ P@missing_bounds ≤ (...))] + reveal_type(ConstraintSet.range(Callable[[int], None], P, Callable[..., None]).with_detailed_display()) + # revealed: ConstraintSet[(P@missing_bounds ≤ (int, /))] + reveal_type(ConstraintSet.upper_bound(P, Callable[[int], None]).with_detailed_display()) + # revealed: ConstraintSet[((...) ≤ P@missing_bounds ≤ (int, /))] + reveal_type(ConstraintSet.range(Callable[..., None], P, Callable[[int], None]).with_detailed_display()) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md index 7ee22c47e0..af5be9c010 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md @@ -246,9 +246,25 @@ def mutually_constrained[U, T](): static_assert(not given_int.implies_subtype_of(T, str)) ``` +## Type variable tuples + +A concrete tuple is not a subtype of an arbitrary type variable tuple. A constraint relating the two +can establish that relationship without admitting incompatible tuple elements. + +```py +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +def tuple_assumptions[*Ts]() -> None: + given = is_constraint_set_assignable_to(tuple[int], tuple[*Ts]) + static_assert(given.implies_subtype_of(tuple[int], tuple[*Ts])) + static_assert(not given.implies_subtype_of(tuple[str], tuple[*Ts])) + static_assert(not ConstraintSet.always().implies_subtype_of(tuple[int], tuple[*Ts])) +``` + ## Compound types -All of the relationships in the above section also apply when a typevar appears in a compound type. +The relationships for [type variables](#type-variables) also apply within compound types. ```py from ty_extensions import static_assert diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index 061f8285ca..76e4e65e79 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -684,6 +684,28 @@ static_assert(not is_assignable_to(tuple[int, *tuple[int, ...], int], tuple[int] static_assert(not is_assignable_to(tuple[int, *tuple[int, ...], int], tuple[int, int])) ``` +An unbounded homogeneous tuple whose element type is an alias of `Any` is also gradual. It is +assignable to fixed-length tuples, including the empty tuple, even through a chain of aliases. + +```py +type Dynamic = Any +type DynamicAlias = Dynamic + +static_assert(is_assignable_to(tuple[Dynamic, ...], tuple[()])) +static_assert(is_assignable_to(tuple[Dynamic, ...], tuple[int])) +static_assert(is_assignable_to(tuple[DynamicAlias, ...], tuple[int, str])) +``` + +When unpacked into a mixed tuple, the gradual segment can supply additional elements, but the fixed +prefix and suffix must still fit within the target and have compatible types. + +```py +static_assert(is_assignable_to(tuple[int, *tuple[Dynamic, ...], str], tuple[int, bool, str])) +static_assert(not is_assignable_to(tuple[int, *tuple[Dynamic, ...], str], tuple[int])) +static_assert(not is_assignable_to(tuple[int, *tuple[Dynamic, ...], str], tuple[str, bool, str])) +static_assert(not is_assignable_to(tuple[int, *tuple[Dynamic, ...], str], tuple[int, bool, int])) +``` + ## Union types ```py @@ -839,9 +861,9 @@ from typing_extensions import Any, Never, Sequence from ty_extensions import static_assert from ty_extensions._internal import is_assignable_to -# The bottom materialization of `tuple[Any]` is `tuple[Never]`, -# which simplifies to `Never`, so `tuple[int]` and `tuple[()]` are -# both assignable to `~tuple[Any]` +# The bottom materialization of `tuple[Any]` is `tuple[Never]`. Both +# `tuple[int]` and `tuple[()]` are disjoint from `tuple[Never]`, so they are +# assignable to `~tuple[Any]`. static_assert(is_assignable_to(tuple[int], ~tuple[Any])) static_assert(is_assignable_to(tuple[()], ~tuple[Any])) @@ -1084,7 +1106,7 @@ parameter following that tuple. ```py from typing import Any, Callable, Never, Unpack, cast -from ty_extensions import static_assert +from ty_extensions import Top, static_assert from ty_extensions._internal import RegularCallableTypeOf, is_assignable_to def expects_suffix(callback: Callable[[Unpack[tuple[str, ...]], None], None]) -> None: ... @@ -1194,6 +1216,17 @@ static_assert(is_assignable_to(OneOrMoreIntegers, GradualSuffix)) static_assert(is_assignable_to(GradualSuffix, OneOrMoreIntegers)) ``` +Gradual and top callable signatures accept a named positional prefix before an unpacked required +suffix. Their synthetic keyword parameters do not represent concrete keyword arguments that can +collide with the prefix. + +```py +def named_prefix_and_suffix(name: int, *args: *tuple[*tuple[int, ...], int]) -> None: ... + +static_assert(is_assignable_to(RegularCallableTypeOf[named_prefix_and_suffix], Callable[..., None])) +static_assert(is_assignable_to(RegularCallableTypeOf[named_prefix_and_suffix], Top[Callable[..., None]])) +``` + A positional parameter cannot also be filled by a target keyword argument. ```py @@ -1225,6 +1258,30 @@ A suffix cannot be extended with elements that the source variadic parameter rej incompatible_suffix: Callable[[*tuple[int, ...], str, str], None] = requires_string_after_integers ``` +### Gradual keyword collisions with unpacked positional parameters + +A gradual keyword type can materialize to `Never`, eliminating an otherwise possible collision. + +```py +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import RegularCallableTypeOf, is_assignable_to + +def source(a: int, *args: *tuple[*tuple[int, ...], int], **kwargs: int) -> None: ... +def target(x: int, /, *args: *tuple[*tuple[int, ...], int], **kwargs: Any) -> None: ... + +static_assert(is_assignable_to(RegularCallableTypeOf[source], RegularCallableTypeOf[target])) +``` + +A gradual source tail does not remove a real named prefix or permit a duplicate argument. + +```py +def gradual_source(a: int, *args: Any, **kwargs: Any) -> None: ... +def concrete_target(x: int, /, *args: *tuple[*tuple[int, ...], int], **kwargs: int) -> None: ... + +static_assert(not is_assignable_to(RegularCallableTypeOf[gradual_source], RegularCallableTypeOf[concrete_target])) +``` + ### Fixed-length unpacked positional parameters An unpacked fixed-length tuple accepts exactly its declared positional arguments, including when the diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md index e7e847615f..8f37dc3637 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md @@ -1186,6 +1186,127 @@ class Both(Left, Right): ... static_assert(not is_disjoint_from(Left, Right)) ``` +### Nested type variables in invariant arguments + +An invariant argument can contain a type variable and still be incompatible with another argument. +For example, `list[T]` cannot equal `int`, regardless of the specialization of `T`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Never +from ty_extensions import static_assert +from ty_extensions._internal import is_disjoint_from + +def incompatible[T](): + static_assert(is_disjoint_from(list[list[T]], list[int])) + static_assert(is_disjoint_from(list[int], list[list[T]])) + static_assert(is_disjoint_from(list[tuple[T, int]], list[tuple[T, str]])) + static_assert(is_disjoint_from(list[tuple[T, str]], list[tuple[T, int]])) +``` + +When the surrounding structure matches, the arguments can instead be equal for some specialization. +Aliases preserve that possibility, including aliases nested inside the argument. + +```py +type Id[T] = T + +def compatible[T](): + static_assert(not is_disjoint_from(list[list[T]], list[list[int]])) + static_assert(not is_disjoint_from(list[list[Id[T]]], list[list[int]])) + static_assert(not is_disjoint_from(list[list[T]], list[list[Never]])) +``` + +An upper bound can rule out equality even when the surrounding structure matches. A type variable +bounded by `str` cannot specialize to `int`, but it can specialize to `str` or `Never`. + +```py +def bounded[T: str](): + static_assert(is_disjoint_from(list[list[T]], list[list[int]])) + static_assert(is_disjoint_from(list[list[int]], list[list[T]])) + static_assert(not is_disjoint_from(list[list[T]], list[list[str]])) + static_assert(not is_disjoint_from(list[list[T]], list[list[Never]])) +``` + +A constrained type variable can only specialize to one of its constraints. Neither `int` nor `Never` +is a valid specialization, while matching either `str` or `bytes` preserves a possible overlap. + +```py +def constrained[T: (str, bytes)](): + static_assert(is_disjoint_from(list[list[T]], list[list[int]])) + static_assert(is_disjoint_from(list[list[int]], list[list[T]])) + static_assert(is_disjoint_from(list[list[T]], list[list[Never]])) + static_assert(not is_disjoint_from(list[list[T]], list[list[str]])) + static_assert(not is_disjoint_from(list[list[T]], list[list[bytes]])) +``` + +### Gradual bounds in invariant arguments + +A gradual upper bound admits fully static specializations. Invariant types overlap when their +arguments can be equal for one of those specializations, including inside another invariant type. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import is_disjoint_from + +def any_bound[T: Any](): + static_assert(not is_disjoint_from(list[T], list[str])) + static_assert(not is_disjoint_from(list[str], list[T])) + static_assert(not is_disjoint_from(list[list[T]], list[list[str]])) + static_assert(not is_disjoint_from(list[list[str]], list[list[T]])) + static_assert(is_disjoint_from(list[T], int)) + static_assert(is_disjoint_from(int, list[T])) + +def bounded[T: list[Any]](): + static_assert(not is_disjoint_from(list[T], list[list[str]])) + static_assert(not is_disjoint_from(list[list[str]], list[T])) + static_assert(is_disjoint_from(list[T], list[str])) + static_assert(is_disjoint_from(list[str], list[T])) +``` + +### Overlapping invariant materialization ranges + +Invariant types are not disjoint when their arguments have a common materialization. For every `T` +bounded by `int`, the left argument can materialize to `int | str`, which is also a materialization +of the right argument. + +```toml +[environment] +python-version = "3.12" +``` + +```pyi +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import is_disjoint_from + +type Left[T] = list[(T | Any) & (int | str | bytes)] +type Right = list[(str | Any) & (int | str | float)] + +def overlap[T: int](): + static_assert(not is_disjoint_from(Left[T], Right)) + static_assert(not is_disjoint_from(Right, Left[T])) + +static_assert(not is_disjoint_from(Left[int], Right)) +static_assert(not is_disjoint_from(Right, Left[int])) +``` + +If the left argument must include `bytes`, the ranges have no common materialization. + +```pyi +static_assert(is_disjoint_from(Left[bytes], Right)) +static_assert(is_disjoint_from(Right, Left[bytes])) +``` + ### NewTypes and overlapping types A `NewType` overlaps with any nominal or structural type that overlaps its concrete base. This diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index 40b54fab60..a126cccbd7 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -987,6 +987,126 @@ type RecursiveGradual = Covariant[RecursiveGradual] | Invariant[Any] static_assert(is_subtype_of(Covariant[RecursiveGradual], Covariant[object])) ``` +## Generic protocol materializations + +Every gradual type is a supertype of its bottom materialization and a subtype of its top +materialization. Here, we check this for generic protocols: + +```py +from typing import Any, Protocol +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_subtype_of + +class Unbounded[T](Protocol): + def read(self) -> T: ... + def other(self) -> Any: ... + +static_assert(is_subtype_of(Unbounded[Any], Top[Unbounded[Any]])) +static_assert(is_subtype_of(Bottom[Unbounded[Any]], Unbounded[Any])) +static_assert(is_subtype_of(Bottom[Unbounded[Any]], Top[Unbounded[Any]])) +static_assert(not is_subtype_of(Unbounded[Any], Bottom[Unbounded[Any]])) +static_assert(not is_subtype_of(Top[Unbounded[Any]], Unbounded[Any])) +static_assert(not is_subtype_of(Top[Unbounded[Any]], Bottom[Unbounded[Any]])) +``` + +The same relations hold when the type parameter has a bound: + +```py +class Bounded[T: str](Protocol): + def read(self) -> T: ... + def other(self) -> Any: ... + +static_assert(is_subtype_of(Bounded[Any], Top[Bounded[Any]])) +static_assert(is_subtype_of(Bottom[Bounded[Any]], Bounded[Any])) +static_assert(is_subtype_of(Bottom[Bounded[Any]], Top[Bounded[Any]])) +static_assert(not is_subtype_of(Bounded[Any], Bottom[Bounded[Any]])) +static_assert(not is_subtype_of(Top[Bounded[Any]], Bounded[Any])) +static_assert(not is_subtype_of(Top[Bounded[Any]], Bottom[Bounded[Any]])) +``` + +These relations also hold between distinct protocols with the same members: + +```py +class EquivalentBounded[T: str](Protocol): + def read(self) -> T: ... + def other(self) -> Any: ... + +static_assert(is_subtype_of(Bounded[Any], Top[EquivalentBounded[Any]])) +static_assert(is_subtype_of(Bottom[Bounded[Any]], EquivalentBounded[Any])) +static_assert(is_subtype_of(Bottom[Bounded[Any]], Top[EquivalentBounded[Any]])) +static_assert(not is_subtype_of(Bounded[Any], Bottom[EquivalentBounded[Any]])) +static_assert(not is_subtype_of(Top[Bounded[Any]], EquivalentBounded[Any])) +static_assert(not is_subtype_of(Top[Bounded[Any]], Bottom[EquivalentBounded[Any]])) +``` + +The same relations also hold when the type parameter has constraints: + +```py +class Constrained[T: (str, bytes)](Protocol): + def read(self) -> T: ... + def other(self) -> Any: ... + +static_assert(is_subtype_of(Constrained[Any], Top[Constrained[Any]])) +static_assert(is_subtype_of(Bottom[Constrained[Any]], Constrained[Any])) +static_assert(is_subtype_of(Bottom[Constrained[Any]], Top[Constrained[Any]])) +static_assert(not is_subtype_of(Constrained[Any], Bottom[Constrained[Any]])) +static_assert(not is_subtype_of(Top[Constrained[Any]], Constrained[Any])) +static_assert(not is_subtype_of(Top[Constrained[Any]], Bottom[Constrained[Any]])) +static_assert(not is_subtype_of(Constrained[str], Top[Constrained[bytes]])) +``` + +A bound also limits the parameter type of a contravariant protocol. The same structural relations +hold between distinct protocols with this method: + +```py +class Writer[T: str](Protocol): + def write(self, value: T) -> None: ... + +class EquivalentWriter[T: str](Protocol): + def write(self, value: T) -> None: ... + +static_assert(is_subtype_of(Writer[Any], Top[EquivalentWriter[Any]])) +static_assert(is_subtype_of(Bottom[Writer[Any]], EquivalentWriter[Any])) +static_assert(is_subtype_of(Bottom[Writer[Any]], Top[EquivalentWriter[Any]])) +static_assert(not is_subtype_of(Writer[Any], Bottom[EquivalentWriter[Any]])) +static_assert(not is_subtype_of(Top[Writer[Any]], EquivalentWriter[Any])) +static_assert(not is_subtype_of(Top[Writer[Any]], Bottom[EquivalentWriter[Any]])) +``` + +Constraints also limit both the read and write types of a mutable attribute. These protocols are +invariant, and their structural relations still respect the materialization directions: + +```py +class Cell[T: (str, bytes)](Protocol): + value: T + +class EquivalentCell[T: (str, bytes)](Protocol): + value: T + +static_assert(is_subtype_of(Cell[Any], Top[EquivalentCell[Any]])) +static_assert(is_subtype_of(Bottom[Cell[Any]], EquivalentCell[Any])) +static_assert(is_subtype_of(Bottom[Cell[Any]], Top[EquivalentCell[Any]])) +static_assert(not is_subtype_of(Cell[Any], Bottom[EquivalentCell[Any]])) +static_assert(not is_subtype_of(Top[Cell[Any]], EquivalentCell[Any])) +static_assert(not is_subtype_of(Top[Cell[Any]], Bottom[EquivalentCell[Any]])) +``` + +If a class inherits from the protocol explicitly, we treat it as a subtype, even if it has invalid +overrides: + +```py +class InvalidOverride(Bounded[str]): + # TODO: this should be an invalid-override error (https://github.com/astral-sh/ty/issues/2156) + read = None + +static_assert(is_subtype_of(InvalidOverride, Bounded[str])) +static_assert(is_subtype_of(InvalidOverride, Top[Bounded[str]])) +static_assert(is_subtype_of(InvalidOverride, Top[Bounded[Any]])) +static_assert(not is_subtype_of(InvalidOverride, Bottom[Bounded[str]])) +static_assert(not is_subtype_of(InvalidOverride, Bottom[Bounded[Any]])) +static_assert(not is_subtype_of(InvalidOverride, Top[EquivalentBounded[Any]])) +``` + ## Callable The general principle is that a callable type is a subtype of another if it's more flexible in what @@ -1426,6 +1546,21 @@ static_assert(is_subtype_of(StringSuffix, Callable[[*tuple[object, ...], int, st static_assert(is_subtype_of(IntegerStringSuffix, Callable[[*tuple[int, ...], int, str], None])) ``` +A named positional prefix cannot make a callback with a required unpacked suffix compatible with a +target that accepts no positional arguments. + +```py +def named_prefix_and_suffix(name: int, *args: *tuple[*tuple[int, ...], int]) -> None: ... +def accepts_no_positional_arguments() -> None: ... + +static_assert( + not is_subtype_of( + RegularCallableTypeOf[named_prefix_and_suffix], + RegularCallableTypeOf[accepts_no_positional_arguments], + ) +) +``` + A positional parameter cannot also be filled by a target keyword argument. ```py @@ -1450,6 +1585,71 @@ static_assert(is_subtype_of(OccupiesKeyword, RegularCallableTypeOf[rejects_keywo static_assert(is_subtype_of(OccupiesKeyword, RegularCallableTypeOf[rejects_named_keyword])) ``` +Variadic target keywords must be compatible with optional source keyword-only parameters unless an +occupied target prefix prevents the corresponding name from being passed. + +```py +def optional_keyword_source(*args: object, flag: int = 0, **kwargs: str) -> None: ... +def unprotected_keyword_target(*args: *tuple[*tuple[int, ...], int], **kwargs: str) -> None: ... +def protected_keyword_target(flag: int, *args: *tuple[*tuple[int, ...], int], **kwargs: str) -> None: ... + +static_assert( + not is_subtype_of( + RegularCallableTypeOf[optional_keyword_source], + RegularCallableTypeOf[unprotected_keyword_target], + ) +) +static_assert( + is_subtype_of( + RegularCallableTypeOf[optional_keyword_source], + RegularCallableTypeOf[protected_keyword_target], + ) +) +``` + +Passing a target argument positionally cannot satisfy a required source keyword-only parameter. A +required target keyword-only parameter with the same name does satisfy it. + +```py +def requires_named_keyword(*args: int, a: int) -> None: ... +def positional_keyword_target(a: int, *args: *tuple[*tuple[int, ...], int]) -> None: ... +def required_keyword_target(*args: *tuple[*tuple[int, ...], int], a: int) -> None: ... + +static_assert( + not is_subtype_of( + RegularCallableTypeOf[requires_named_keyword], + RegularCallableTypeOf[positional_keyword_target], + ) +) +static_assert( + is_subtype_of( + RegularCallableTypeOf[requires_named_keyword], + RegularCallableTypeOf[required_keyword_target], + ) +) +``` + +The same mismatch produces an assignment diagnostic when the target signature is used as an +annotation. + +```py +type PositionalKeywordTarget = RegularCallableTypeOf[positional_keyword_target] + +# error: [invalid-assignment] +callback: PositionalKeywordTarget = requires_named_keyword +``` + +A positional-only target prefix cannot prevent variadic keywords from colliding with an occupied +source parameter. A matching positional-or-keyword target prefix does prevent that collision. + +```py +def unprotected_positional_prefix(a: int, /, *args: *tuple[*tuple[int, ...], int], **kwargs: int) -> None: ... +def protected_positional_prefix(a: int, *args: *tuple[*tuple[int, ...], int], **kwargs: int) -> None: ... + +static_assert(not is_subtype_of(OccupiesKeyword, RegularCallableTypeOf[unprotected_positional_prefix])) +static_assert(is_subtype_of(OccupiesKeyword, RegularCallableTypeOf[protected_positional_prefix])) +``` + Equivalent empty or fixed-length unpacked parameters are compatible, but cannot be reused for additional positional arguments. @@ -2332,6 +2532,55 @@ static_assert(not is_subtype_of(TypeOf[A.g], Callable[[], int])) static_assert(is_subtype_of(TypeOf[A.f], Callable[[A, int], int])) ``` +### Bound receivers and `Self` + +A bound method exposes its captured receiver through the read-only `__self__` attribute. A method +bound to a subclass can therefore be a subtype of the same method bound to its base class, but not +the reverse. A `Self` return type follows the same direction. + +```py +from typing import Self +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_subtype_of + +class Base: + def plain(self) -> int: + return 0 + + def returns_self(self) -> Self: + return self + + def accepts_self(self, other: Self) -> None: ... + @classmethod + def make(cls) -> Self: + return cls() + +class Child(Base): ... + +def check(base: Base, child: Child): + static_assert(is_subtype_of(TypeOf[child.plain], TypeOf[base.plain])) + static_assert(not is_subtype_of(TypeOf[base.plain], TypeOf[child.plain])) + static_assert(is_subtype_of(TypeOf[child.returns_self], TypeOf[base.returns_self])) + static_assert(not is_subtype_of(TypeOf[base.returns_self], TypeOf[child.returns_self])) +``` + +`Self` in a remaining parameter is contravariant: a method that requires a `Child` cannot replace +one that accepts any `Base`. The reverse substitution still fails the captured-receiver requirement. + +```py +def check_parameters(base: Base, child: Child): + static_assert(not is_subtype_of(TypeOf[child.accepts_self], TypeOf[base.accepts_self])) + static_assert(not is_subtype_of(TypeOf[base.accepts_self], TypeOf[child.accepts_self])) +``` + +Classmethods capture the class object, while `Self` in their return type describes an instance. + +```py +def check_classmethods(base: Base, child: Child): + static_assert(is_subtype_of(TypeOf[child.make], TypeOf[base.make])) + static_assert(not is_subtype_of(TypeOf[base.make], TypeOf[child.make])) +``` + ### Overloads #### Subtype overloaded diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md index 5d8a7960de..a37a142ba8 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md @@ -169,7 +169,7 @@ type C2 = Callable[[int, tuple[int | Any]], tuple[Any]] def _(top: Top[C2], bottom: Bottom[C2]) -> None: reveal_type(top) # revealed: (int, tuple[int], /) -> tuple[object] - reveal_type(bottom) # revealed: (int, tuple[object], /) -> Never + reveal_type(bottom) # revealed: (int, tuple[object], /) -> tuple[Never] ``` But, if the callable itself is in a contravariant position, then the variance is flipped i.e., if @@ -279,6 +279,37 @@ def takes_objects(*args: object, **kwargs: object) -> object: static_assert(not is_subtype_of(TopCallable, RegularCallableTypeOf[takes_objects])) ``` +## `ParamSpec` specializations + +For a class invariant in a `ParamSpec`, every fixed specialization lies between the bottom and top +materializations of its `...` specialization. This holds for both subtyping and assignability. The +reverse relations do not hold for an arbitrary fixed specialization. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Callable +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class Box[**P]: + callback: Callable[P, None] + +def _[**P](): + static_assert(is_subtype_of(Box[P], Top[Box[...]])) + static_assert(is_subtype_of(Bottom[Box[...]], Box[P])) + static_assert(not is_subtype_of(Top[Box[...]], Box[P])) + static_assert(not is_subtype_of(Box[P], Bottom[Box[...]])) + + static_assert(is_assignable_to(Box[P], Top[Box[...]])) + static_assert(is_assignable_to(Bottom[Box[...]], Box[P])) + static_assert(not is_assignable_to(Top[Box[...]], Box[P])) + static_assert(not is_assignable_to(Box[P], Bottom[Box[...]])) +``` + ## Tuple All positions in a tuple are covariant. @@ -294,13 +325,13 @@ from ty_extensions import Bottom, Top, static_assert from ty_extensions._internal import Unknown, is_equivalent_to static_assert(is_equivalent_to(Top[tuple[Any, int]], tuple[object, int])) -static_assert(is_equivalent_to(Bottom[tuple[Any, int]], Never)) +static_assert(is_equivalent_to(Bottom[tuple[Any, int]], tuple[Never, int])) static_assert(is_equivalent_to(Top[tuple[Unknown, int]], tuple[object, int])) -static_assert(is_equivalent_to(Bottom[tuple[Unknown, int]], Never)) +static_assert(is_equivalent_to(Bottom[tuple[Unknown, int]], tuple[Never, int])) static_assert(is_equivalent_to(Top[tuple[Any, int, Unknown]], tuple[object, int, object])) -static_assert(is_equivalent_to(Bottom[tuple[Any, int, Unknown]], Never)) +static_assert(is_equivalent_to(Bottom[tuple[Any, int, Unknown]], tuple[Never, int, Never])) ``` Except for when the tuple itself is in a contravariant position, then all positions in the tuple @@ -313,7 +344,7 @@ from ty_extensions._internal import TypeOf type C = Callable[[tuple[Any, int], tuple[str, Unknown]], None] def _(top: Top[C], bottom: Bottom[C]) -> None: - reveal_type(top) # revealed: (Never, Never, /) -> None + reveal_type(top) # revealed: (tuple[Never, int], tuple[str, Never], /) -> None reveal_type(bottom) # revealed: (tuple[object, int], tuple[str, object], /) -> None ``` @@ -342,6 +373,197 @@ def _( reveal_type(bottom_aiu) # revealed: Bottom[list[tuple[Any, int, Unknown]]] ``` +## Gradual tuple length in invariant positions + +An unrestricted variable-length tuple with dynamic elements can materialize to an empty tuple. The +top materialization of an enclosing invariant generic includes that specialization, and its bottom +materialization is a subtype of it. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import Any, TypeVarTuple +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_disjoint_from, is_subtype_of + +Ts = TypeVarTuple("Ts") + +static_assert(is_subtype_of(list[tuple[()]], Top[list[tuple[Any, ...]]])) +static_assert(not is_disjoint_from(list[tuple[()]], Top[list[tuple[Any, ...]]])) +static_assert(not is_disjoint_from(Top[list[tuple[Any, ...]]], list[tuple[()]])) +static_assert(is_subtype_of(Bottom[list[tuple[Any, ...]]], list[tuple[()]])) +static_assert(not is_subtype_of(list[tuple[()]], Bottom[list[tuple[Any, ...]]])) +``` + +The same gradual-length choice also includes nonempty, fixed-length tuple specializations. + +```py +static_assert(is_subtype_of(list[tuple[int]], Top[list[tuple[Any, ...]]])) +static_assert(not is_disjoint_from(list[tuple[int]], Top[list[tuple[Any, ...]]])) +static_assert(not is_disjoint_from(Top[list[tuple[Any, ...]]], list[tuple[int]])) +static_assert(is_subtype_of(Bottom[list[tuple[Any, ...]]], list[tuple[int]])) +static_assert(not is_subtype_of(list[tuple[int]], Bottom[list[tuple[Any, ...]]])) +``` + +An exact tuple remains a valid materialization when its element is spelled through a type alias. + +```py +from typing_extensions import TypeAliasType + +ItemAlias = TypeAliasType("ItemAlias", int) + +static_assert(is_subtype_of(list[tuple[ItemAlias]], Top[list[tuple[Any, ...]]])) +``` + +Aliases around the entire tuple still need to be resolved before comparing the materialization +families. + +```py +FixedTupleAlias = TypeAliasType("FixedTupleAlias", tuple[int]) + +# TODO: Resolve aliases around the entire tuple in invariant subtyping. +static_assert(is_subtype_of(list[FixedTupleAlias], Top[list[tuple[Any, ...]]])) # error: [static-assert-error] +``` + +A gradual fixed-length tuple has a narrower materialization range than an unrestricted gradual +tuple. Their top bounds preserve that containment, while their bottom bounds reverse it. + +```py +static_assert(is_subtype_of(Top[list[tuple[Any]]], Top[list[tuple[Any, ...]]])) +static_assert(not is_subtype_of(Top[list[tuple[Any, ...]]], Top[list[tuple[Any]]])) +static_assert(is_subtype_of(Bottom[list[tuple[Any, ...]]], Bottom[list[tuple[Any]]])) +static_assert(not is_subtype_of(Bottom[list[tuple[Any]]], Bottom[list[tuple[Any, ...]]])) +static_assert(not is_subtype_of(Top[list[tuple[Any, ...]]], Bottom[list[tuple[Any, ...]]])) +``` + +An unpacked type variable tuple is another valid exact-tuple specialization, even though its length +is not known. + +```py +def symbolic(value: list[tuple[*Ts]]) -> None: + static_assert(is_subtype_of(list[tuple[*Ts]], Top[list[tuple[Any, ...]]])) + static_assert(not is_disjoint_from(list[tuple[*Ts]], Top[list[tuple[Any, ...]]])) + static_assert(not is_disjoint_from(Top[list[tuple[Any, ...]]], list[tuple[*Ts]])) + static_assert(is_subtype_of(Bottom[list[tuple[Any, ...]]], list[tuple[*Ts]])) + static_assert(not is_subtype_of(list[tuple[*Ts]], Bottom[list[tuple[Any, ...]]])) +``` + +A tuple subclass is not itself a materialization of the exact built-in `tuple[Any, ...]` type. The +surrounding invariant generic must therefore keep the subclass distinct. + +```py +class IntTuple(tuple[int]): ... + +static_assert(is_subtype_of(IntTuple, tuple[object, ...])) +static_assert(not is_subtype_of(list[IntTuple], Top[list[tuple[Any, ...]]])) +static_assert(is_disjoint_from(list[IntTuple], Top[list[tuple[Any, ...]]])) +static_assert(is_disjoint_from(Top[list[tuple[Any, ...]]], list[IntTuple])) +static_assert(not is_subtype_of(Bottom[list[tuple[Any, ...]]], list[IntTuple])) +``` + +A static homogeneous tuple does not make a gradual choice of length, even though every `int` is an +`object`. + +```py +static_assert(not is_subtype_of(list[tuple[int]], Top[list[tuple[object, ...]]])) +static_assert(is_disjoint_from(list[tuple[int]], Top[list[tuple[object, ...]]])) +``` + +Fixed elements in a mixed tuple must also retain their invariant identity. In particular, a `bool` +prefix cannot replace the required `int` prefix simply because `bool` is a subtype of `int`. + +```py +static_assert(not is_subtype_of(list[tuple[bool, str]], Top[list[tuple[int, *tuple[Any, ...]]]])) +static_assert(is_disjoint_from(list[tuple[bool, str]], Top[list[tuple[int, *tuple[Any, ...]]]])) +``` + +A required suffix retains its invariant identity even when there is no required prefix. A `bool` +cannot replace the required `int` suffix. + +```py +static_assert(not is_subtype_of(list[tuple[bool]], Top[list[tuple[*tuple[Any, ...], int]]])) +static_assert(is_disjoint_from(list[tuple[bool]], Top[list[tuple[*tuple[Any, ...], int]]])) +``` + +A tuple with a required suffix cannot materialize to an empty tuple. + +```py +static_assert(not is_subtype_of(list[tuple[()]], Top[list[tuple[*tuple[Any, ...], int]]])) +static_assert(is_disjoint_from(list[tuple[()]], Top[list[tuple[*tuple[Any, ...], int]]])) +``` + +TODO: Handle valid mixed gradual-length tuples without losing their required prefix and suffix +elements. For example, `list[tuple[int, str]]` should be a subtype of +`Top[list[tuple[int, *tuple[Any, ...]]]]`. + +## Gradual tuple length with aliased elements + +When `Dynamic` aliases `Any`, `tuple[Dynamic, ...]` has the same gradual-length choices as +`tuple[Any, ...]`. These choices are preserved when the tuple is an invariant type argument. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Any, TypeAliasType +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_disjoint_from, is_subtype_of + +type Dynamic = Any + +static_assert(is_subtype_of(list[tuple[int]], Top[list[tuple[Dynamic, ...]]])) +static_assert(is_subtype_of(Bottom[list[tuple[Dynamic, ...]]], list[tuple[int]])) +static_assert(not is_disjoint_from(list[tuple[int]], Top[list[tuple[Dynamic, ...]]])) +static_assert(not is_disjoint_from(Top[list[tuple[Dynamic, ...]]], list[tuple[int]])) +``` + +Alias chains, nested generic aliases, and aliases created with `TypeAliasType` preserve the same +gradual-length choice. + +```py +type IndirectDynamic = Dynamic +type Identity[T] = T + +CalledDynamic = TypeAliasType("CalledDynamic", Any) + +static_assert(is_subtype_of(list[tuple[int]], Top[list[tuple[IndirectDynamic, ...]]])) +static_assert(is_subtype_of(list[tuple[int]], Top[list[tuple[Identity[Identity[Any]], ...]]])) +static_assert(is_subtype_of(list[tuple[int]], Top[list[tuple[CalledDynamic, ...]]])) +static_assert(is_subtype_of(Bottom[list[tuple[CalledDynamic, ...]]], list[tuple[int]])) +``` + +An alias of a fully static element type does not make the tuple's length gradual, even with an +unused dynamic type argument. Likewise, `Any` within a container or union does not make the alias +itself dynamic. + +```py +type Constant[T] = object +type Container = list[Any] +type Recursive = list[Recursive] | Any + +static_assert(not is_subtype_of(list[tuple[int]], Top[list[tuple[Constant[Any], ...]]])) +static_assert(is_disjoint_from(list[tuple[int]], Top[list[tuple[Constant[Any], ...]]])) +static_assert(not is_subtype_of(list[tuple[int]], Top[list[tuple[Container, ...]]])) +static_assert(not is_subtype_of(list[tuple[int]], Top[list[tuple[Recursive, ...]]])) +``` + +An alias cycle does not establish that the element is dynamic. Comparing these tuple specializations +terminates without treating either alias as `Any`. + +```py +Loop = TypeAliasType("Loop", "Loop") # error: [cyclic-type-alias-definition] +First = TypeAliasType("First", "Second") # error: [cyclic-type-alias-definition] +Second = TypeAliasType("Second", First) # error: [cyclic-type-alias-definition] + +static_assert(is_disjoint_from(list[tuple[int]], list[tuple[Loop, ...]])) +static_assert(is_disjoint_from(list[tuple[int]], list[tuple[First, ...]])) +``` + ## Union All positions in a union are covariant. @@ -469,9 +691,9 @@ from ty_extensions._internal import Unknown, is_equivalent_to static_assert(is_equivalent_to(Top[~Any], object)) static_assert(is_equivalent_to(Bottom[~Any], Never)) -# tuple[Any, int] is in a contravariant position, so the -# top materialization is Never and the negation of it -static_assert(is_equivalent_to(Top[~tuple[Any, int]], object)) +# tuple[Any, int] is in a contravariant position, so its top +# materialization negates the tuple's bottom materialization. +static_assert(is_equivalent_to(Top[~tuple[Any, int]], ~tuple[Never, int])) static_assert(is_equivalent_to(Bottom[~tuple[Any, int]], ~tuple[object, int])) ``` @@ -1200,6 +1422,11 @@ def generic_recursive_materialization(value: Top[Covariant[GenericRecursive[int] ## Subtyping +```toml +[environment] +python-version = "3.12" +``` + Any `list[T]` is a subtype of `Top[list[Any]]`, but with more restrictive gradual types, not all other specializations are subtypes. @@ -1272,6 +1499,24 @@ static_assert(not is_subtype_of(Bottom[list[int | Any]], Bottom[list[bool | Any] static_assert(not is_subtype_of(Bottom[list[int | Any]], Bottom[list[Any]])) ``` +An unresolved type variable does not necessarily satisfy a materialization's bounds. Conversely, +`Top[list[Unknown]]` includes specializations that do not match an arbitrary fixed `T`. + +```pyi +from ty_extensions._internal import Unknown + +def unresolved[T](): + static_assert(not is_subtype_of(list[T], Top[list[int & Any]])) + static_assert(not is_subtype_of(Top[list[Unknown]], list[T])) +``` + +A declared upper bound on `T` can make this relation true: + +```pyi +def bounded[T: int](): + static_assert(is_subtype_of(list[T], Top[list[int & Any]])) +``` + ## Assignability ### General @@ -2007,7 +2252,25 @@ def materialized_inference(inherited: Top[InheritedInferenceAny]) -> None: def materialized_structural_inference(structural: Top[StructuralInferenceAny]) -> None: reveal_type(infer_item(structural)) # revealed: object +``` + +A top-materialized structural protocol nested inside a contravariant class supplies an upper bound +without widening a narrower argument. + +```py +class Contravariant[T]: + def put(self, value: T) -> None: ... + +def infer_contravariant_item[T](container: Contravariant[InferenceBase[T]], value: T) -> T: + return value + +def nested_inference(container: Contravariant[Top[StructuralInferenceAny]], value: bool) -> None: + reveal_type(infer_contravariant_item(container, value)) # revealed: bool +``` +Bounds and constraints still reject a materialized `object` property when its type is incompatible. + +```py def bounded_item[T: str](value: InferenceBase[T]) -> T: raise NotImplementedError @@ -2156,6 +2419,47 @@ def recursive_materialized_inference( reveal_type(infer_recursive_value(bottom)) # revealed: Never ``` +A materialized recursive protocol nested inside a contravariant class contributes an upper bound +without expanding its recursive property. + +```py +class Contravariant[T]: + def put(self, value: T) -> None: ... + +def infer_contravariant_value[T](container: Contravariant[RecursiveValue[T]], value: T) -> T: + return value + +def nested_recursive_inference(container: Contravariant[Top[RecursiveAny]], value: bool) -> None: + reveal_type(infer_contravariant_value(container, value)) # revealed: bool +``` + +When a materialized protocol has no nonrecursive members, inference must defer to a separate +argument rather than expand its recursive requirement or reject the call. + +```py +class RecursiveOnlyTarget[T](Protocol): + @property + def child(self) -> RecursiveOnlyTarget[T]: ... + +class RecursiveOnlySource[T](Protocol): + @property + def child(self) -> RecursiveOnlySource[T]: ... + +def infer_recursive_only[T](value: RecursiveOnlyTarget[T], witness: T) -> T: + return witness + +def infer_contravariant_recursive_only[T](value: Contravariant[RecursiveOnlyTarget[T]], witness: T) -> T: + return witness + +def no_finite_recursive_members( + top: Top[RecursiveOnlySource[Any]], + contravariant: Contravariant[Top[RecursiveOnlySource[Any]]], + witness: bool, +) -> None: + reveal_type(infer_recursive_only(top, witness)) # revealed: bool + reveal_type(infer_contravariant_recursive_only(contravariant, witness)) # revealed: bool +``` + The nonrecursive property is used only to infer the specialization. The complete protocol must still be checked, so a matching `value` cannot hide an incompatible `child`. @@ -2240,6 +2544,93 @@ def recursive_materialized_overload_resolution( reveal_type(select_specific_recursive_value(valid)) # revealed: Literal["str"] ``` +### Generic inference through materialized recursive protocol specializations + +A recursive requirement can provide the only evidence for one of a materialized protocol's type +parameters. The finite `first` property determines `First`, but inference still needs +`recursive_second` to determine `Second`. Both materialization polarities preserve that information. + +```py +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol +from ty_extensions import Bottom, Top + +class Pair[First, Second](Protocol): + marker: Any + + @property + def first(self) -> First: ... + def recursive_second(self, child: Pair[Any, Any]) -> Second: ... + +def infer_second[Second](value: Top[Pair[int, Second]]) -> Second: + raise NotImplementedError + +def infer_bottom_second[Second](value: Bottom[Pair[int, Second]]) -> Second: + raise NotImplementedError + +def check(top: Top[Pair[int, str]], bottom: Bottom[Pair[int, str]]) -> None: + reveal_type(infer_second(top)) # revealed: str + reveal_type(infer_bottom_second(bottom)) # revealed: str +``` + +A type alias around the parameter does not remove the recursive member's contribution. + +```py +type Identity[T] = T + +def infer_aliased_second[Second](value: Top[Pair[int, Identity[Second]]]) -> Second: + raise NotImplementedError + +def aliased(top: Top[Pair[int, str]]) -> None: + reveal_type(infer_aliased_second(top)) # revealed: str +``` + +Comparing callable parameters reverses the protocol comparison: `Pair[int, Second]` becomes the +source type. The recursive method supplies the upper bound `object`, so `Second` is inferred as +`object` even though the target specialization has no type variables. + +```py +def infer_source_second[Second](callback: Callable[[Top[Pair[int, Second]]], None]) -> Second: + raise NotImplementedError + +def accept_pair(value: Top[Pair[int, object]]) -> None: ... + +reveal_type(infer_source_second(accept_pair)) # revealed: object +``` + +The source's `first` property can itself contain `Pair` while the target's `first` property is +finite. That source member remains available to establish the valid covariant widening. + +```py +def widen(source: Top[Pair[Pair[int, str], str]]) -> None: + widened: Top[Pair[object, str]] = source +``` + +### Opposite materializations of recursive protocols + +Materialization can change a fixed `Any` inside a recursive method independently of the protocol's +type parameter. A top-materialized method returning `object` cannot satisfy the bottom-materialized +requirement to return `Never`, even when the finite `value` property is compatible. + +```py +from __future__ import annotations + +from typing import Any, Protocol +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_assignable_to + +class RecursiveValue[T](Protocol): + @property + def value(self) -> T: ... + def consume(self, child: RecursiveValue[Any]) -> Any: ... + +static_assert(not is_assignable_to(Top[RecursiveValue[str]], Bottom[RecursiveValue[object]])) +static_assert(is_assignable_to(Bottom[RecursiveValue[str]], Top[RecursiveValue[object]])) +static_assert(is_assignable_to(Top[RecursiveValue[str]], RecursiveValue[object])) +``` + ### Generator delegation `yield from` uses the same materialized yield and return types as direct generator methods. Applying @@ -2310,7 +2701,7 @@ A legacy type variable in the protocol's type arguments still makes the enclosin from typing import Any, Protocol, TypeVar from ty_extensions import Top -T = TypeVar("T") +T = TypeVar("T", covariant=True) class LegacyProtocol(Protocol[T]): value: Any @@ -2463,6 +2854,54 @@ def recursive_nested_materialization( reveal_type(nested_bottom.marker) # revealed: Never ``` +### Recursive protocols with stable specializations + +These specializations have identical property types: both `value` properties return `str | int`, and +both children have type `Recursive[str | int]`. Materializing either side leaves these fully static +requirements unchanged, including when the materialization directions are opposite. + +```py +from __future__ import annotations + +from typing import Protocol +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import is_assignable_to + +class Recursive[T](Protocol): + @property + def value(self) -> T | int: ... + @property + def child(self) -> Recursive[T | int]: ... + +static_assert(is_assignable_to(Top[Recursive[str | int]], Recursive[str])) +static_assert(is_assignable_to(Bottom[Recursive[str | int]], Recursive[str])) +static_assert(is_assignable_to(Recursive[str | int], Top[Recursive[str]])) +static_assert(is_assignable_to(Recursive[str | int], Bottom[Recursive[str]])) +static_assert(is_assignable_to(Top[Recursive[str | int]], Bottom[Recursive[str]])) +``` + +### Recursive protocols with growing specializations + +Matching outer properties do not establish compatibility when a recursive child changes the +requirements. Here, the children expose `list[str | int] | int` and `list[str] | int`, which are +incompatible because `list` is invariant. + +```py +from __future__ import annotations + +from typing import Protocol +from ty_extensions import Top, static_assert +from ty_extensions._internal import is_assignable_to + +class Growing[T](Protocol): + @property + def value(self) -> T | int: ... + @property + def child(self) -> Growing[list[T]]: ... + +static_assert(not is_assignable_to(Top[Growing[str | int]], Growing[str])) +``` + ### Display Materialized protocols display `Top` and `Bottom` around the protocol class: diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/satisfied_by_all_typevars.md b/crates/ty_python_semantic/resources/mdtest/type_properties/satisfied_by_all_typevars.md deleted file mode 100644 index 1c32e94e33..0000000000 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/satisfied_by_all_typevars.md +++ /dev/null @@ -1,485 +0,0 @@ -# Constraint set satisfaction - -```toml -[environment] -python-version = "3.12" -``` - -Constraint sets exist to help us check assignability and subtyping of types in the presence of -typevars. We construct a constraint set describing the conditions under which assignability holds -between the two types. Then we check whether that constraint set is satisfied for the valid -specializations of the relevant typevars. This file tests that final step. - -## Inferable vs non-inferable typevars - -Typevars can appear in _inferable_ or _non-inferable_ positions. - -When a typevar is in an inferable position, the constraint set only needs to be satisfied for _some_ -valid specialization. The most common inferable position occurs when invoking a generic function: -all of the function's typevars are inferable, because we want to use the argument types to infer -which specialization is being invoked. - -When a typevar is in a non-inferable position, the constraint set must be satisfied for _every_ -valid specialization. The most common non-inferable position occurs in the body of a generic -function or class: here we don't know in advance what type the typevar will be specialized to, and -so we have to ensure that the body is valid for all possible specializations. - -```py -def f[T](t: T) -> T: - # In the function body, T is non-inferable. All assignability checks involving T must be - # satisfied for _all_ valid specializations of T. - return t - -# When invoking the function, T is inferable — we attempt to infer a specialization that is valid -# for the particular arguments that are passed to the function. Assignability checks (in particular, -# that the argument type is assignable to the parameter type) only need to succeed for _at least -# one_ specialization. -f(1) -``` - -In all of the examples below, for ease of reproducibility, we explicitly list the typevars that are -inferable in each `satisfied_by_all_typevars` call; any typevar not listed is assumed to be -non-inferable. - -## Unbounded typevar - -If a typevar has no bound or constraints, then it can specialize to any type. In an inferable -position, that means we just need a single type (any type at all!) that satisfies the constraint -set. In a non-inferable position, that means the constraint set must be satisfied for every possible -type. - -```py -from typing import final -from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet - -class Super: ... -class Base(Super): ... -class Sub(Base): ... - -@final -class Unrelated: ... - -def unbounded[T](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # (T = Never) is a valid specialization, which satisfies (T ≤ Unrelated). - static_assert(ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T ≤ Unrelated). - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Unrelated) is a valid specialization, which does not satisfy (T ≤ Super). - static_assert(not ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Unrelated) is a valid specialization, which does not satisfy (T ≤ Base). - static_assert(not ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) - - # (T = Sub) is a valid specialization, which satisfies (T ≤ Sub). - static_assert(ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Unrelated) is a valid specialization, which does not satisfy (T ≤ Sub). - static_assert(not ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars()) -``` - -## Typevar with an upper bound - -If a typevar has an upper bound, then it must specialize to a type that is a subtype of that bound. -For an inferable typevar, that means we need a single type that satisfies both the constraint set -and the upper bound. For a non-inferable typevar, that means the constraint set must be satisfied -for every type that satisfies the upper bound. - -```py -from typing import final, Never -from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet - -class Super: ... -class Base(Super): ... -class Sub(Base): ... - -@final -class Unrelated: ... - -def bounded[T: Base](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars(inferable=tuple[T])) - # Every valid specialization satisfies (T ≤ Base). Since (Base ≤ Super), every valid - # specialization also satisfies (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # Every valid specialization satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) - - # (T = Sub) is a valid specialization, which satisfies (T ≤ Sub). - static_assert(ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T ≤ Sub). - static_assert(not ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars()) - - # (T = Never) is a valid specialization, which satisfies (T ≤ Unrelated). - constraints = ConstraintSet.upper_bound(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T ≤ Unrelated). - static_assert(not constraints.satisfied_by_all_typevars()) - - # Never is the only type that satisfies both (T ≤ Base) and (T ≤ Unrelated). So there is no - # valid specialization that satisfies (T ≤ Unrelated ∧ T ≠ Never). - constraints = constraints & ~ConstraintSet.equality(T, Never) - static_assert(not constraints.satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not constraints.satisfied_by_all_typevars()) -``` - -If the upper bound is a gradual type, we are free to choose any materialization of the upper bound -that makes the test succeed. In non-inferable positions, it is most helpful to choose the bottom -materialization as the upper bound. That is the most restrictive possible choice, which minimizes -the number of valid specializations that must satisfy the constraint set. In inferable positions, -the opposite is true: it is most helpful to choose the top materialization. That is the most -permissive possible choice, which maximizes the number of valid specializations that might satisfy -the constraint set. - -```py -from typing import Any - -def bounded_by_gradual[T: Any](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # If we choose Base as the materialization for the upper bound, then (T = Base) is a valid - # specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # We are free to choose any materialization of the upper bound, and only have to show that the - # constraint set holds for that one materialization. Having chosen one materialization, we then - # have to show that the constraint set holds for all valid specializations of that - # materialization. If we choose Never as the materialization, then all valid specializations - # must satisfy (T ≤ Never). That means there is only one valid specialization, (T = Never), - # which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization, then (T = Unrelated) is a valid specialization, - # which satisfies (T ≤ Unrelated). - constraints = ConstraintSet.upper_bound(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Never as the materialization, then (T = Never) is the only valid specialization, - # which satisfies (T ≤ Unrelated). - static_assert(constraints.satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization, then (T = Unrelated) is a valid specialization, - # which satisfies (T ≤ Unrelated ∧ T ≠ Never). - constraints = constraints & ~ConstraintSet.equality(T, Never) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # There is no upper bound that we can choose to satisfy this constraint set in non-inferable - # position. (T = Never) will be a valid assignment no matter what, and that does not satisfy - # (T ≤ Unrelated ∧ T ≠ Never). - static_assert(not constraints.satisfied_by_all_typevars()) -``` - -When the upper bound is a more complex gradual type, we are still free to choose any materialization -that causes the check to succeed, and we will still choose the bottom materialization in -non-inferable position, and the top materialization in inferable position. The variance of the -typevar does not affect whether there is a materialization we can choose. Below, we test the most -restrictive variance (i.e., invariance), but we get the same results for other variances as well. - -```py -def bounded_by_gradual[T: list[Any]](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # If we choose list[Base] as the materialization of the upper bound, then (T = list[Base]) is a - # valid specialization, which satisfies (T ≤ list[Base]). - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Base as the materialization, then all valid specializations must satisfy - # (T ≤ list[Base]). - # We are free to choose any materialization of the upper bound, and only have to show that the - # constraint set holds for that one materialization. Having chosen one materialization, we then - # have to show that the constraint set holds for all valid specializations of that - # materialization. If we choose list[Base] as the materialization, then all valid specializations - # must satisfy (T ≤ list[Base]), which is exactly the constraint set that we need to satisfy. - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated]). - constraints = ConstraintSet.upper_bound(T, list[Unrelated]) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Unrelated as the materialization, then all valid specializations must satisfy - # (T ≤ list[Unrelated]). - static_assert(constraints.satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never). - constraints = constraints & ~ConstraintSet.equality(T, Never) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # There is no upper bound that we can choose to satisfy this constraint set in non-inferable - # position. (T = Never) will be a valid assignment no matter what, and that does not satisfy - # (T ≤ list[Unrelated] ∧ T ≠ Never). - static_assert(not constraints.satisfied_by_all_typevars()) -``` - -## Constrained typevar - -If a typevar has constraints, then it must specialize to one of those specific types. (Not to a -subtype of one of those types!) For an inferable typevar, that means we need the constraint set to -be satisfied by any one of the constraints. For a non-inferable typevar, that means we need the -constraint set to be satisfied by all of those constraints. - -```py -from typing import final, Never -from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet - -class Super: ... -class Base(Super): ... -class Sub(Base): ... - -@final -class Unrelated: ... - -def constrained[T: (Base, Unrelated)](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # (T = Unrelated) is a valid specialization, which satisfies (T ≤ Unrelated). - static_assert(ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T ≤ Unrelated). - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Unrelated) is a valid specialization, which does not satisfy (T ≤ Super). - static_assert(not ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars()) - - # (T = Base) is a valid specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Unrelated) is a valid specialization, which does not satisfy (T ≤ Base). - static_assert(not ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) - - # Neither (T = Base) nor (T = Unrelated) satisfy (T ≤ Sub). - static_assert(not ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.upper_bound(T, Sub).satisfied_by_all_typevars()) - - # (T = Base) and (T = Unrelated) both satisfy (T ≤ Super ∨ T ≤ Unrelated). - constraints = ConstraintSet.upper_bound(T, Super) | ConstraintSet.upper_bound(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(constraints.satisfied_by_all_typevars()) - - # (T = Base) and (T = Unrelated) both satisfy (T ≤ Base ∨ T ≤ Unrelated). - constraints = ConstraintSet.upper_bound(T, Base) | ConstraintSet.upper_bound(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(constraints.satisfied_by_all_typevars()) - - # (T = Unrelated) is a valid specialization, which satisfies (T ≤ Sub ∨ T ≤ Unrelated). - constraints = ConstraintSet.upper_bound(T, Sub) | ConstraintSet.upper_bound(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T ≤ Sub ∨ T ≤ Unrelated). - static_assert(not constraints.satisfied_by_all_typevars()) - - # (T = Unrelated) is a valid specialization, which satisfies (T = Super ∨ T = Unrelated). - constraints = ConstraintSet.equality(T, Super) | ConstraintSet.equality(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T = Super ∨ T = Unrelated). - static_assert(not constraints.satisfied_by_all_typevars()) - - # (T = Base) and (T = Unrelated) both satisfy (T = Base ∨ T = Unrelated). - constraints = ConstraintSet.equality(T, Base) | ConstraintSet.equality(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(constraints.satisfied_by_all_typevars()) - - # (T = Unrelated) is a valid specialization, which satisfies (T = Sub ∨ T = Unrelated). - constraints = ConstraintSet.equality(T, Sub) | ConstraintSet.equality(T, Unrelated) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # (T = Base) is a valid specialization, which does not satisfy (T = Sub ∨ T = Unrelated). - static_assert(not constraints.satisfied_by_all_typevars()) -``` - -If any of the constraints is a gradual type, we are free to choose any materialization of that -constraint that makes the test succeed. In non-inferable positions, it is most helpful to choose the -bottom materialization as the constraint. That is the most restrictive possible choice, which -minimizes the number of valid specializations that must satisfy the constraint set. In inferable -positions, the opposite is true: it is most helpful to choose the top materialization. That is the -most permissive possible choice, which maximizes the number of valid specializations that might -satisfy the constraint set. - -```py -from typing import Any - -def constrained_by_gradual[T: (Base, Any)](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization of the gradual constraint, then (T = Unrelated) - # is a valid specialization, which satisfies (T ≤ Unrelated). - static_assert(ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = Base) is a valid specialization, which does - # not satisfy (T ≤ Unrelated). - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # If we choose Super as the materialization, then (T = Super) is a valid specialization, which - # satisfies (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Never as the materialization, then (T = Base) and (T = Never) are the only valid - # specializations, both of which satisfy (T ≤ Super). - static_assert(ConstraintSet.upper_bound(T, Super).satisfied_by_all_typevars()) - - # If we choose Base as the materialization, then (T = Base) is a valid specialization, which - # satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Never as the materialization, then (T = Base) and (T = Never) are the only valid - # specializations, both of which satisfy (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) - -def constrained_by_two_gradual[T: (Any, Any)](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # If we choose Unrelated as the materialization of either constraint, then (T = Unrelated) is a - # valid specialization, which satisfies (T ≤ Unrelated). - static_assert(ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Unrelated as the materialization of both constraints, then (T = Unrelated) is the - # only valid specialization, which satisfies (T ≤ Unrelated). - static_assert(ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # If we choose Base as the materialization of either constraint, then (T = Base) is a valid - # specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Never as the materialization of both constraints, then (T = Never) is the only - # valid specialization, which satisfies (T ≤ Base). - static_assert(ConstraintSet.upper_bound(T, Base).satisfied_by_all_typevars()) -``` - -When a constraint is a more complex gradual type, we are still free to choose any materialization -that causes the check to succeed, and we will still choose the bottom materialization in -non-inferable position, and the top materialization in inferable position. The variance of the -typevar does not affect whether there is a materialization we can choose. Below, we test the most -restrictive variance (i.e., invariance), but we get the same results for other variances as well. - -```py -def constrained_by_gradual[T: (list[Base], list[Any])](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # No matter which materialization we choose, every valid specialization will be of the form - # (T = list[X]). Because Unrelated is final, it is disjoint from all lists. There is therefore - # no materialization or specialization that satisfies (T ≤ Unrelated). - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # If we choose list[Super] as the materialization, then (T = list[Super]) is a valid - # specialization, which satisfies (T ≤ list[Super]). - static_assert(ConstraintSet.upper_bound(T, list[Super]).satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Super]). - static_assert(not ConstraintSet.upper_bound(T, list[Super]).satisfied_by_all_typevars()) - - # If we choose list[Base] as the materialization, then (T = list[Base]) is a valid - # specialization, which satisfies (T ≤ list[Base]). - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose list[Base] as the materialization, then all valid specializations must satisfy - # (T ≤ list[Base]). - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars()) - - # If we choose list[Sub] as the materialization, then (T = list[Sub]) is a valid specialization, - # which # satisfies (T ≤ list[Sub]). - static_assert(ConstraintSet.upper_bound(T, list[Sub]).satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Sub]). - static_assert(not ConstraintSet.upper_bound(T, list[Sub]).satisfied_by_all_typevars()) - - # If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated]). - constraints = ConstraintSet.upper_bound(T, list[Unrelated]) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Unrelated]). - static_assert(not constraints.satisfied_by_all_typevars()) - - # If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never). - constraints = constraints & ~ConstraintSet.equality(T, Never) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # There is no materialization that we can choose to satisfy this constraint set in non-inferable - # position. (T = Never) will be a valid assignment no matter what, and that does not satisfy - # (T ≤ list[Unrelated] ∧ T ≠ Never). - static_assert(not constraints.satisfied_by_all_typevars()) - -def constrained_by_two_gradual[T: (list[Any], list[Any])](): - static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(ConstraintSet.always().satisfied_by_all_typevars()) - - static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.never().satisfied_by_all_typevars()) - - # No matter which materialization we choose, every valid specialization will be of the form - # (T = list[X]). Because Unrelated is final, it is disjoint from all lists. There is therefore - # no materialization or specialization that satisfies (T ≤ Unrelated). - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T])) - static_assert(not ConstraintSet.upper_bound(T, Unrelated).satisfied_by_all_typevars()) - - # If we choose list[Super] as the materialization, then (T = list[Super]) is a valid - # specialization, which satisfies (T ≤ list[Super]). - static_assert(ConstraintSet.upper_bound(T, list[Super]).satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Super]). - static_assert(ConstraintSet.upper_bound(T, list[Super]).satisfied_by_all_typevars()) - - # If we choose list[Base] as the materialization, then (T = list[Base]) is a valid - # specialization, which satisfies (T ≤ list[Base]). - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T])) - # If we choose Base as the materialization, then all valid specializations must satisfy - # (T ≤ list[Base]). - static_assert(ConstraintSet.upper_bound(T, list[Base]).satisfied_by_all_typevars()) - - # If we choose list[Sub] as the materialization, then (T = list[Sub]) is a valid specialization, - # which satisfies (T ≤ list[Sub]). - static_assert(ConstraintSet.upper_bound(T, list[Sub]).satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Sub]). - static_assert(ConstraintSet.upper_bound(T, list[Sub]).satisfied_by_all_typevars()) - - # If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated]). - constraints = ConstraintSet.upper_bound(T, list[Unrelated]) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # No matter which materialization we choose, (T = list[Base]) is a valid specialization, which - # does not satisfy (T ≤ list[Unrelated]). - static_assert(constraints.satisfied_by_all_typevars()) - - # If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid - # specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never). - constraints = constraints & ~ConstraintSet.equality(T, Never) - static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T])) - # There is no constraint that we can choose to satisfy this constraint set in non-inferable - # position. (T = Never) will be a valid assignment no matter what, and that does not satisfy - # (T ≤ list[Unrelated] ∧ T ≠ Never). - static_assert(constraints.satisfied_by_all_typevars()) -``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/truthiness.md b/crates/ty_python_semantic/resources/mdtest/type_properties/truthiness.md index d2019720da..831649467b 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/truthiness.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/truthiness.md @@ -134,7 +134,9 @@ def _(path: Path, superclass: super): reveal_type(bool(superclass)) # revealed: bool ``` -### `Callable` types always have ambiguous truthiness +### Callable objects + +Callable objects can define `__bool__`, so `Callable` parameters have ambiguous truthiness. ```py from typing import Any, Callable @@ -144,7 +146,8 @@ def f(x: Callable[..., Any], y: Callable[[int], str]): reveal_type(bool(y)) # revealed: bool ``` -But certain callable objects are known to be always truthy: +But instances of `types.FunctionType` (whether they're defined using a `def` statement or a `lambda` +expression) are always truthy, and this is also true for bound methods: ```py from types import FunctionType @@ -152,6 +155,12 @@ from types import FunctionType class A: def method(self): ... +reveal_type(bool(f)) # revealed: Literal[True] +reveal_type(bool(lambda: False)) # revealed: Literal[True] + +lambda_function = lambda: False +reveal_type(bool(lambda_function)) # revealed: Literal[True] + reveal_type(bool(A().method)) # revealed: Literal[True] reveal_type(bool(f.__get__)) # revealed: Literal[True] reveal_type(bool(FunctionType.__get__)) # revealed: Literal[True] diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md b/crates/ty_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md index d5f289c9d2..038e7a52b8 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md @@ -1,30 +1,25 @@ # Tuples containing `Never` -A heterogeneous `tuple[…]` type that contains `Never` as a type argument simplifies to `Never`. One -way to think about this is the following: in order to construct a tuple, you need to have an object -of every element type. But since there is no object of type `Never`, you cannot construct the tuple. -Such a tuple type is therefore uninhabited and equivalent to `Never`. - -In the language of algebraic data types, a tuple type is a product type and `Never` acts like the -zero element in multiplication, similar to how a Cartesian product with the empty set is the empty -set. +A heterogeneous `tuple[…]` type that contains `Never` remains distinct from `Never`. Tuple types +include user-defined subclasses, so their element types must not be discarded solely because an +ordinary tuple with those elements cannot be constructed. ```py from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to from typing_extensions import Never, NoReturn -static_assert(is_equivalent_to(Never, tuple[Never])) -static_assert(is_equivalent_to(Never, tuple[Never, int])) -static_assert(is_equivalent_to(Never, tuple[int, Never])) -static_assert(is_equivalent_to(Never, tuple[int, Never, str])) -static_assert(is_equivalent_to(Never, tuple[int, tuple[str, Never]])) -static_assert(is_equivalent_to(Never, tuple[tuple[str, Never], int])) +static_assert(not is_equivalent_to(Never, tuple[Never])) +static_assert(not is_equivalent_to(Never, tuple[Never, int])) +static_assert(not is_equivalent_to(Never, tuple[int, Never])) +static_assert(not is_equivalent_to(Never, tuple[int, Never, str])) +static_assert(not is_equivalent_to(Never, tuple[int, tuple[str, Never]])) +static_assert(not is_equivalent_to(Never, tuple[tuple[str, Never], int])) def _(x: tuple[Never], y: tuple[int, Never], z: tuple[Never, int]): - reveal_type(x) # revealed: Never - reveal_type(y) # revealed: Never - reveal_type(z) # revealed: Never + reveal_type(x) # revealed: tuple[Never] + reveal_type(y) # revealed: tuple[int, Never] + reveal_type(z) # revealed: tuple[Never, int] ``` The empty `tuple` is *not* equivalent to `Never`! @@ -33,13 +28,13 @@ The empty `tuple` is *not* equivalent to `Never`! static_assert(not is_equivalent_to(Never, tuple[()])) ``` -`NoReturn` is just a different spelling of `Never`, so the same is true for `NoReturn`: +`NoReturn` is just a different spelling of `Never`, so these tuple types also retain their shape: ```py -static_assert(is_equivalent_to(NoReturn, tuple[NoReturn])) -static_assert(is_equivalent_to(NoReturn, tuple[NoReturn, int])) -static_assert(is_equivalent_to(NoReturn, tuple[int, NoReturn])) -static_assert(is_equivalent_to(NoReturn, tuple[int, NoReturn, str])) -static_assert(is_equivalent_to(NoReturn, tuple[int, tuple[str, NoReturn]])) -static_assert(is_equivalent_to(NoReturn, tuple[tuple[str, NoReturn], int])) +static_assert(not is_equivalent_to(NoReturn, tuple[NoReturn])) +static_assert(not is_equivalent_to(NoReturn, tuple[NoReturn, int])) +static_assert(not is_equivalent_to(NoReturn, tuple[int, NoReturn])) +static_assert(not is_equivalent_to(NoReturn, tuple[int, NoReturn, str])) +static_assert(not is_equivalent_to(NoReturn, tuple[int, tuple[str, NoReturn]])) +static_assert(not is_equivalent_to(NoReturn, tuple[tuple[str, NoReturn], int])) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md index f2013f2115..601872e764 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md @@ -853,10 +853,8 @@ from __future__ import annotations from typing import Final, Protocol, TypeVar -T = TypeVar("T", covariant=True) +T = TypeVar("T") -# `replace` takes a `T`, which a covariant type variable cannot appear in -# error: [invalid-generic-class] class Owned(Protocol[T]): owner: Final[T] @@ -1090,6 +1088,38 @@ class D: # No else: y may be unbound at runtime, but there is still an assignment path ``` +### Assignment in a loop in `__init__` + +An assignment in a loop body provides a value for a `Final` attribute declared in the class body. + +```py +from typing import Final + +class C: + value: Final[int] + + def __init__(self) -> None: + for _ in range(2): + self.value = 1 +``` + +### Rebinding `self` does not initialize `Final` attributes + +Reading a `Final` attribute before a loop and then rebinding `self` does not assign a value to the +attribute. + +```py +from typing import Final + +class C: + value: Final[int] # error: [final-without-value] "read-only symbol `value` is not assigned a value" + + def __init__(self, repeat: bool, replacement: "C") -> None: + self.value + while repeat: + self = replacement +``` + ### Reachable `Final` declaration wins for diagnostics If an earlier `Final` declaration is statically unreachable, diagnostics should be attached to the diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index d97d37322a..2dccf7b331 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -428,6 +428,42 @@ alice["extra"] = True bob["extra"] = True ``` +## Unpacked assignments to `TypedDict` variables + +This is a regression test for a bug in an early implementation of precise annotations for unpacked +assignments. + +When a dictionary literal is assigned directly to a `TypedDict` variable, ty checks the literal +against the variable's type and suppresses the assignment diagnostic to avoid reporting the same +error twice. A dictionary literal inside an unpacked value does not receive that type context, so +this more specific diagnostic is never emitted. + +The early implementation passed the inner dictionary to the duplicate-diagnostic check after +identifying it for the primary annotation. This incorrectly suppressed the assignment diagnostic as +well, causing ty to report no error for an incompatible dictionary. + +```py +from typing import TypedDict + +class Payload(TypedDict): + value: int + +payload: Payload +payload, other = ({"value": "wrong"}, 0) # snapshot: invalid-assignment +``` + +```snapshot +error[invalid-assignment]: Object of type `dict[str, str]` is not assignable to `Payload` + --> src/mdtest_snippet.py:7:19 + | +6 | payload: Payload + | ------- Declared type +7 | payload, other = ({"value": "wrong"}, 0) # snapshot: invalid-assignment + | ------- ^^^^^^^^^^^^^^^^^^ Incompatible value of type `dict[str, str]` + | | + | Assigned to this variable +``` + ## Nested `TypedDict` Nested `TypedDict` fields are also supported. @@ -1080,6 +1116,239 @@ def takes_td_or_iterable(value: TD | Iterable[int]) -> None: takes_td_or_iterable({42: 42}) ``` +## Union of `TypedDict` behind a type alias + +```toml +[environment] +python-version = "3.12" +``` + +A `TypedDict` is still found when the annotation reaches it through a type alias, including when the +alias resolves to a union and is itself one element of a larger union: + +```py +from typing import TypedDict +from typing_extensions import TypeAliasType + +class Person(TypedDict): + name: str + age: int | None + +type PersonAlias = Person +type PersonOrId = Person | int +PersonOrIdAliasType = TypeAliasType("PersonOrIdAliasType", Person | int) + +aliased: PersonAlias = {"name": "Alice", "age": 30} +reveal_type(aliased) # revealed: Person + +aliased_in_union: PersonAlias | str = {"name": "Alice", "age": 30} +reveal_type(aliased_in_union) # revealed: Person + +union_alias: PersonOrId = {"name": "Alice", "age": 30} +reveal_type(union_alias) # revealed: Person + +union_alias_in_union: PersonOrId | str = {"name": "Alice", "age": 30} +reveal_type(union_alias_in_union) # revealed: Person + +alias_type_in_union: PersonOrIdAliasType | str = {"name": "Alice", "age": 30} +reveal_type(alias_type_in_union) # revealed: Person +``` + +A dictionary constructed with keyword arguments uses the same aliased `TypedDict` context: + +```py +constructed: PersonOrId | str = dict(name="Alice", age=30) +reveal_type(constructed) # revealed: Person +``` + +Keys are still validated against the aliased `TypedDict`: + +```py +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +unknown_key: PersonOrId | str = {"name": "Alice", "age": 30, "nickname": "Ali"} +``` + +Expanding can leave a single `TypedDict` rather than a union, when every arm aliases the same one. +Such an annotation is still validated field by field: + +```py +type FirstPerson = Person +type SecondPerson = Person + +collapsed: FirstPerson | SecondPerson = {"name": "Alice", "age": 30} +reveal_type(collapsed) # revealed: Person + +collapsed_constructor: FirstPerson | SecondPerson = dict(name="Alice", age=30) +reveal_type(collapsed_constructor) # revealed: Person + +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +collapsed_unknown_key: FirstPerson | SecondPerson = {"name": "Alice", "age": 30, "nickname": "Ali"} + +collapsed_constructor_unknown_key: FirstPerson | SecondPerson = dict( + name="Alice", + age=30, + # error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" + nickname="Ali", +) +``` + +The same holds where the annotation is a parameter default or a nested field: + +```py +class Team(TypedDict): + lead: FirstPerson | SecondPerson + +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +def hire(person: FirstPerson | SecondPerson = {"name": "Alice", "age": 30, "nickname": "Ali"}): ... + +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +team: Team = {"lead": {"name": "Alice", "age": 30, "nickname": "Ali"}} +``` + +Constructor inference currently ignores compatible non-`TypedDict` union members. This also occurs +without aliases; expansion only exposes the existing limitation: + +```py +# TODO: The `dict[str, str]` fallback should accept this constructor without errors. +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +accepted_by_fallback: PersonOrId | dict[str, str] = dict(other="x") + +# TODO: This should reveal `dict[str, str]`, not `Person`. +reveal_type(accepted_by_fallback) # revealed: Person +``` + +The same limitation applies to arguments, return values, and nested `TypedDict` fields: + +```py +class Roster(TypedDict): + lead: PersonOrId | dict[str, str] + +def takes_fallback(value: PersonOrId | dict[str, str]) -> None: ... + +# TODO: The `dict[str, str]` fallback should accept this argument without errors. +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +takes_fallback(dict(other="x")) + +def returns_fallback() -> PersonOrId | dict[str, str]: + # TODO: The `dict[str, str]` fallback should accept this return without errors. + # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" + # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" + # error: [invalid-key] "Unknown key "other" for TypedDict `Person`" + return dict(other="x") + +# TODO: The `dict[str, str]` fallback should accept this nested value without errors. +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +nested_fallback: Roster = {"lead": dict(other="x")} +``` + +Broader fallback types are also ignored: + +```py +from typing import Any, Mapping + +# TODO: The `Any` fallback should accept this constructor without errors. +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +any_fallback: PersonOrId | Any = dict(other="x") + +# TODO: The `Mapping[str, str]` fallback should accept this constructor without errors. +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +mapping_fallback: PersonOrId | Mapping[str, str] = dict(other="x") +``` + +A constructor with an invalid key is correctly validated when no union member provides a compatible +dictionary fallback, whether the alias appears directly or in a larger union: + +```py +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +no_fallback: PersonOrId = dict(other="x") + +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-key] "Unknown key "other" for TypedDict `Person`" +invalid_constructor: PersonOrId | str = dict(other="x") +``` + +## Overload selection with an aliased `TypedDict` + +```toml +[environment] +python-version = "3.12" +``` + +A dictionary literal selects the matching overload when its `TypedDict` type is nested inside a +union-valued alias: + +```py +from typing import TypedDict, assert_type, overload + +class Payload(TypedDict): + required: int + +type PayloadOrInt = Payload | int + +@overload +def select(value: PayloadOrInt | str) -> str: ... +@overload +def select(value: float) -> bytes: ... +def select(value: object) -> object: + return str(value) + +assert_type(select({"required": 1}), str) +``` + +## `TypedDict` behind a `TypeAliasType` alias on Python 3.11 + +```toml +[environment] +python-version = "3.11" +``` + +Expansion is not tied to the `type` statement. `TypeAliasType` is expanded the same way on versions +that predate it, and the `TypedDict` behind one is still found and validated: + +```py +from typing import TypedDict +from typing_extensions import TypeAliasType + +class Person(TypedDict): + name: str + age: int | None + +PersonOrId = TypeAliasType("PersonOrId", Person | int) + +union_alias_in_union: PersonOrId | str = {"name": "Alice", "age": 30} +reveal_type(union_alias_in_union) # revealed: Person +``` + +Dictionary constructors use the same aliased `TypedDict` context: + +```py +constructed: PersonOrId | str = dict(name="Alice", age=30) +reveal_type(constructed) # revealed: Person +``` + +Both dictionary literals and constructors reject unknown keys: + +```py +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +unknown_key: PersonOrId | str = {"name": "Alice", "age": 30, "nickname": "Ali"} + +# error: [invalid-key] "Unknown key "nickname" for TypedDict `Person`" +invalid_constructor: PersonOrId | str = dict(name="Alice", age=30, nickname="Ali") +``` + ## Type ignore compatibility issues Users should be able to ignore TypedDict validation errors with `# type: ignore` @@ -2119,6 +2388,34 @@ static_assert(not is_subtype_of(LeftRecursiveDict[int], RightRecursiveDict[int]) # A conservative cycle fallback must not accept structurally different recursive TypedDicts. static_assert(not is_subtype_of(LeftRecursiveDict[int], DifferentRecursiveDict[int])) +class ShiftingLeftDict[A, B, C](TypedDict): + value: A + child: ShiftingLeftDict[B, C, None] + +class ShiftingRightDict[A, B, C](TypedDict): + value: A + child: ShiftingRightDict[B, C, None] + +# These recursive specializations reach an exact repetition after shifting out every initial +# argument. +static_assert( + is_subtype_of( + ShiftingLeftDict[int, str, bytes], + ShiftingRightDict[int, str, bytes], + ) +) + +class SaturatingLeftDict[T](TypedDict): + value: T + child: SaturatingLeftDict[T | int] + +class SaturatingRightDict[T](TypedDict): + value: T + child: SaturatingRightDict[T | int] + +# Repeatedly adding the same union element also reaches an exact repetition. +static_assert(is_subtype_of(SaturatingLeftDict[str], SaturatingRightDict[str])) + class FiniteLeftDict[T](TypedDict): value: T @@ -2565,6 +2862,54 @@ def _(u: IntX | StrX) -> None: reveal_type(u.setdefault("x", 1)) # revealed: int | str ``` +## `get()` with literal union defaults + +```toml +[environment] +python-version = "3.12" +``` + +For a non-required field, `get()` returns the union of the field type and the default type. Passing +that result to a typed function preserves all of its possible literal values: + +```py +from typing import Literal, TypedDict +from typing_extensions import assert_type + +Value = Literal[0, 1, 2] + +class OptionalValue(TypedDict, total=False): + value: Value + +def accept(value: Value | None) -> None: ... +def optional_default(mapping: OptionalValue, default: Value | None) -> None: + accept(mapping.get("value", default)) + result: Value | None = mapping.get("value", default) + assert_type(result, Value | None) +``` + +An incompatible default is still reflected in the result and rejected by the typed function: + +```py +def invalid_default(mapping: OptionalValue) -> None: + # error: [invalid-argument-type] + accept(mapping.get("value", "invalid")) +``` + +For a required field, the default cannot contribute to the result. This also holds when the field +type is an explicit type alias: + +```py +type ValueAlias = Value + +class RequiredValue(TypedDict): + value: ValueAlias + +def required_default(mapping: RequiredValue, default: Value | None) -> None: + result: Value | None = mapping.get("value", default) + assert_type(result, Value) +``` + ## Unlike normal classes `TypedDict` types do not act like normal classes. For example, calling `type(..)` on an inhabitant @@ -2600,6 +2945,31 @@ def _(p: Alias) -> None: reveal_type(p.__class__) # revealed: ``` +Truthiness narrowing can give a `TypedDict` value an intersection type, but its runtime class is +still `dict`. + +```py +class OptionalPerson(TypedDict, total=False): + name: str + +def narrowed_class(person: OptionalPerson) -> None: + if person: + reveal_type(type(person)) # revealed: + reveal_type(person.__class__) # revealed: +``` + +Excluding `None` from a type variable's `TypedDict` bound should also identify `dict` as the runtime +class. This is difficult to represent while preserving the type variable: `type[Person]` describes +the `TypedDict` schema constructor, not the runtime `dict` class. + +```py +def exclude_none[T: Person | None](value: T) -> None: + if value is not None: + # TODO: Preserve the runtime class. Intersecting `type[T]` with the exact `dict` + # class is not sufficient: specializing `T` to `Person` makes that intersection `Never`. + reveal_type(type(value)) # revealed: type[T@exclude_none] +``` + Passing a `TypedDict` to `dict()` copies it into a regular dictionary: ```py @@ -2619,7 +2989,8 @@ def mixed(movie_or_int: Movie | int) -> None: dict(movie_or_int) # error: [no-matching-overload] ``` -The same result is inferred efficiently for a union of `TypedDict`s: +The same result is inferred efficiently for a union of `TypedDict`s, including when the result is +checked against a bare `dict`: ```toml [environment] @@ -2658,8 +3029,10 @@ X = TypedDict("X", {"type": Literal["x"]}) Item = A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X +def takes_bare_dict(value: dict[Any, Any]) -> None: ... def _(item: Item) -> None: reveal_type(dict(item)) # revealed: dict[str, object] + takes_bare_dict(dict(item)) # Runtime narrowing preserves each `TypedDict` schema without exposing unrestricted dictionary # operations. The union should still reuse its common protocol constraints. @@ -2820,7 +3193,7 @@ type Left22 = Left21 | Right21 def _(item: Left22) -> None: reveal_type(dict(item)) # revealed: dict[str, object] -type RecursiveItem = A | RecursiveItem +type RecursiveItem = A | RecursiveItem # error: [cyclic-type-alias-definition] def _(item: RecursiveItem) -> None: # The common-constraint check must terminate when an alias refers back to its containing union. @@ -2835,7 +3208,7 @@ from _collections_abc import dict_items from collections.abc import Callable from typing import Protocol, TypeVar, TypedDict, runtime_checkable -ItemsT = TypeVar("ItemsT") +ItemsT = TypeVar("ItemsT", covariant=True) class HasItems(Protocol[ItemsT]): def items(self) -> ItemsT: ... @@ -2946,7 +3319,9 @@ def _(value: AnyExtraItems | OtherAnyExtraItems) -> None: reveal_type(get_bounded_mapping(value)) # revealed: Any ``` -Rejected common-constraint probes must not affect fallback protocol inference: +Rejected common-constraint probes must not affect fallback protocol inference. Both mappings below +contain an `int`, so inference should select the `int` constraint. It currently selects the broader +`object` constraint instead: ```py from typing import Literal, Protocol, TypeVar, TypedDict @@ -2967,7 +3342,8 @@ def get_value(value: GetValue[ConstrainedValue]) -> ConstrainedValue: def takes_str(value: str) -> None: ... def _(value: ValueA | ValueB) -> None: - reveal_type(get_value(value)) # revealed: int + # TODO: revealed int + reveal_type(get_value(value)) # revealed: object takes_str(get_value(value)) # error: [invalid-argument-type] ``` @@ -2976,7 +3352,7 @@ Common constraints must preserve correlations in mutable protocols: ```py from typing import Any, Protocol, TypeVar, TypedDict -Key = TypeVar("Key") +Key = TypeVar("Key", contravariant=True) Value = TypeVar("Value") class SetAndGet(Protocol[Key, Value]): @@ -3534,6 +3910,102 @@ static_assert(is_assignable_to(Items[Any], Items[int])) static_assert(not is_subtype_of(Items[Any], Items[int])) ``` +### Inherited methods + +Methods on a generic `TypedDict` subclass use the subclass's type arguments when checking the +receiver. Methods that return `Self`, such as `copy()`, preserve the subclass and its +specialization. + +```py +from typing import Generic, TypeVar, TypedDict + +T = TypeVar("T") + +class Base(TypedDict, Generic[T]): + value: T + +class Child(Base[T]): ... + +def methods(child: Child[int]) -> None: + reveal_type(child.keys()) # revealed: dict_keys[str, object] + reveal_type(child.items()) # revealed: dict_items[str, object] + reveal_type(child.values()) # revealed: dict_values[str, object] + reveal_type(child.copy()) # revealed: Child[int] +``` + +Accessing a method through the specialized class also specializes its field types, while still +rejecting incompatible updates. + +```py +def unbound_methods(child: Child[int]) -> None: + reveal_type(Child[int].get(child, "value")) # revealed: int + Child[int].update(child, value=1) + Child[int].update(child, value="wrong") # error: [invalid-argument-type] +``` + +A specialized `TypedDict` subclass can also be unpacked into a call or a dictionary. Calls still +check the inherited field's specialized type against the parameter type. + +```py +def takes_int(value: int) -> None: ... +def unpack(child: Child[int], wrong: Child[str]) -> None: + takes_int(**child) + unpacked = {**child} + takes_int(**wrong) # error: [invalid-argument-type] +``` + +### Inherited methods with type parameter defaults + +An explicit specialization overrides a type parameter's default, including for inherited methods. An +unbound method accessed through the unsubscripted class uses the default when checking its receiver. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import TypedDict + +class Base[T = int](TypedDict): + value: T + +class Child[T = int](Base[T]): ... + +def methods(base: Base[str], child: Child[str], default: Child[int]) -> None: + reveal_type(base.copy()) # revealed: Base[str] + reveal_type(child.copy()) # revealed: Child[str] + reveal_type(Child[str].copy(child)) # revealed: Child[str] + reveal_type(Child.copy(default)) # revealed: Child[int] + Child[int].copy(child) # error: [invalid-argument-type] + Child.copy(child) # error: [invalid-argument-type] +``` + +### Inherited methods on closed TypedDicts + +A closed generic `TypedDict` subclass exposes its specialized item types through its view methods. +Its inherited `copy()` method also preserves the specialization. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing_extensions import TypedDict + +class Base[T](TypedDict, closed=True): + value: T + +class Child[T](Base[T]): ... + +def methods(child: Child[int]) -> None: + reveal_type(child.keys()) # revealed: dict_keys[Literal["value"], int] + reveal_type(child.items()) # revealed: dict_items[Literal["value"], int] + reveal_type(child.values()) # revealed: dict_values[Literal["value"], int] + reveal_type(child.copy()) # revealed: Child[int] +``` + ### Specialized constructor signatures An explicitly specialized constructor substitutes its type parameter in both the receiver and the @@ -4504,6 +4976,19 @@ class TD12(TypedDict("TD12", {}, extra_items=InitVar[int])): ... # error: [inva class TD13(TypedDict("TD13", {}, extra_items=Final[int])): ... # error: [invalid-type-form] ``` +## Function syntax inside string annotations + +A functional `TypedDict` can appear in `Annotated` metadata. Inferring `extra_items` in a stub must +retain the enclosing string's identity rather than looking up its parsed nodes in the module's +semantic index. + +```pyi +from typing_extensions import Annotated, TypedDict + +value: "Annotated[int, TypedDict('T', {}, extra_items=int)]" +reveal_type(value) # revealed: int +``` + ## Function syntax with forward references Functional TypedDict supports forward references (string annotations): @@ -5901,6 +6386,37 @@ def _(u: Foo | Bar): reveal_type(u) # revealed: Bar ``` +A union member can have several possible tags. Comparing against a different member's tag removes +the multi-tag member from the matching branch, including in unions with more than two members: + +```py +class MultiTag(TypedDict): + tag: Literal["bar", "baz"] + +def two_members(u: Foo | MultiTag): + if u["tag"] == "foo": + reveal_type(u) # revealed: Foo + else: + reveal_type(u) # revealed: MultiTag + +def three_members(u: Foo | MultiTag | Bar): + if u["tag"] != "foo": + reveal_type(u) # revealed: MultiTag | Bar + else: + reveal_type(u) # revealed: Foo +``` + +Matching one of several tags selects that member, but excluding just one of its tags does not remove +it from the union: + +```py +def match_one_tag(u: Foo | MultiTag): + if u["tag"] == "bar": + reveal_type(u) # revealed: MultiTag + else: + reveal_type(u) # revealed: Foo | MultiTag +``` + Boolean tags can be narrowed by truthiness, including through a generic `TypedDict` and a type alias: @@ -6054,6 +6570,22 @@ class WackyInt(int): _: NonLiteralTD = {"tag": WackyInt(99)} # allowed ``` +The same restriction applies to a tag union containing a non-literal type. The `int` alternative can +still hold a `WackyInt` that compares equal to `"foo"`: + +```py +class MixedTag(TypedDict): + tag: Literal["bar"] | int + +def mixed_tag(u: Foo | MixedTag): + if u["tag"] == "foo": + reveal_type(u) # revealed: Foo | MixedTag + else: + reveal_type(u) # revealed: MixedTag + +_: MixedTag = {"tag": WackyInt(99)} +``` + Intersections containing a TypedDict with literal fields can be narrowed with equality checks. Since `Foo` requires `tag == "foo"`, the else branch is `Never`: @@ -6322,6 +6854,23 @@ def match_statements(u: Foo | Bar | Baz | Bing): reveal_type(u) # revealed: Bing ``` +A tag can contain multiple literal values. A literal pattern selects the matching dictionary; +failing one of its tags leaves the other tag possible in later cases: + +```py +class MultiTag(TypedDict): + tag: Literal["bar", "baz"] + +def match_multiple_tags(u: Foo | MultiTag | Bar): + match u["tag"]: + case "foo": + reveal_type(u) # revealed: Foo + case "bar": + reveal_type(u) # revealed: MultiTag + case _: + reveal_type(u) # revealed: MultiTag | Bar +``` + Enum literal tags are also supported in match statements: ```py @@ -7439,6 +7988,17 @@ class ChildWithBadValueType(Base): year: NotRequired[int] ``` +Recursive items are subject to the same consistency requirement. A recursive `TypedDict` is not +consistent with the base's `bool` extra items: + +```py +class RecursiveBase(TypedDict, extra_items=bool): ... + +# error: [invalid-typed-dict-header] +class RecursiveChild(RecursiveBase): + child: NotRequired["RecursiveChild"] +``` + ### A subclass of a TypedDict with read-only `extra_items: T` may add required or non-required items assignable to `T` ```py @@ -8168,6 +8728,68 @@ class HasReadOnly(TypedDict, extra_items=int): static_assert(not is_assignable_to(HasReadOnly, dict[str, int])) ``` +### Recursive fields with mutable extra items + +A declared item can have a different type from the extra items, including a reference to the +`TypedDict` itself. Such a type is compatible with `Mapping[str, object]`, but not with a mutable +dictionary whose values must all be `bool`: + +```py +from collections.abc import Mapping +from typing_extensions import TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class Recursive(TypedDict, total=False, extra_items=bool): + child: "Recursive" + +static_assert(is_assignable_to(Recursive, Mapping[str, object])) +static_assert(not is_assignable_to(Recursive, dict[str, bool])) +static_assert(not is_subtype_of(Recursive, dict[str, bool])) +``` + +### Mutually recursive fields with dictionary-valued extra items + +Recursive items can also refer to another `TypedDict`. Even when the extra items are themselves +dictionaries, the declared items need not be consistent with them: + +```py +from collections.abc import Mapping +from typing_extensions import TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class Left(TypedDict, total=False, extra_items=dict[str, object]): + child: "Right" + +class Right(TypedDict, total=False, extra_items=dict[str, object]): + child: Left + +static_assert(is_assignable_to(Left, Mapping[str, object])) +static_assert(not is_assignable_to(Left, dict[str, dict[str, object]])) +static_assert(not is_subtype_of(Right, dict[str, dict[str, object]])) +``` + +### Recursive functional TypedDicts with mutable extra items + +The functional syntax has the same dictionary compatibility rules as the class syntax: + +```py +from collections.abc import Mapping +from typing_extensions import NotRequired, TypedDict + +Recursive = TypedDict("Recursive", {"child": "NotRequired[Recursive]"}, extra_items=bool) + +def as_mapping(value: Recursive) -> Mapping[str, object]: + return value + +def as_dict(value: Recursive) -> dict[str, bool]: + return value # error: [invalid-return-type] + +def update(value: Recursive) -> None: + value.update({"child": value}) +``` + [closed]: https://peps.python.org/pep-0728/#disallowing-extra-items-explicitly [subtyping section]: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types [`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html diff --git a/crates/ty_python_semantic/resources/mdtest/unary/integers.md b/crates/ty_python_semantic/resources/mdtest/unary/integers.md index ec439977ed..596e6f951f 100644 --- a/crates/ty_python_semantic/resources/mdtest/unary/integers.md +++ b/crates/ty_python_semantic/resources/mdtest/unary/integers.md @@ -21,5 +21,9 @@ reveal_type(-True) # revealed: Literal[-1] ```py reveal_type(~0) # revealed: Literal[-1] reveal_type(~1) # revealed: Literal[-2] -reveal_type(~True) # revealed: Literal[-2] + +# `~` on a `bool` is currently deprecated in typeshed. +# error: [deprecated] +# revealed: Literal[-2] +reveal_type(~True) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/union_types.md b/crates/ty_python_semantic/resources/mdtest/union_types.md index ebf3edf134..0ea3eddaf2 100644 --- a/crates/ty_python_semantic/resources/mdtest/union_types.md +++ b/crates/ty_python_semantic/resources/mdtest/union_types.md @@ -393,8 +393,9 @@ def gradual_aliases( reveal_type(nested_last) # revealed: Covariant[NestedGradualAlias] ``` -Matching materialization endpoints do not establish that gradual tuple arguments have the same -shape. A bounded generic must preserve which tuple position contains the gradual element. +Matching top materializations do not establish that gradual tuple arguments have the same shape. A +bounded generic must preserve which tuple position contains the gradual element, including in its +bottom materialization. ```py from ty_extensions import Bottom, Top, static_assert @@ -408,7 +409,7 @@ class C[T: tuple[int, int]]: raise NotImplementedError static_assert(is_equivalent_to(Top[C[L]], Top[C[R]])) -static_assert(is_equivalent_to(Bottom[C[L]], Bottom[C[R]])) +static_assert(not is_equivalent_to(Bottom[C[L]], Bottom[C[R]])) static_assert(not is_equivalent_to(C[L], C[R])) static_assert(not is_equivalent_to(C[L] | C[R], C[L])) static_assert(not is_equivalent_to(C[R] | C[L], C[R])) diff --git a/crates/ty_python_semantic/resources/mdtest/unpacking.md b/crates/ty_python_semantic/resources/mdtest/unpacking.md index 26a5902265..3c7af61047 100644 --- a/crates/ty_python_semantic/resources/mdtest/unpacking.md +++ b/crates/ty_python_semantic/resources/mdtest/unpacking.md @@ -119,7 +119,7 @@ reveal_type(d) # revealed: Unknown ```py [a, *b, c] = (1, 2) reveal_type(a) # revealed: Literal[1] -reveal_type(b) # revealed: list[Never] +reveal_type(b) # revealed: list[Unknown] reveal_type(c) # revealed: Literal[2] ``` @@ -128,7 +128,7 @@ reveal_type(c) # revealed: Literal[2] ```py [a, *b, c] = (1, 2, 3) reveal_type(a) # revealed: Literal[1] -reveal_type(b) # revealed: list[Literal[2]] +reveal_type(b) # revealed: list[int] reveal_type(c) # revealed: Literal[3] ``` @@ -137,7 +137,7 @@ reveal_type(c) # revealed: Literal[3] ```py [a, *b, c, d] = (1, 2, 3, 4, 5, 6) reveal_type(a) # revealed: Literal[1] -reveal_type(b) # revealed: list[Literal[2, 3, 4]] +reveal_type(b) # revealed: list[int] reveal_type(c) # revealed: Literal[5] reveal_type(d) # revealed: Literal[6] ``` @@ -148,7 +148,7 @@ reveal_type(d) # revealed: Literal[6] [a, b, *c] = (1, 2, 3, 4) reveal_type(a) # revealed: Literal[1] reveal_type(b) # revealed: Literal[2] -reveal_type(c) # revealed: list[Literal[3, 4]] +reveal_type(c) # revealed: list[int] ``` ### Starred expression (6) @@ -164,6 +164,31 @@ reveal_type(e) # revealed: Unknown reveal_type(f) # revealed: Unknown ``` +### Starred unpacking of a large tuple + +For performance, ty widens inferred integer literal types to `int` in tuples with more than 64 +elements. Unpacking preserves that widening: this unannotated assignment infers `int` for `first` +and `list[int]` for `rest`, including when the elements come from a list literal expansion. Widening +also applies inside nested tuple elements. Unpacking the small tuple `(0, (1,), 2)` instead infers +`Literal[0]` and `Literal[1]` for the fixed targets. + +```py +# fmt: off +first, (second,), *rest = (*[ + 0, (1,), 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, 62, 63, 64, +],) +# fmt: on +reveal_type(first) # revealed: int +reveal_type(second) # revealed: int +reveal_type(rest) # revealed: list[int] +``` + ### Non-iterable unpacking ```py @@ -225,6 +250,41 @@ reveal_type(a) # revealed: Literal[1] reveal_type(b) # revealed: Literal[2] ``` +### Too few values in a list literal + +A list literal without starred elements has a known length. If it cannot fill all targets, we report +the mismatch and infer `Unknown` for those targets, as with a tuple literal. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected 2" +first, last = [1] +reveal_type(first) # revealed: Unknown +reveal_type(last) # revealed: Unknown +``` + +A starred target does not reduce the number of elements required by the fixed targets. On a length +mismatch, its element type is also unknown. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +first, *rest, last = [1] +reveal_type(first) # revealed: Unknown +reveal_type(rest) # revealed: list[Unknown] +reveal_type(last) # revealed: Unknown +``` + +### Too many values in a list literal + +Without a starred target, every element needs a corresponding target. Extra elements cause an error +and leave all targets with unknown types. + +```py +# error: [invalid-assignment] "Too many values to unpack: Expected 2" +first, last = [1, 2, 3] +reveal_type(first) # revealed: Unknown +reveal_type(last) # revealed: Unknown +``` + ### Simple unpacking ```py @@ -268,6 +328,339 @@ def _(value: list[int]): reveal_type(c) # revealed: int ``` +## List and tuple literals + +### Starred targets + +Unpacking a list literal assigns each element to its corresponding target. + +```py +first: int +first, *rest = [1, "wrong"] +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] +``` + +The starred target can also precede the fixed targets: + +```py +*rest, last = ["one", "two", 3] +reveal_type(rest) # revealed: list[str] +reveal_type(last) # revealed: Literal[3] +``` + +A starred target between fixed targets excludes both the prefix and the suffix from its element +type: + +```py +[first, *rest, last] = [1, "two", "three", 4] +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] +reveal_type(last) # revealed: Literal[4] +``` + +### Empty starred targets + +When the fixed targets consume every element, the starred target receives an empty list. As with an +empty list literal, the element type is unknown, allowing values to be added later. + +```py +first, *rest, last = [1, 2] +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[Unknown] +reveal_type(last) # revealed: Literal[2] +rest.append(3) + +(*empty,) = [] +reveal_type(empty) # revealed: list[Unknown] +``` + +### Nested list literals + +Element positions are preserved when list literals are nested inside other list or tuple literals. + +```py +(first, *rest), *outer_rest, (last,) = [[1, "two"], False, [3]] +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] +reveal_type(outer_rest) # revealed: list[bool] +reveal_type(last) # revealed: Literal[3] +``` + +The same nested lists retain their element positions when the outer literal is a tuple. + +```py +(first, *rest), *outer_rest, (last,) = ([1, "two"], False, [3]) +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] +reveal_type(outer_rest) # revealed: list[bool] +reveal_type(last) # revealed: Literal[3] +``` + +Unpacking another iterable alongside a list literal does not affect the literal's element types. + +```py +def nested(values: list[int]): + # error: [refutable-unpacking] "`list[int]` may not have exactly 1 element, which would raise `ValueError` when unpacked" + (first, *rest), (other,) = ( + [1, "two"], + values, + ) + reveal_type(first) # revealed: Literal[1] + reveal_type(rest) # revealed: list[str] + reveal_type(other) # revealed: int +``` + +If a nested list has too few elements, only the targets unpacked from that list get unknown types. +The sibling target retains its corresponding element's type. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +(first, *rest, last), other = [[1], 2] +reveal_type(first) # revealed: Unknown +reveal_type(rest) # revealed: list[Unknown] +reveal_type(last) # revealed: Unknown +reveal_type(other) # revealed: Literal[2] +``` + +A starred outer target does not hide a length mismatch inside either kind of literal. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected 2" +(first, last), *rest = ([1],) +reveal_type(first) # revealed: Unknown +reveal_type(last) # revealed: Unknown + +# error: [invalid-assignment] "Not enough values to unpack: Expected 2" +(first, last), *rest = [[1]] +reveal_type(first) # revealed: Unknown +reveal_type(last) # revealed: Unknown +``` + +### Incompatible targets + +An incompatible element still causes an error for its corresponding target. + +```py +first: int +# error: [invalid-assignment] "Object of type `Literal["wrong"]` is not assignable to `int`" +first, *rest = ["wrong", 1] +reveal_type(rest) # revealed: list[int] +``` + +The starred target is checked against the list of collected elements. + +```py +numbers: list[int] +# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `list[int]`" +first, *numbers = [1, "wrong"] +``` + +### Capture-list inference + +A starred target receives a new list. Inferred literal element types are promoted, as in a list +literal, so additional values of the same type can be appended. + +```py +first, *rest = [1, "two"] +rest.append("three") +reveal_type(rest) # revealed: list[str] + +first, *rest = (1, "two") +rest.append("three") +reveal_type(rest) # revealed: list[str] + +first, *rest = (1,) +rest.append("three") +reveal_type(rest) # revealed: list[Unknown] +``` + +The collected elements are also compatible with an explicitly annotated list. + +```py +strings: list[str] +first, *strings = [1, "two"] +first, *strings = [1] +``` + +Singleton values follow the same inference rules as in a list literal. + +```py +optional: list[int | None] +first, *optional = [1, None] +first, *optional = (1, None) +``` + +Explicit literal annotations are preserved when constructing the collected list. + +```py +from typing import Literal + +def explicit_literal(value: Literal["one", "two"]): + first, *rest = [1, value] + reveal_type(rest) # revealed: list[Literal["one", "two"]] + first, *rest = (1, value) + reveal_type(rest) # revealed: list[Literal["one", "two"]] +``` + +### Collected tuple elements + +Homogeneous tuple literals of different lengths are promoted to a variable-length tuple element +type, as in an ordinary list literal. + +```py +rest: list[tuple[int, ...]] +first, *rest = [(1,), (2,), (3, 4)] +reveal_type(first) # revealed: tuple[Literal[1]] +reveal_type(rest) # revealed: list[tuple[int, ...]] + +first, *rest = ((1,), (2,), (3, 4)) +reveal_type(first) # revealed: tuple[Literal[1]] +reveal_type(rest) # revealed: list[tuple[int, ...]] +``` + +A tuple from a variable retains its annotated shape and prevents tuple-size promotion for the +collected elements. + +```py +def annotated_tuple(value: tuple[int, int]): + first, *rest = [0, (1,), value] + reveal_type(rest) # revealed: list[tuple[int] | tuple[int, int]] + first, *rest = (0, (1,), value) + reveal_type(rest) # revealed: list[tuple[int] | tuple[int, int]] +``` + +Tuple literals collected between multiple expansions remain eligible for tuple-size promotion. + +```py +def expanded_tuples(values: list[str]): + first, *rest, last = (0, *values, (1,), *values, (2, 3), False) + reveal_type(first) # revealed: Literal[0] + reveal_type(rest) # revealed: list[str | tuple[int, ...]] + reveal_type(last) # revealed: Literal[False] + + first, *rest, last = [0, *values, (1,), *values, (2, 3), False] + reveal_type(first) # revealed: Literal[0] + reveal_type(rest) # revealed: list[str | tuple[int, ...]] + reveal_type(last) # revealed: Literal[False] +``` + +### Starred expressions on the right-hand side + +A starred element can contribute an unknown number of values. The literal's AST length does not +determine whether it can fill the targets. + +```py +def unpack(values: list[int]): + first, *rest, last = [*values] + reveal_type(first) # revealed: int + reveal_type(rest) # revealed: list[int] + reveal_type(last) # revealed: int +``` + +Known elements before and after a starred expression keep their positions, for both tuple and list +literals. Only the starred target collects the elements supplied by `values`. + +```py +def fixed_ends(values: list[str]): + first: int + first, *rest, last = (1, *values, 2) + reveal_type(first) # revealed: Literal[1] + reveal_type(rest) # revealed: list[str] + reveal_type(last) # revealed: Literal[2] + + first, *rest, last = [1, *values, 2] + reveal_type(first) # revealed: Literal[1] + reveal_type(rest) # revealed: list[str] + reveal_type(last) # revealed: Literal[2] +``` + +### Ambiguous positions around an expansion + +When an expansion may be empty, a fixed target can receive either one of its elements or a value +from the other side of the expansion. We combine those possibilities without losing the types of the +unambiguous targets. + +```py +def ambiguous(values: list[str]): + first, second, *rest = (0, *values, 1) + reveal_type(first) # revealed: Literal[0] + reveal_type(second) # revealed: str | Literal[1] + reveal_type(rest) # revealed: list[str | int] + + first, second, *rest = [0, *values, 1] + reveal_type(first) # revealed: Literal[0] + reveal_type(second) # revealed: str | Literal[1] + reveal_type(rest) # revealed: list[str | int] +``` + +### Unpacking literal expansions + +Expanding a literal preserves its elements and length. These assignments fail even though their +right-hand sides contain starred expressions. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +first, *rest, last = (*(1,),) +reveal_type(first) # revealed: Unknown +reveal_type(rest) # revealed: list[Unknown] +reveal_type(last) # revealed: Unknown + +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +first, *rest, last = [*[1]] +reveal_type(first) # revealed: Unknown +reveal_type(rest) # revealed: list[Unknown] +reveal_type(last) # revealed: Unknown +``` + +A dictionary literal with a single key supplies one element. + +```py +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +first, *rest, last = (*{"key": 1},) +# error: [invalid-assignment] "Not enough values to unpack: Expected at least 2" +first, *rest, last = [*{"key": 1}] +``` + +### Unpacking a named expression + +A named expression preserves the structure of its value when unpacked immediately. The bound list +itself still has an ordinary list type. + +```py +first: int +first, *rest = (items := (1, "two")) +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] + +first, *rest = (items := [1, "two"]) +reveal_type(first) # revealed: Literal[1] +reveal_type(rest) # revealed: list[str] +reveal_type(items) # revealed: list[int | str] +``` + +### Aliases in unpacked values + +Collected lists retain aliases in their element types. + +```toml +[environment] +python-version = "3.12" +``` + +```py +type Element = int | str + +def aliases(value: Element): + first, *rest = (value, value) + reveal_type(first) # revealed: int | str + reveal_type(rest) # revealed: list[Element] + + first, *rest = [value, value] + reveal_type(first) # revealed: int | str + reveal_type(rest) # revealed: list[Element] +``` + ## Homogeneous tuples ### Simple unpacking @@ -469,7 +862,7 @@ def f(x: HeterogeneousTupleSubclass): reveal_type(o) # revealed: I0 reveal_type(p) # revealed: I1 reveal_type(q) # revealed: I2 - reveal_type(r) # revealed: list[Never] + reveal_type(r) # revealed: list[Unknown] # error: [invalid-assignment] "Not enough values to unpack: Expected at least 4" [s, t, u, v, *w] = x @@ -581,7 +974,7 @@ reveal_type(d) # revealed: Unknown ```py a, *b, c = "ab" reveal_type(a) # revealed: Literal["a"] -reveal_type(b) # revealed: list[Never] +reveal_type(b) # revealed: list[Unknown] reveal_type(c) # revealed: Literal["b"] ``` @@ -590,7 +983,7 @@ reveal_type(c) # revealed: Literal["b"] ```py a, *b, c = "abc" reveal_type(a) # revealed: Literal["a"] -reveal_type(b) # revealed: list[Literal["b"]] +reveal_type(b) # revealed: list[str] reveal_type(c) # revealed: Literal["c"] ``` @@ -599,7 +992,7 @@ reveal_type(c) # revealed: Literal["c"] ```py a, *b, c, d = "abcdef" reveal_type(a) # revealed: Literal["a"] -reveal_type(b) # revealed: list[Literal["b", "c", "d"]] +reveal_type(b) # revealed: list[str] reveal_type(c) # revealed: Literal["e"] reveal_type(d) # revealed: Literal["f"] ``` @@ -610,7 +1003,7 @@ reveal_type(d) # revealed: Literal["f"] a, b, *c = "abcd" reveal_type(a) # revealed: Literal["a"] reveal_type(b) # revealed: Literal["b"] -reveal_type(c) # revealed: list[Literal["c", "d"]] +reveal_type(c) # revealed: list[str] ``` ### Starred expression (6) @@ -728,7 +1121,7 @@ reveal_type(d) # revealed: Unknown ```py a, *b, c = b"ab" reveal_type(a) # revealed: Literal[97] -reveal_type(b) # revealed: list[Never] +reveal_type(b) # revealed: list[Unknown] reveal_type(c) # revealed: Literal[98] ``` @@ -737,7 +1130,7 @@ reveal_type(c) # revealed: Literal[98] ```py a, *b, c = b"abc" reveal_type(a) # revealed: Literal[97] -reveal_type(b) # revealed: list[Literal[98]] +reveal_type(b) # revealed: list[int] reveal_type(c) # revealed: Literal[99] ``` @@ -746,7 +1139,7 @@ reveal_type(c) # revealed: Literal[99] ```py a, *b, c, d = b"abcdef" reveal_type(a) # revealed: Literal[97] -reveal_type(b) # revealed: list[Literal[98, 99, 100]] +reveal_type(b) # revealed: list[int] reveal_type(c) # revealed: Literal[101] reveal_type(d) # revealed: Literal[102] ``` @@ -757,7 +1150,7 @@ reveal_type(d) # revealed: Literal[102] a, b, *c = b"abcd" reveal_type(a) # revealed: Literal[97] reveal_type(b) # revealed: Literal[98] -reveal_type(c) # revealed: list[Literal[99, 100]] +reveal_type(c) # revealed: list[int] ``` ### Very long literal diff --git a/crates/ty_python_semantic/resources/mdtest/with/async.md b/crates/ty_python_semantic/resources/mdtest/with/async.md index 94e1317252..1a7d63d059 100644 --- a/crates/ty_python_semantic/resources/mdtest/with/async.md +++ b/crates/ty_python_semantic/resources/mdtest/with/async.md @@ -18,6 +18,243 @@ async def test(): reveal_type(f) # revealed: Target ``` +## Exception-suppressing async context managers and union aliases + +An asynchronous context manager can suppress exceptions if its `__aexit__` method returns `bool`: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Literal + +class Suppresses: + async def __aenter__(self) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> bool: + return True + +async def may_raise() -> str: + raise ValueError + +async def preserved_binding() -> None: + result = None + async with Suppresses(): + result = await may_raise() + reveal_type(result) # revealed: None | str +``` + +If an exception interrupts an assignment to a new name, that name may remain undefined: + +```py +async def missing_binding() -> None: + async with Suppresses(): + value = await may_raise() + # error: [possibly-unresolved-reference] + reveal_type(value) # revealed: str +``` + +An `__aexit__` return type of `None` does not suppress exceptions: + +```py +class Propagates: + async def __aenter__(self) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> None: ... + +async def propagating_exit() -> None: + result = None + async with Propagates(): + result = await may_raise() + reveal_type(result) # revealed: str +``` + +[The typing specification](https://typing.python.org/en/latest/spec/exceptions.html#context-managers) +treats an awaited `Literal[True] | None` return type as non-suppressing, even though a truthy return +value would suppress an exception at runtime: + +```py +class OptionalTrueExit: + async def __aenter__(self) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> Literal[True] | None: + return True + +async def optional_true_exit() -> None: + result = None + async with OptionalTrueExit(): + result = await may_raise() + reveal_type(result) # revealed: str +``` + +A PEP 695 alias does not prevent a suppressing union member from preserving an earlier binding: + +```py +type Managers = Suppresses | Propagates + +async def preserved_union_binding(manager: Managers) -> None: + result = None + async with manager: + result = await may_raise() + reveal_type(result) # revealed: None | str +``` + +A suppressed exception can also leave a new binding undefined: + +```py +async def missing_union_binding(manager: Managers) -> None: + async with manager: + result = await may_raise() + # error: [possibly-unresolved-reference] + reveal_type(result) # revealed: str +``` + +## Earlier async context managers can suppress later entry failures + +If an earlier async context manager suppresses an exception while a later manager enters, the later +manager's target may never be assigned: + +```py +class Suppresses: + async def __aenter__(self) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> bool: + return True + +class EnterFails: + async def __aenter__(self) -> str: + raise ValueError + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: ... + +async def later_entry_fails() -> None: + async with Suppresses(), EnterFails() as target: + pass + # error: [possibly-unresolved-reference] + reveal_type(target) # revealed: str +``` + +## Returning from an exception-suppressing async context manager + +A context manager cannot suppress a return statement: + +```py +class Suppresses: + async def __aenter__(self) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> bool: + return True + +async def bare_return() -> str: + async with Suppresses(): + return "finished" +``` + +An exception raised while evaluating an awaited return expression can be suppressed instead: + +```py +async def may_raise() -> str: + raise ValueError + +async def interrupted_return() -> str: # error: [invalid-return-type] + async with Suppresses(): + return await may_raise() +``` + +## Overloaded async context manager exit methods + +An overloaded async exit method can distinguish normal exits from exceptions: + +```py +from typing import Awaitable, Literal, overload +from typing_extensions import Never + +async def may_raise() -> str: + raise ValueError +``` + +An overload returning `True` only on a normal exit cannot suppress an exception: + +```py +class NormalExitOnly: + async def __aenter__(self) -> None: ... + @overload + async def __aexit__(self, exc_type: None, exc_value, traceback) -> Literal[True]: ... + @overload + async def __aexit__(self, exc_type: type[BaseException], exc_value, traceback) -> Literal[False]: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> bool: + return exc_type is None + +async def normal_exit_only() -> None: + result = None + async with NormalExitOnly(): + result = await may_raise() + reveal_type(result) # revealed: str +``` + +Of the following three overloads, the second applies when an exception is raised, and the third +applies when the suite exits without an exception. The first overload never applies because its +exception argument is `Never`: + +```py +class NeverExit: + async def __aenter__(self) -> None: ... + @overload + async def __aexit__(self, exc_type: Never, exc_value, traceback) -> Literal[True]: ... + @overload + async def __aexit__(self, exc_type: type[BaseException], exc_value, traceback) -> Literal[False]: ... + @overload + async def __aexit__(self, exc_type: None, exc_value, traceback) -> Literal[False]: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> bool: + return False + +async def impossible_exit() -> None: + result = None + async with NeverExit(): + result = await may_raise() + reveal_type(result) # revealed: str +``` + +An exceptional overload can suppress its exception even if another exceptional overload cannot: + +```py +class SuppressesValueError: + async def __aenter__(self) -> None: ... + @overload + async def __aexit__(self, exc_type: type[ValueError], exc_value: ValueError, traceback: object) -> Literal[True]: ... + @overload + async def __aexit__(self, exc_type: type[TypeError], exc_value: TypeError, traceback: object) -> None: ... + @overload + async def __aexit__(self, exc_type: None, exc_value: None, traceback: None) -> None: ... + async def __aexit__(self, exc_type, exc_value, traceback) -> Literal[True] | None: + return True if exc_type is ValueError else None + +async def mixed_exceptional_exits() -> None: + result = None + async with SuppressesValueError(): + result = await may_raise() + reveal_type(result) # revealed: None | str +``` + +An exceptional overload that returns a non-awaitable does not prevent a later awaitable overload +from suppressing a different exception: + +```py +class SuppressesAfterNonAwaitable: + async def __aenter__(self) -> None: ... + @overload + def __aexit__(self, exc_type: type[TypeError], exc_value: TypeError, traceback: object) -> bool: ... + @overload + def __aexit__(self, exc_type: type[ValueError], exc_value: ValueError, traceback: object) -> Awaitable[Literal[True]]: ... + @overload + def __aexit__(self, exc_type: None, exc_value: None, traceback: None) -> Awaitable[None]: ... + def __aexit__(self, exc_type, exc_value, traceback) -> bool | Awaitable[Literal[True]] | Awaitable[None]: + raise NotImplementedError + +async def suppresses_after_non_awaitable() -> None: + result = None + async with SuppressesAfterNonAwaitable(): + result = await may_raise() + reveal_type(result) # revealed: None | str +``` + ## Multiple targets ```py diff --git a/crates/ty_python_semantic/resources/mdtest/with/sync.md b/crates/ty_python_semantic/resources/mdtest/with/sync.md index 0669d941dd..f7be445474 100644 --- a/crates/ty_python_semantic/resources/mdtest/with/sync.md +++ b/crates/ty_python_semantic/resources/mdtest/with/sync.md @@ -18,6 +18,583 @@ with Manager() as f: reveal_type(f) # revealed: Target ``` +## Exception-suppressing context managers + +When a context manager suppresses an exception during an assignment, the previous binding remains +visible after the `with` statement: + +```py +from contextlib import suppress + +def may_raise() -> str: + raise ValueError + +result = None +with suppress(ValueError): + result = may_raise() + +reveal_type(result) # revealed: None | str +``` + +A new name may remain undefined when an exception interrupts its assignment: + +```py +with suppress(ValueError): + value = may_raise() + +# error: [possibly-unresolved-reference] +reveal_type(value) # revealed: str +``` + +A deleted binding is not restored if a later exception is suppressed: + +```py +deleted = 1 +with suppress(ValueError): + del deleted + may_raise() + +deleted # error: [unresolved-reference] +``` + +An assignment that cannot raise is not affected by exception suppression: + +```py +with suppress(ValueError): + safe_value = 1 + +reveal_type(safe_value) # revealed: Literal[1] +``` + +## Assigning a context manager target can raise + +Unpacking the result of `__enter__` can raise after the context manager has entered. Suppressing +that exception preserves an earlier binding, while a new target may remain undefined: + +```py +class EmptyIterableManager: + def __enter__(self) -> list[int]: + return [] + + def __exit__(self, exc_type, exc_value, traceback) -> bool: + return True + +value = "before" +# error: [refutable-unpacking] "`list[int]` may not have exactly 2 elements, which would raise `ValueError` when unpacked" +with EmptyIterableManager() as ( + value, + missing, +): + pass + +reveal_type(value) # revealed: Literal["before"] | int +# error: [possibly-unresolved-reference] +reveal_type(missing) # revealed: int +``` + +## Earlier context managers can suppress later entry failures + +If an earlier context manager suppresses an exception while a later manager enters, the later +manager's target may never be assigned: + +```py +from contextlib import suppress + +class EnterFails: + def __enter__(self) -> str: + raise ValueError + + def __exit__(self, exc_type, exc_value, traceback) -> None: ... + +with suppress(ValueError), EnterFails() as target: + pass + +# error: [possibly-unresolved-reference] +reveal_type(target) # revealed: str +``` + +## Loop exits inside multiple context managers + +A context manager cannot suppress a `break`, but it can suppress an exception while the next manager +enters. An assignment after the managers is therefore only possibly reached: + +```py +from contextlib import nullcontext, suppress + +for _ in [1]: + with suppress(ValueError), nullcontext(): + break + after_break = 1 + +after_break # error: [possibly-unresolved-reference] +``` + +It cannot suppress a `continue` either: + +```py +for _ in [1]: + with suppress(ValueError), nullcontext(): + continue + after_continue = 1 + +after_continue # error: [possibly-unresolved-reference] +``` + +An exception inside one manager can likewise be suppressed before a `break`: + +```py +for _ in [1]: + with suppress(ValueError): + int("invalid") + break + after_exception = 1 + +after_exception # error: [possibly-unresolved-reference] +``` + +## Loop exits inside nested context managers + +Nested context managers cannot suppress a `break`, but the outer manager can suppress an exception +while the inner manager enters: + +```py +from contextlib import nullcontext, suppress + +for _ in [1]: + with suppress(ValueError): + with nullcontext(): + break + after_break = 1 + +after_break # error: [possibly-unresolved-reference] +``` + +They cannot suppress a `continue` either: + +```py +for _ in [1]: + with suppress(ValueError): + with nullcontext(): + continue + after_continue = 1 + +after_continue # error: [possibly-unresolved-reference] +``` + +## Returning from an exception-suppressing context manager + +A context manager cannot suppress a return statement: + +```py +from contextlib import suppress + +def bare_return() -> int: + with suppress(ValueError): + return 1 +``` + +It can suppress an exception raised while evaluating the return expression, allowing the function to +continue without returning a value: + +```py +def may_raise() -> int: + raise ValueError + +# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`" +def interrupted_return() -> int: + with suppress(ValueError): + return may_raise() +``` + +## Exception handlers inside a suppressing context manager + +A bare `except:` catches an exception before it can reach the surrounding context manager: + +```py +from contextlib import suppress + +def caught_before_suppression() -> int: + with suppress(ValueError): + try: + raise ValueError + except: + return 1 +``` + +## A terminal `finally` prevents exception suppression + +A `return` in a `finally` block replaces the exception before it can reach an enclosing context +manager: + +```py +from contextlib import suppress + +def always_returns() -> int: + with suppress(ValueError): + try: + raise ValueError + finally: + return 1 +``` + +## Cleanup runs before an enclosing context manager suppresses an exception + +Assignments in a `finally` block are visible after an enclosing context manager suppresses the +exception: + +```py +from contextlib import suppress + +def cleanup_before_suppression() -> None: + result = None + with suppress(ValueError): + try: + raise ValueError + finally: + result = "cleaned" + reveal_type(result) # revealed: Literal["cleaned"] +``` + +## Eager expressions inside a suppressing context manager + +A list comprehension evaluates its body eagerly, so a context manager can suppress an exception +raised inside it: + +```py +from contextlib import suppress + +def may_raise() -> int: + raise ValueError + +# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`" +def eager_comprehension() -> int: + with suppress(ValueError): + [may_raise() for _ in [0]] + return 1 +``` + +Generator expressions are also assumed to run eagerly, so their exceptions can be suppressed: + +```py +# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`" +def eager_generator() -> int: + with suppress(ValueError): + (may_raise() for _ in [0]) + return 1 +``` + +## Context manager exit return types + +The typing specification treats an `__exit__` return type of `bool` as potentially suppressing: + +```py +from typing import Any, Literal + +class Manager: + def __enter__(self) -> None: ... + +class ReturnsBool(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> bool: + return True + +def may_raise() -> str: + raise ValueError + +bool_result = None +with ReturnsBool(): + bool_result = may_raise() +reveal_type(bool_result) # revealed: None | str +``` + +An `__exit__` return type of `Literal[True]` can also suppress exceptions: + +```py +class ReturnsTrue(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> Literal[True]: + return True + +true_result = None +with ReturnsTrue(): + true_result = may_raise() +reveal_type(true_result) # revealed: None | str +``` + +An `__exit__` return type of `Literal[False]` cannot suppress exceptions: + +```py +class ReturnsFalse(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> Literal[False]: + return False + +false_result = None +with ReturnsFalse(): + false_result = may_raise() +reveal_type(false_result) # revealed: str +``` + +An `__exit__` return type of `None` cannot suppress exceptions: + +```py +class ReturnsNone(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> None: ... + +none_result = None +with ReturnsNone(): + none_result = may_raise() +reveal_type(none_result) # revealed: str +``` + +[The typing specification](https://typing.python.org/en/latest/spec/exceptions.html#context-managers) +classifies `bool | None` as non-suppressing for compatibility with common non-suppressing context +managers, even though a truthy return value can suppress an exception at runtime: + +```py +class ReturnsOptionalBool(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> bool | None: + return None + +optional_result = None +with ReturnsOptionalBool(): + optional_result = may_raise() +reveal_type(optional_result) # revealed: str +``` + +This convention also treats `Literal[True] | None` as non-suppressing: + +```py +class ReturnsOptionalTrue(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> Literal[True] | None: + return True + +optional_true_result = None +with ReturnsOptionalTrue(): + optional_true_result = may_raise() +reveal_type(optional_true_result) # revealed: str +``` + +An `__exit__` return type of `Literal[False] | None` cannot suppress exceptions either: + +```py +class ReturnsOptionalFalse(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> Literal[False] | None: + return False + +optional_false_result = None +with ReturnsOptionalFalse(): + optional_false_result = may_raise() +reveal_type(optional_false_result) # revealed: str +``` + +An `__exit__` return type of `Any` does not indicate exception suppression either: + +```py +class ReturnsAny(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> Any: + return False + +any_result = None +with ReturnsAny(): + any_result = may_raise() +reveal_type(any_result) # revealed: str +``` + +## Context managers with union and aliased union types + +A context manager with a union type may suppress an exception if any member can suppress it, even +when another member cannot: + +```toml +[environment] +python-version = "3.12" +``` + +```py +class Manager: + def __enter__(self) -> None: ... + +class Suppresses(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> bool: + return True + +class Propagates(Manager): + def __exit__(self, exc_type, exc_value, traceback) -> bool | None: + return None + +def may_raise() -> str: + raise ValueError + +def possibly_suppressing(manager: Suppresses | Propagates) -> None: + result = None + with manager: + result = may_raise() + reveal_type(result) # revealed: None | str +``` + +A PEP 695 alias does not prevent a suppressing union member from preserving an earlier binding: + +```py +type Managers = Suppresses | Propagates + +def preserved_binding(manager: Managers) -> None: + result = None + with manager: + result = may_raise() + reveal_type(result) # revealed: None | str +``` + +A suppressed exception can also leave a new binding undefined: + +```py +def missing_binding(manager: Managers) -> None: + with manager: + result = may_raise() + # error: [possibly-unresolved-reference] + reveal_type(result) # revealed: str +``` + +## Non-suppressing context managers preserve narrowing + +A non-suppressing manager does not change narrowing after an exception propagates: + +```py +class Manager: + def __enter__(self) -> None: ... + def __exit__(self, exc_type, exc_value, traceback) -> None: ... + +def propagating_exception(value: int | str) -> None: + if isinstance(value, int): + with Manager(): + raise ValueError + reveal_type(value) # revealed: str +``` + +Narrowing established after an earlier operation that may raise is preserved too: + +```py +def narrowing_after_possible_exception(value: int | str) -> None: + with Manager(): + int("invalid") + if isinstance(value, int): + raise ValueError + reveal_type(value) # revealed: str +``` + +Type guard narrowing on one exception path is preserved when another path introduces a new binding +inside a non-suppressing context manager: + +```py +from typing import TypeGuard + +class Base: ... + +def is_string(value: Base) -> TypeGuard[str]: + return isinstance(value, str) + +def make_integer() -> int: + return 1 + +def type_guard_across_exception_handler() -> None: + value = Base() + try: + if not is_string(value): + return + except Exception: + with Manager(): + value = make_integer() + + reveal_type(value) # revealed: str | int +``` + +Ordinary `isinstance` narrowing follows the same rule: an excluded `None` does not reappear when the +exception-handler assignment is merged: + +```py +def isinstance_across_exception_handler(source: str | None) -> None: + value = source + try: + if not isinstance(value, str): + return + except Exception: + with Manager(): + value = make_integer() + + reveal_type(value) # revealed: str | int +``` + +## Overloaded context manager exit methods + +Whether an overloaded exit method can suppress an exception depends on the overload used when an +exception occurs, not the overload used when its suite exits without an exception. In the latter +case, Python calls `__exit__(None, None, None)`: + +```py +from typing import Literal, overload +from typing_extensions import Never + +class Manager: + def __enter__(self) -> None: ... + +def may_raise() -> str: + raise ValueError +``` + +A manager that returns `True` only during normal exit cannot suppress exceptions: + +```py +class NormalOnly(Manager): + @overload + def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> Literal[True]: ... + @overload + def __exit__(self, exc_type: type[BaseException], exc_value: BaseException, traceback: object) -> Literal[False]: ... + def __exit__(self, exc_type, exc_value, traceback) -> bool: + return exc_type is None + +normal_value = None +with NormalOnly(): + normal_value = may_raise() +reveal_type(normal_value) # revealed: str +``` + +An exceptional overload cannot suppress an exception if either exception argument is uninhabited: + +```py +class ImpossibleExceptionalExit(Manager): + @overload + def __exit__(self, exc_type: Never, exc_value: BaseException, traceback: object) -> Literal[True]: ... + @overload + def __exit__(self, exc_type: type[BaseException], exc_value: Never, traceback: object) -> Literal[True]: ... + @overload + def __exit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: object | None + ) -> Literal[False]: ... + def __exit__(self, exc_type, exc_value, traceback) -> bool: + return False + +impossible_exception_value = None +with ImpossibleExceptionalExit(): + impossible_exception_value = may_raise() +reveal_type(impossible_exception_value) # revealed: str +``` + +An exceptional overload can suppress its exception even if another exceptional overload cannot: + +```py +class SuppressesValueError(Manager): + @overload + def __exit__(self, exc_type: type[ValueError], exc_value: ValueError, traceback: object) -> Literal[True]: ... + @overload + def __exit__(self, exc_type: type[TypeError], exc_value: TypeError, traceback: object) -> None: ... + @overload + def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> None: ... + def __exit__(self, exc_type, exc_value, traceback) -> Literal[True] | None: + return True if exc_type is ValueError else None + +mixed_exceptional_value = None +with SuppressesValueError(): + mixed_exceptional_value = may_raise() +reveal_type(mixed_exceptional_value) # revealed: None | str +``` + ## Union context manager ```py diff --git a/crates/ty_python_semantic/src/assumed.rs b/crates/ty_python_semantic/src/assumed.rs index aa0c81d1c7..5b9b4618cb 100644 --- a/crates/ty_python_semantic/src/assumed.rs +++ b/crates/ty_python_semantic/src/assumed.rs @@ -35,7 +35,7 @@ use crate::types::context::ProgramEnvironment; use crate::types::{EnumLiteralType, Type}; /// the type an observation pins a name to, when it pins one at all -pub(crate) fn seeded_type<'db>( +fn seeded_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, observed: &Observed, diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index 965347aa52..49eb52f8dd 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -1,4 +1,5 @@ use crate::dependencies::DependencyManifest; +use crate::dependency::DependencyMetadata; use crate::lint::{LintRegistry, RuleSelection}; use crate::{AnalysisSettings, ExperimentalSettings, PythonVersionWithSource}; use ruff_db::diagnostic::Diagnostic; @@ -29,6 +30,9 @@ pub trait Db: PythonCoreDb { /// feature, and a module's meaning cannot depend on which file is asking. fn experimental_settings(&self) -> &ExperimentalSettings; + /// Returns the package manager's dependency information for this file. + fn dependency_metadata(&self, file: File) -> Option<&DependencyMetadata>; + /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). fn verbose(&self) -> bool; @@ -286,6 +290,10 @@ pub(crate) mod tests { &self.experimental_settings } + fn dependency_metadata(&self, _file: File) -> Option<&DependencyMetadata> { + None + } + fn verbose(&self) -> bool { false } @@ -310,10 +318,14 @@ pub(crate) mod tests { python_version: PythonVersion, /// Target Python platform python_platform: PythonPlatform, + /// Roots containing first-party modules. + src_roots: Vec, /// Path and content pairs for files that should be present files: Vec<(&'a str, &'a str)>, /// Directories resolved as site-packages (third-party) search paths site_packages: Vec, + /// Whether module resolution should include packages from the synthetic virtual environment. + third_party_packages: bool, } impl<'a> TestDbBuilder<'a> { @@ -321,8 +333,10 @@ pub(crate) mod tests { Self { python_version: PythonVersion::default(), python_platform: PythonPlatform::default(), + src_roots: vec![SystemPathBuf::from("/src")], files: vec![], site_packages: vec![], + third_party_packages: false, } } @@ -348,6 +362,11 @@ pub(crate) mod tests { self } + pub(crate) fn with_src_roots(mut self, src_roots: Vec) -> Self { + self.src_roots = src_roots; + self + } + pub(crate) fn with_file( mut self, path: &'a (impl AsRef + ?Sized), @@ -357,11 +376,27 @@ pub(crate) mod tests { self } + /// Makes packages installed in the synthetic virtual environment available for imports. + /// + /// Files under `/.venv/lib/python3.13/site-packages` are treated as third-party modules, + /// mirroring the import roots discovered from a project's configured Python environment. + pub(crate) fn with_third_party_packages(mut self) -> Self { + self.third_party_packages = true; + self + } + pub(crate) fn build(self) -> anyhow::Result { let mut db = TestDb::new(); - let src_root = SystemPathBuf::from("/src"); - db.memory_file_system().create_directory_all(&src_root)?; + for src_root in &self.src_roots { + db.memory_file_system().create_directory_all(src_root)?; + } + + let default_site_packages = SystemPathBuf::from("/.venv/lib/python3.13/site-packages"); + if self.third_party_packages { + db.memory_file_system() + .create_directory_all(&default_site_packages)?; + } for site_packages in &self.site_packages { db.memory_file_system() .create_directory_all(site_packages)?; @@ -370,10 +405,18 @@ pub(crate) mod tests { db.write_files(self.files) .context("Failed to write test files")?; - let search_paths = SearchPathSettings { - site_packages_paths: self.site_packages, - ..SearchPathSettings::new(vec![src_root]) + let mut search_path_settings = if self.third_party_packages { + SearchPathSettings { + src_roots: self.src_roots, + site_packages_paths: vec![default_site_packages], + ..SearchPathSettings::empty() + } + } else { + SearchPathSettings::new(self.src_roots) }; + search_path_settings + .site_packages_paths + .extend(self.site_packages); let program_settings = ProgramSettings { python_version: PythonVersionWithSource { @@ -381,7 +424,7 @@ pub(crate) mod tests { source: PythonVersionSource::default(), }, python_platform: self.python_platform, - search_paths: search_paths + search_paths: search_path_settings .to_search_paths(db.system(), db.vendored(), &FallibleStrategy) .context("Invalid search path settings")?, }; diff --git a/crates/ty_python_semantic/src/dependencies.rs b/crates/ty_python_semantic/src/dependencies.rs index 2bf3ded425..42d104df64 100644 --- a/crates/ty_python_semantic/src/dependencies.rs +++ b/crates/ty_python_semantic/src/dependencies.rs @@ -99,7 +99,7 @@ impl DependencyManifest { /// /// A distribution can be in more than one — a test dependency that is also /// an extra, say — and then any one of them being available is enough. - pub fn groups_declaring<'a>( + fn groups_declaring<'a>( &'a self, distribution: &'a DistributionName, ) -> impl Iterator { @@ -110,7 +110,7 @@ impl DependencyManifest { } /// Whether any group declares `distribution`. - pub fn declares(&self, distribution: &DistributionName) -> bool { + fn declares(&self, distribution: &DistributionName) -> bool { self.groups_declaring(distribution).next().is_some() } @@ -218,7 +218,7 @@ impl AllowedGroups { } impl<'db> AvailableGroups<'db> { - pub fn manifest(&self) -> Option<&'db DependencyManifest> { + fn manifest(&self) -> Option<&'db DependencyManifest> { match self { AvailableGroups::Unknown => None, AvailableGroups::Known { manifest, .. } => Some(manifest), @@ -307,7 +307,7 @@ pub fn import_standing<'db>( /// it is asked at the point of reporting rather than folded into /// [`import_standing`]: a project whose imports are all in order never pays for /// the requirement graph at all. -pub fn installed_because<'db>( +pub(crate) fn installed_because<'db>( db: &'db dyn Db, file: File, distribution: &DistributionName, diff --git a/crates/ty_python_semantic/src/dependency.rs b/crates/ty_python_semantic/src/dependency.rs new file mode 100644 index 0000000000..d1cc603131 --- /dev/null +++ b/crates/ty_python_semantic/src/dependency.rs @@ -0,0 +1,230 @@ +//! Direct dependencies and module ownership supplied by a package manager. + +use std::collections::{BTreeMap, BTreeSet}; + +use compact_str::CompactString; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use ty_module_resolver::{ + ImportingFile, Module, ModuleName, editable_search_paths, file_to_module, resolve_real_module, +}; +use ty_python_core::ProgramFile; + +use crate::Db; + +/// Returns the missing dependency for this import, if one can be identified. +/// +/// Cache the diagnostic details so metadata changes do not invalidate import inference when +/// the result for this importing file and module is unchanged. +#[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn missing_direct_dependency<'db>( + db: &'db dyn Db, + importing_file: ProgramFile<'db>, + imported_module: Module<'db>, +) -> Option { + let metadata = db.dependency_metadata(importing_file.file(db))?; + metadata.missing_dependency(db, importing_file, imported_module) +} + +/// The dependency information needed to check imports, without source ranges or lockfile details. +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +pub struct DependencyMetadata { + pub projects: Box<[DependencyProject]>, + /// Installable packages, keyed by opaque package-manager IDs rather than names. + /// A distribution can provide several Python modules; its name need not match their import names. + /// IDs distinguish distributions with the same name but different sources. + pub distributions: BTreeMap, + /// Maps module names to the IDs in [`Self::distributions`] that provide those modules. + /// A module can have several owners, for example when distributions share a namespace package. + pub module_owners: BTreeMap>, +} + +impl DependencyMetadata { + /// Check whether `importing_file` is allowed to import `imported_module`. + /// + /// Use the script's own declarations or the nearest containing project's declarations. + /// Imports of its own distribution and its runtime or optional dependencies are allowed. + /// Dependency groups are also allowed for files not identified as package code. + /// + /// Return the missing dependency, or `None` if the import is allowed or its project or + /// owning distribution cannot be identified. + fn missing_dependency<'db>( + &self, + db: &'db dyn Db, + importing_file: ProgramFile<'db>, + imported_module: Module<'db>, + ) -> Option { + let path = importing_file.file(db).path(db).as_system_path()?; + let project = self + .projects + .iter() + .filter(|project| match project.kind { + DependencyProjectKind::Project => path.starts_with(&project.path), + DependencyProjectKind::Script => path == project.path.as_path(), + }) + .max_by_key(|project| project.path.as_str().len())?; + + // Stubs can belong to a different distribution, so prefer the runtime module. + // Fall back to the resolved stub for native modules that ty cannot resolve at runtime. + let runtime_module = resolve_real_module( + db, + ImportingFile::File( + importing_file.file(db), + importing_file.resolver_environment(db), + ), + imported_module.name(db), + ) + .unwrap_or(imported_module); + let id = self.owner(db, runtime_module)?; + + // Runtime and optional declarations take precedence when a dependency is also in a group. + if project.distribution.as_ref() == Some(id) || project.dependencies.contains(id) { + return None; + } + + let group_dependency = project.group_dependencies.contains(id); + if group_dependency && !self.is_package_file(db, importing_file, project) { + return None; + } + + Some(MissingDependency { + distribution_name: self.distributions.get(id)?.name.clone(), + group_dependency, + project_kind: project.kind, + }) + } + + fn owner<'db>(&self, db: &'db dyn Db, module: Module<'db>) -> Option<&CompactString> { + // A namespace can also contain local modules that the package manager doesn't know about. + // Only attribute concrete modules; inference checks the children of `from ns import x`. + let search_path = module.search_path(db)?; + if search_path.is_standard_library() { + // ty bundles typing_extensions stubs, but the runtime module is third-party. + if module.name(db).first_component() != "typing_extensions" { + return None; + } + } else if !search_path.is_site_packages() { + if let Some(path) = module + .file(db) + .and_then(|file| file.path(db).as_system_path()) + && let Some(owner) = self.editable_owner(path) + { + return Some(owner); + } + + // A local module can shadow an installed distribution with the same import name. + // Its name alone is not evidence that the import uses that distribution. + if !search_path.is_editable() { + return None; + } + } + + self.module_owner(module.name(db)) + } + + fn module_owner(&self, module: &ModuleName) -> Option<&CompactString> { + let owners = module + .ancestors() + .find_map(|name| self.module_owners.get(&name))?; + + // In particular, importing a namespace shared by several distributions doesn't establish + // which of them is required. A more specific submodule may have an unambiguous owner. + match owners.as_ref() { + [owner] => Some(owner), + _ => None, + } + } + + fn editable_owner(&self, path: &SystemPath) -> Option<&CompactString> { + let mut owner = None; + let mut longest_root = 0; + + for (id, distribution) in &self.distributions { + let Some(root) = &distribution.editable_path else { + continue; + }; + if !path.starts_with(root) { + continue; + } + + match root.as_str().len().cmp(&longest_root) { + std::cmp::Ordering::Greater => { + longest_root = root.as_str().len(); + owner = Some(id); + } + std::cmp::Ordering::Equal => owner = None, + std::cmp::Ordering::Less => {} + } + } + + owner + } + + fn is_package_file( + &self, + db: &dyn Db, + file: ProgramFile<'_>, + project: &DependencyProject, + ) -> bool { + let Some(id) = &project.distribution else { + return false; + }; + + if let Some(module) = file_to_module(db, file.resolver_file(db)) + && self.module_owner(module.name(db)) == Some(id) + { + return true; + } + + // Editable installs may only record a .pth file, not their Python modules. Use the + // resolver's editable roots before deduplication against first-party search paths. A root + // narrower than the project directory separates package code from sibling tests/scripts. + // A flat install exposing the whole project does not establish that distinction. + let Some(root) = self + .distributions + .get(id) + .and_then(|distribution| distribution.editable_path.as_ref()) + else { + return false; + }; + let Some(path) = file.file(db).path(db).as_system_path() else { + return false; + }; + + editable_search_paths(db, file.resolver_environment(db)).any(|search_root| { + search_root != root.as_path() + && search_root.starts_with(root) + && path.starts_with(search_root) + }) + } +} + +/// The direct dependency declarations of a workspace member, virtual workspace root, or script. +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +pub struct DependencyProject { + /// The project directory or the exact path of a standalone script. + pub path: SystemPathBuf, + pub kind: DependencyProjectKind, + pub distribution: Option, + pub dependencies: BTreeSet, + pub group_dependencies: BTreeSet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize)] +pub enum DependencyProjectKind { + Project, + Script, +} + +/// A distribution's display name and, for editable installs, its source directory. +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +pub struct DependencyDistribution { + pub name: CompactString, + pub editable_path: Option, +} + +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] +pub(crate) struct MissingDependency { + pub(crate) distribution_name: CompactString, + pub(crate) group_dependency: bool, + pub(crate) project_kind: DependencyProjectKind, +} diff --git a/crates/ty_python_semantic/src/diagnostic/mod.rs b/crates/ty_python_semantic/src/diagnostic/mod.rs index f8346f25a0..165af7aaf2 100644 --- a/crates/ty_python_semantic/src/diagnostic/mod.rs +++ b/crates/ty_python_semantic/src/diagnostic/mod.rs @@ -27,15 +27,19 @@ pub fn inferred_python_version_source_annotation( source: &PythonVersionSource, ) -> Option { match source { - PythonVersionSource::ConfigFile(source) => source.span(db).map(Annotation::primary), - PythonVersionSource::PyvenvCfgFile(source) => source.span(db).map(Annotation::primary), + PythonVersionSource::ConfigFile(source) | PythonVersionSource::PyvenvCfgFile(source) => { + source.span(db).map(Annotation::primary) + } + PythonVersionSource::ScriptMetadata(span) => { + span.range().map(|_| Annotation::primary(span.clone())) + } PythonVersionSource::InstallationDirectoryLayout { source, .. } => source .as_ref() .and_then(|source| source.span(db)) .map(Annotation::primary), PythonVersionSource::Cli | PythonVersionSource::Editor - | PythonVersionSource::UvWorkspace + | PythonVersionSource::UvMetadata | PythonVersionSource::Default => None, } } @@ -72,6 +76,18 @@ pub(crate) fn add_inferred_python_version_hint_to_diagnostic( )); } } + source @ crate::PythonVersionSource::ScriptMetadata(_) => { + let mut sub_diagnostic = SubDiagnostic::new( + SubDiagnosticSeverity::Info, + format_args!( + "Python {version} was assumed when {action} because it was specified in script metadata" + ), + ); + if let Some(annotation) = inferred_python_version_source_annotation(db, source) { + sub_diagnostic.annotate(annotation.message("Python version configured here")); + } + diagnostic.sub(sub_diagnostic); + } source @ crate::PythonVersionSource::PyvenvCfgFile(_) => { if let Some(annotation) = inferred_python_version_source_annotation(db, source) { let mut sub_diagnostic = SubDiagnostic::new( @@ -101,9 +117,9 @@ pub(crate) fn add_inferred_python_version_hint_to_diagnostic( because it's the version of the selected Python interpreter in your editor", )); } - crate::PythonVersionSource::UvWorkspace => { + crate::PythonVersionSource::UvMetadata => { diagnostic.info(format_args!( - "Python {version} was assumed when {action} because it was provided by uv workspace metadata", + "Python {version} was assumed when {action} because it was provided by uv metadata", )); } crate::PythonVersionSource::InstallationDirectoryLayout { diff --git a/crates/ty_python_semantic/src/django_settings.rs b/crates/ty_python_semantic/src/django_settings.rs index feadba0ec4..124718560e 100644 --- a/crates/ty_python_semantic/src/django_settings.rs +++ b/crates/ty_python_semantic/src/django_settings.rs @@ -41,8 +41,8 @@ const SETTINGS_CLASS: &str = "LazySettings"; /// a file that points `DJANGO_SETTINGS_MODULE` somewhere, and where at #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub struct SettingsNaming { - pub file: File, - pub module: CompactString, + file: File, + module: CompactString, } /// every file of `files` that names a settings module, in path order diff --git a/crates/ty_python_semantic/src/dunder_all.rs b/crates/ty_python_semantic/src/dunder_all.rs index 3a1dedc325..875ba7ce12 100644 --- a/crates/ty_python_semantic/src/dunder_all.rs +++ b/crates/ty_python_semantic/src/dunder_all.rs @@ -3,7 +3,7 @@ use ruff_python_ast::name::Name; use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt}; use ruff_python_ast::{self as ast}; use rustc_hash::FxHashSet; -use ty_module_resolver::{ImportingFile, ModuleName, resolve_module}; +use ty_module_resolver::{ImportingFile, resolve_module_for_import_from}; use crate::types::{Type, TypeContext, infer_expression_types}; use crate::{Db, ProgramEnvironment}; @@ -165,9 +165,7 @@ impl<'db> DunderAllNamesCollector<'db> { let importing_file = ImportingFile::File(self.file.file(db), self.env.resolver_environment(db)); - let module_name = - ModuleName::from_import_statement(db, importing_file, import_from).ok()?; - let module = resolve_module(db, importing_file, &module_name)?; + let module = resolve_module_for_import_from(db, importing_file, import_from)?; dunder_all_names( db, ProgramFile::new(db, module.file(db)?, self.env.program(db)), diff --git a/crates/ty_python_semantic/src/lexical_name_path.rs b/crates/ty_python_semantic/src/lexical_name_path.rs new file mode 100644 index 0000000000..5ad11059f4 --- /dev/null +++ b/crates/ty_python_semantic/src/lexical_name_path.rs @@ -0,0 +1,129 @@ +use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +use ruff_python_ast::{self as ast, name::Name}; +use tracing::trace; +use ty_python_core::definition::{Definition, DefinitionKind}; +use ty_python_core::scope::NodeWithScopeKind; +use ty_python_core::semantic_index; + +use crate::Db; + +/// Returns the module-relative lexical name path to `definition`. +/// +/// For example, the path to `method` here is `["Outer", "method"]`: +/// ```python +/// class Outer: +/// def method(): ... +/// ``` +pub(crate) fn lexical_name_path_for_definition( + db: &dyn Db, + definition: Definition, +) -> Option> { + let parsed = parsed_module(db, definition.python_file(db)); + let module = parsed.load(db); + + let mut path = vec![ + lexical_name_path_component_for_leaf(db, &module, definition) + .map_err(|()| { + trace!("Found unsupported DefinitionKind for lexical name path"); + }) + .ok()?, + ]; + + let index = semantic_index(db, definition.program_file(db)); + for (_scope_id, scope) in index.ancestor_scopes(definition.file_scope(db)) { + let component = lexical_name_path_component_for_node(&module, scope.node()) + .map_err(|()| { + trace!("Found unsupported NodeScopeKind for lexical name path"); + }) + .ok()?; + if let Some(component) = component { + path.push(component); + } + } + + path.reverse(); + Some(path) +} + +/// Computes a lexical name path component for an enclosing scope. +/// +/// See [`lexical_name_path_for_definition`][] for details. +pub(crate) fn lexical_name_path_component_for_node( + parsed: &ParsedModuleRef, + node: &NodeWithScopeKind, +) -> Result, ()> { + let component = match node { + NodeWithScopeKind::Module => { + // This is just implicit, so has no component + return Ok(None); + } + NodeWithScopeKind::Class(class) => class.node(parsed).name.id.clone(), + NodeWithScopeKind::Function(func) => func.node(parsed).name.id.clone(), + NodeWithScopeKind::TypeAlias(_) + | NodeWithScopeKind::ClassTypeParameters(_) + | NodeWithScopeKind::FunctionTypeParameters(_) + | NodeWithScopeKind::TypeAliasTypeParameters(_) + | NodeWithScopeKind::Lambda(_) + | NodeWithScopeKind::ListComprehension(_) + | NodeWithScopeKind::SetComprehension(_) + | NodeWithScopeKind::DictComprehension(_) + | NodeWithScopeKind::GeneratorExpression(_) => { + // Not yet implemented + return Err(()); + } + }; + Ok(Some(component)) +} + +/// Computes the final component of a lexical name path. +/// +/// See [`lexical_name_path_for_definition`][] for details. +fn lexical_name_path_component_for_leaf( + db: &dyn Db, + parsed: &ParsedModuleRef, + definition: Definition, +) -> Result { + let component = match definition.kind(db) { + DefinitionKind::Function(func) => func.node(parsed).name.id.clone(), + DefinitionKind::Class(class) => class.node(parsed).name.id.clone(), + DefinitionKind::Assignment(assignment) => { + let ast::Expr::Name(name) = assignment.target(parsed) else { + return Err(()); + }; + name.id.clone() + } + DefinitionKind::AnnotatedAssignment(assignment) => { + let ast::Expr::Name(name) = assignment.target(parsed) else { + return Err(()); + }; + name.id.clone() + } + DefinitionKind::TypeAlias(_) + | DefinitionKind::Import(_) + | DefinitionKind::ImportFrom(_) + | DefinitionKind::ImportFromSubmodule(_) + | DefinitionKind::StarImport(_) + | DefinitionKind::NamedExpression(_) + | DefinitionKind::StatementExpressionValue(_) + | DefinitionKind::AugmentedAssignment(_) + | DefinitionKind::DictKeyAssignment(_) + | DefinitionKind::For(_) + | DefinitionKind::Comprehension(_) + | DefinitionKind::Parameter(_) + | DefinitionKind::LambdaParameter { .. } + | DefinitionKind::WithItem(_) + | DefinitionKind::MatchPattern(_) + | DefinitionKind::ExceptHandler(_) + | DefinitionKind::TypeVar(_) + | DefinitionKind::ParamSpec(_) + | DefinitionKind::TypeVarTuple(_) + | DefinitionKind::TypeMatchCapture(_) + | DefinitionKind::LoopHeader(_) + | DefinitionKind::NestedBindings(_) => { + // Not yet implemented + return Err(()); + } + }; + + Ok(component) +} diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index c255d746ea..a7ad4c4a4b 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -39,12 +39,11 @@ use ty_python_core::definition::docstring_from_body; use ty_python_core::platform::PythonPlatform; use ty_python_core::scope::ScopeId; use ty_python_core::{ - BindingWithConstraintsIterator, DeclarationsIterator, FileScopeId, attribute_scopes, - semantic_index, + BindingWithConstraints, DeclarationsIterator, FileScopeId, attribute_scopes, semantic_index, }; pub use ty_site_packages::{ PythonEnvironment, PythonVersionFileSource, PythonVersionSource, PythonVersionWithSource, - SitePackagesPaths, SysPrefixPathOrigin, + SitePackagesDiscoveryError, SitePackagesPaths, SysPrefixPathOrigin, }; pub use types::conformance::declares_conformances; pub use types::conformance::{ @@ -69,17 +68,23 @@ pub use types::reified_infer::{ pub use types::static_resource::{ResourceError, render_as, resolve_static_resource}; pub use types::template::finite_string_set; pub use types::visibility::private_symbols; -pub use types::{DisplaySettings, ProgramEnvironment, TypeQualifiers}; +pub use types::{ + DisplaySettings, FixtureBinding, FixtureExposure, FixtureNameSource, ProgramEnvironment, + TypeQualifiers, fixture_bindings_for_parameter, fixture_exposures_for_definition, + pytest_global_plugin_files, +}; pub mod api_lockfile; mod assumed; pub use assumed::stop_offset; mod db; pub mod dependencies; +pub mod dependency; pub mod django_settings; pub mod django_template; mod dunder_all; mod fixes; +mod lexical_name_path; pub mod lint; pub(crate) mod place; pub(crate) mod place_load; @@ -380,20 +385,35 @@ impl Default for AnalysisSettings { /// Returns all attribute assignments (and their method scope IDs) with a symbol name matching /// the one given for a specific class body scope. /// +/// Loop headers are excluded: rebinding an attribute's receiver can create a loop header for the +/// attribute without assigning to the attribute itself. +/// /// Only call this when doing type inference on the same file as `class_body_scope`, otherwise it /// introduces a direct dependency on that file's AST. pub(crate) fn attribute_assignments<'db, 's>( db: &'db dyn Db, class_body_scope: ScopeId<'db>, name: &'s str, -) -> impl Iterator, FileScopeId)> + use<'s, 'db> { +) -> impl Iterator< + Item = ( + impl Iterator>, + FileScopeId, + ), +> + use<'s, 'db> { let index = semantic_index(db, class_body_scope.program_file(db)); - attribute_scopes(db, class_body_scope).filter_map(|function_scope_id| { + attribute_scopes(db, class_body_scope).filter_map(move |function_scope_id| { let place_table = index.place_table(function_scope_id); let member = place_table.member_id_by_instance_attribute_name(name)?; let use_def = index.use_def_map(function_scope_id); - Some((use_def.reachable_member_bindings(member), function_scope_id)) + let assignments = use_def + .reachable_member_bindings(member) + .filter(move |binding| { + !binding + .binding + .is_defined_and(|definition| definition.kind(db).is_loop_header()) + }); + Some((assignments, function_scope_id)) }) } @@ -433,11 +453,11 @@ pub fn check_file_unwrap(db: &dyn Db, file: ProgramFile<'_>) -> Vec .unwrap_or_else(|error| vec![error]) } -pub fn check_file(db: &dyn Db, file: ProgramFile<'_>) -> Result, Diagnostic> { +fn check_file(db: &dyn Db, file: ProgramFile<'_>) -> Result, Diagnostic> { check_file_with(db, file, Vec::new()) } -/// [`check_file`], with lint diagnostics worked out elsewhere folded in. +/// `check_file`, with lint diagnostics worked out elsewhere folded in. /// /// `external` are diagnostics about `file` that this crate cannot compute — the /// django route checks read the project's whole url tree, which is not something diff --git a/crates/ty_python_semantic/src/lint.rs b/crates/ty_python_semantic/src/lint.rs index f06bc8b5fd..eee2702648 100644 --- a/crates/ty_python_semantic/src/lint.rs +++ b/crates/ty_python_semantic/src/lint.rs @@ -22,7 +22,7 @@ pub struct LintMetadata { /// The default level of the lint if the user doesn't specify one. /// /// This is the level under the default [`TypeCheckingPreset`]; use - /// [`TypeCheckingPreset::level`] to resolve the level under any other preset. + /// `TypeCheckingPreset::level` to resolve the level under any other preset. pub default_level: Level, /// How the lint behaves under the `ty-compatible` [`TypeCheckingPreset`]. @@ -180,6 +180,7 @@ impl LintMetadata { format!("Deprecated (since {since}): {reason}") } LintStatus::Removed { since, reason } => format!("Removed (since {since}): {reason}"), + LintStatus::Preview { since } => format!("Preview (since {since})"), }; let preset = match self.ty_compat() { @@ -232,6 +233,12 @@ pub const fn lint_metadata_defaults(status: LintStatus) -> LintMetadata { serde(tag = "type", rename_all = "lowercase") )] pub enum LintStatus { + /// The lint is available, but its behavior is not yet stable. + Preview { + /// The version in which the lint was added. + since: &'static str, + }, + /// The lint is stable. Stable { /// The version in which the lint was added. @@ -261,6 +268,10 @@ pub enum LintStatus { } impl LintStatus { + pub(crate) const fn preview(since: &'static str) -> Self { + LintStatus::Preview { since } + } + pub const fn stable(since: &'static str) -> Self { LintStatus::Stable { since } } @@ -578,7 +589,7 @@ impl std::fmt::Display for GetLintError { #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum LintEntry { - /// An existing lint rule. Can be stable or deprecated. + /// An existing lint rule. Can be in preview, stable or deprecated. Lint(LintId), /// A lint rule that has been removed. Removed(LintId), @@ -730,9 +741,12 @@ pub enum LintSource { /// The rule was enabled in a configuration file. File, + /// The rule was enabled in a standalone script's inline metadata. + ScriptMetadata, + /// The rule was enabled from the configuration in the editor. Editor, - /// The rule was enabled by uv workspace metadata. - UvWorkspace, + /// The rule was enabled by uv metadata. + UvMetadata, } diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index bd6b31da37..65433cd634 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1,7 +1,10 @@ +pub(crate) mod definitions; + use crate::ProgramEnvironment; use itertools::Either; use ruff_index::IndexSlice; use ruff_python_ast::{self as ast, PythonVersion}; +use rustc_hash::FxHashMap; use ty_module_resolver::{ KnownModule, Module, ModuleName, file_to_module, resolve_module_confident, }; @@ -12,13 +15,13 @@ use crate::place_load::{ PlaceLoadSourceKind, resolve_place_load, }; use crate::reachability::{ - ReachabilityEvaluationCache, evaluate_reachability, evaluate_reachability_with_cache, + NarrowingProjector, ReachabilityEvaluationCache, evaluate_reachability, + evaluate_reachability_with_cache, }; -use crate::types::narrow::NarrowingEvaluatorExtension; use crate::types::{ DynamicType, KnownClass, MemberLookupPolicy, Type, TypeAndQualifiers, TypeQualifiers, - UnionBuilder, UnionType, binding_type, exists_at_runtime, inferred_declaration, - is_discarded_dict_key_assignment, + UnionBuilder, UnionType, binding_type, inferred_declaration, is_discarded_dict_key_assignment, + may_exist_at_runtime, }; use crate::{Db, FxIndexSet, FxOrderSet}; use ty_python_core::definition::{Definition, DefinitionKind, DefinitionState}; @@ -311,7 +314,7 @@ impl<'db> Place<'db> { } #[must_use] - pub(crate) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Place<'db> { + fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Place<'db> { match self { Place::Defined(defined) => Place::Defined(DefinedPlace { ty: f(defined.ty), @@ -696,7 +699,7 @@ fn builtins_symbol_impl<'db>( if matches!(visibility, BuiltinVisibility::RuntimeOnly) && let Place::Defined(defined) = found_symbol.place && let Some(definition) = defined.provenance.definition() - && !exists_at_runtime(db, definition) + && !may_exist_at_runtime(db, definition) { return None; } @@ -1765,7 +1768,7 @@ fn symbol_impl<'db>( #[salsa::tracked( returns(clone), cycle_initial=|db, _, definition: Definition<'db>| { - loop_header_reachability_impl(db, definition, true) + loop_header_reachability_impl(db, definition, Some(&mut FxHashMap::default())) }, cycle_fn=loop_header_reachability_cycle_recover, heap_size = ruff_memory_usage::heap_size, @@ -1774,7 +1777,7 @@ pub(crate) fn loop_header_reachability<'db>( db: &'db dyn Db, definition: Definition<'db>, ) -> LoopHeaderReachability<'db> { - loop_header_reachability_impl(db, definition, false) + loop_header_reachability_impl(db, definition, None) } fn loop_header_reachability_cycle_recover<'db>( @@ -1790,7 +1793,7 @@ fn loop_header_reachability_cycle_recover<'db>( fn loop_header_reachability_impl<'db>( db: &'db dyn Db, definition: Definition<'db>, - is_cycle_initial: bool, + mut cycle_initial_cache: Option<&mut FxHashMap, Truthiness>>, ) -> LoopHeaderReachability<'db> { // This cutoff was chosen by benchmarking real isort to keep loop analysis // overhead minimal while preserving diagnostics. @@ -1806,12 +1809,13 @@ fn loop_header_reachability_impl<'db>( let place = loop_header_definition.place(); let mut deleted_reachability = Truthiness::AlwaysFalse; + let mut deleted_narrowing_constraints = FxIndexSet::default(); let mut reachable_bindings = FxIndexSet::default(); let live_bindings: Vec<_> = loop_header.bindings_for_place(place).collect(); let use_exact_reachability = use_def.reachability_constraints().used_interiors().len() <= MAX_EXACT_LOOP_HEADER_REACHABILITY_NODES; for live_binding in live_bindings { - let reachability = if is_cycle_initial { + let reachability = if cycle_initial_cache.is_some() { Truthiness::Ambiguous } else if use_exact_reachability { evaluate_reachability(db, use_def, live_binding.reachability_constraint()) @@ -1828,11 +1832,42 @@ fn loop_header_reachability_impl<'db>( } match use_def.definition(live_binding.binding()) { - DefinitionState::Defined(def) => { + // Assignment validity can depend on this header, so avoid inferring it while + // initializing a cycle. + DefinitionState::Defined(def) + if cycle_initial_cache.is_some() || !is_discarded_dict_key_assignment(db, def) => + { debug_assert_ne!( def, definition, "loop headers only include bindings from within the loop" ); + if def.kind(db).is_loop_header() { + // An inner loop can reach a `break` with a header binding that carries a + // deletion from an earlier iteration. That deletion also affects boundness + // in the enclosing loop. + let nested_deleted_reachability = + if let Some(cache) = cycle_initial_cache.as_deref_mut() { + // Cycle initialization cannot evaluate predicates that could re-enter + // the cycle. Memoize this structural walk because a descendant header + // can be reached through several containing headers. + cache.get(&def).copied().unwrap_or_else(|| { + let deleted_reachability = + loop_header_reachability_impl(db, def, Some(cache)) + .deleted_reachability; + cache.insert(def, deleted_reachability); + deleted_reachability + }) + } else { + loop_header_reachability(db, def).deleted_reachability + }; + // This binding is reachable, but a conditional loop-back path can make a + // definitely reachable nested deletion only possibly reachable here. + deleted_reachability = + deleted_reachability.or(match nested_deleted_reachability { + Truthiness::AlwaysTrue => reachability, + other => other, + }); + } reachable_bindings.insert(ReachableLoopBinding { definition: def, narrowing_constraint: live_binding.narrowing_constraint(), @@ -1841,8 +1876,11 @@ fn loop_header_reachability_impl<'db>( // `del` in the loop body is always visible to code after the loop via the // normal control flow merge. Updating `deleted_reachability` here is // necessary for prior uses in the loop to see it. - DefinitionState::Deleted => { + // Discarded dictionary-key bindings also require a fallback to the receiver's + // value type instead of contributing their assigned value. + DefinitionState::Defined(_) | DefinitionState::Deleted => { deleted_reachability = deleted_reachability.or(reachability); + deleted_narrowing_constraints.insert(live_binding.narrowing_constraint()); } DefinitionState::Undefined => { unreachable!("loop headers only include bindings from within the loop") @@ -1852,6 +1890,7 @@ fn loop_header_reachability_impl<'db>( LoopHeaderReachability { deleted_reachability, + deleted_narrowing_constraints: deleted_narrowing_constraints.into_iter().collect(), reachable_bindings, } } @@ -1859,8 +1898,12 @@ fn loop_header_reachability_impl<'db>( /// Result of [`loop_header_reachability`]: pre-computed reachability info for loop-back bindings. #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct LoopHeaderReachability<'db> { + /// Reachability of deletions, including those carried by nested loop headers. pub(crate) deleted_reachability: Truthiness, - /// Reachable loop-back bindings that are not `del`s. + /// Constraints established after a deletion, member invalidation, or discarded key assignment. + /// These still narrow the fallback type of the member on the next iteration. + pub(crate) deleted_narrowing_constraints: Box<[ScopedNarrowingConstraint]>, + /// Reachable loop-back bindings whose values contribute to inferred types. pub(crate) reachable_bindings: FxIndexSet>, } @@ -1872,15 +1915,27 @@ impl<'db> LoopHeaderReachability<'db> { ) -> LoopHeaderReachability<'db> { // Avoid losing precision for cycles that are soon to converge. // See [`Type::cycle_normalized`] for more details. - let reachable_bindings = if cycle.iteration() <= crate::TAINTED_CYCLES { - self.reachable_bindings - } else { - let previous_bindings = previous.reachable_bindings.iter().copied(); - previous_bindings.chain(self.reachable_bindings).collect() - }; + if cycle.iteration() <= crate::TAINTED_CYCLES { + return self; + } + + let mut reachable_bindings: FxIndexSet<_> = previous + .reachable_bindings + .iter() + .copied() + .chain(self.reachable_bindings) + .collect(); + reachable_bindings.shrink_to_fit(); + let deleted_narrowing_constraints: FxIndexSet<_> = previous + .deleted_narrowing_constraints + .iter() + .copied() + .chain(self.deleted_narrowing_constraints) + .collect(); LoopHeaderReachability { deleted_reachability: self.deleted_reachability, + deleted_narrowing_constraints: deleted_narrowing_constraints.into_iter().collect(), reachable_bindings, } } @@ -1943,6 +1998,7 @@ fn place_from_bindings_impl<'db>( let mut provenance = Provenance::Unknown; // special handling for synthetic loop header definitions and nested bindings definitions let mut only_non_shadowing_bindings = true; + let mut narrowing_projector = None; let mut types = bindings_with_constraints.filter_map( |BindingWithConstraints { @@ -2068,10 +2124,24 @@ fn place_from_bindings_impl<'db>( first_definition.get_or_insert(binding); provenance = provenance.or(Provenance::SingleDefinition(binding)); let binding_ty = binding_type(db, binding); - Some(( - narrowing_constraint.narrow(db, env, binding_ty, binding.place(db)), - static_reachability, - )) + let narrowed = match narrowing_constraint.constraint() { + ScopedNarrowingConstraint::ALWAYS_TRUE => binding_ty, + ScopedNarrowingConstraint::ALWAYS_FALSE => Type::Never, + constraint => narrowing_projector + .get_or_insert_with(|| { + NarrowingProjector::new( + db, + env, + narrowing_constraint.narrowing_constraints(), + predicates, + narrowing_constraint.predicate_narrowing_targets(), + binding.place(db), + binding_ty, + ) + }) + .narrow(constraint, binding_ty), + }; + Some((narrowed, static_reachability)) }, ); @@ -2820,6 +2890,8 @@ pub(crate) enum ConsideredDefinitions { #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; use crate::db::tests::{TestDb, setup_db}; @@ -2928,14 +3000,14 @@ mod tests { #[track_caller] fn assert_bound_string_symbol<'db>(db: &'db TestDb, symbol: Place<'db>) { - assert!(matches!( + assert_matches!( symbol, Place::Defined(DefinedPlace { ty: Type::NominalInstance(_), definedness: Definedness::AlwaysDefined, .. }) - )); + ); assert_eq!( symbol.expect_type(), KnownClass::Str.to_instance(db, &db.program_environment()) diff --git a/crates/ty_python_semantic/src/place/definitions.rs b/crates/ty_python_semantic/src/place/definitions.rs new file mode 100644 index 0000000000..09bc3ced60 --- /dev/null +++ b/crates/ty_python_semantic/src/place/definitions.rs @@ -0,0 +1,45 @@ +use smallvec::SmallVec; +use ty_python_core::BindingWithConstraintsIterator; +use ty_python_core::definition::{Definition, DefinitionState}; + +use crate::Db; +use crate::reachability::ReachabilityConstraintsExtension; + +/// A set of definitions found by name resolution. +pub(crate) struct DefinitionResolution<'db> { + definitions: SmallVec<[Definition<'db>; 2]>, +} + +impl<'db> DefinitionResolution<'db> { + /// Returns the definitions found by name resolution. + pub(crate) fn definitions(&self) -> &[Definition<'db>] { + &self.definitions + } + + /// Resolves the reachable definitions supplied by the given bindings. + pub(crate) fn from_bindings( + db: &'db dyn Db, + mut bindings: BindingWithConstraintsIterator<'db, 'db>, + ) -> Self { + let mut definitions = SmallVec::new(); + + while let Some(binding) = bindings.next() { + let reachability = bindings.reachability_constraints().evaluate( + db, + bindings.predicates(), + binding.reachability_constraint, + ); + if reachability.is_always_false() { + continue; + } + + if let DefinitionState::Defined(definition) = binding.binding + && !definitions.contains(&definition) + { + definitions.push(definition); + } + } + + Self { definitions } + } +} diff --git a/crates/ty_python_semantic/src/place_load.rs b/crates/ty_python_semantic/src/place_load.rs index 01a3264df7..6cbeecdaac 100644 --- a/crates/ty_python_semantic/src/place_load.rs +++ b/crates/ty_python_semantic/src/place_load.rs @@ -251,7 +251,23 @@ impl<'db> Iterator for PlaceLoadResolution<'db, '_> { bindings, enclosing_scope, } = snapshot; - self.next_node = Some(PlaceLoadResolutionNode::ImplicitGlobalSource); + let global_place_table = self.context.index.place_table(FileScopeId::global()); + let has_explicit_global = self + .loaded_symbol_name() + .and_then(|name| global_place_table.symbol_id(name)) + .is_some_and(|symbol_id| { + let symbol = global_place_table.symbol(symbol_id); + symbol.is_bound() || symbol.is_declared() + }); + + // Nested global assignments create synthetic module bindings even when the + // module never defines the name itself. Do not let those bindings hide an + // implicit global or builtin when the forwarded assignment did not run. + self.next_node = Some(if has_explicit_global { + PlaceLoadResolutionNode::ExplicitGlobalSource(PlaceLoadSourceRole::Ordinary) + } else { + PlaceLoadResolutionNode::ImplicitGlobalSource + }); let source = self.constraints.source( PlaceLoadSourceKind::Bindings(bindings), diff --git a/crates/ty_python_semantic/src/preset.rs b/crates/ty_python_semantic/src/preset.rs index ac34ec000e..3a22f23d5f 100644 --- a/crates/ty_python_semantic/src/preset.rs +++ b/crates/ty_python_semantic/src/preset.rs @@ -28,7 +28,7 @@ pub enum TypeCheckingPreset { } impl TypeCheckingPreset { - pub const fn is_strict(self) -> bool { + pub(crate) const fn is_strict(self) -> bool { matches!(self, Self::Strict) } @@ -47,7 +47,7 @@ impl TypeCheckingPreset { } /// the level `lint` runs at under this preset, before any `rules` configuration - pub const fn level(self, lint: &LintMetadata) -> Level { + pub(crate) const fn level(self, lint: &LintMetadata) -> Level { match self { Self::Strict => lint.default_level, Self::TyCompatible => match lint.ty_compat { diff --git a/crates/ty_python_semantic/src/reachability.rs b/crates/ty_python_semantic/src/reachability.rs index 07cece5bbb..9001e6220b 100644 --- a/crates/ty_python_semantic/src/reachability.rs +++ b/crates/ty_python_semantic/src/reachability.rs @@ -212,14 +212,17 @@ use crate::{ sequence_pattern_type_builder, singleton_pattern_type, }, }; +use ruff_db::parsed::parsed_module; use ruff_index::{Idx, IndexSlice}; +use ruff_python_ast as ast; use ruff_python_ast::name::Name; use ruff_text_size::TextRange; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use ty_python_core::{ - BindingWithConstraints, DeclarationWithConstraint, DeclarationsIterator, FileScopeId, - ScopedDefinitionId, SemanticIndex, Truthiness, UseDefMap, + BindingWithConstraints, DeclarationWithConstraint, DeclarationsIterator, EvaluationMode, + FileScopeId, NarrowingEvaluator, PredicateNarrowingTargets, ScopedDefinitionId, SemanticIndex, + Truthiness, UseDefMap, definition::DefinitionState, expression::Expression, narrowing_constraints::{NarrowingConstraints, ScopedNarrowingConstraint}, @@ -544,13 +547,18 @@ fn accumulate_constraint<'db>( const NON_TERMINAL_CALL_CHUNK_SIZE: usize = 16; const REACHABILITY_EVALUATION_CHUNK_SIZE: usize = 256; - +const CONTROL_FLOW_REACHABILITY_CHECKPOINT_INTERVAL: usize = 16; +const NARROWING_EVALUATION_CHECKPOINT_INTERVAL: usize = 8; fn predicate_scope<'db>(db: &'db dyn Db, predicate: &Predicate<'db>) -> ScopeId<'db> { match predicate.node { - PredicateNode::Expression(expression) => expression.scope(db), + PredicateNode::Expression(expression) + | PredicateNode::Condition(expression) + | PredicateNode::ChainedComparisonCondition(expression) + | PredicateNode::ContextManagerSuppresses { expression, .. } => expression.scope(db), PredicateNode::IsNonTerminalCall(CallableAndCallExpr { callable, .. }) | PredicateNode::AssertsCall(CallableAndCallExpr { callable, .. }) => callable.scope(db), PredicateNode::Pattern(pattern) => pattern.scope(db), + PredicateNode::FinallyNormalPathImpossible { scope, .. } => scope, PredicateNode::OrPatternAlternative(scope) => scope, PredicateNode::SubjectElementPattern(subject_element) => subject_element.pattern.scope(db), PredicateNode::IsNonEmptyIterable(expression) => expression.scope(db), @@ -708,6 +716,30 @@ fn evaluate_reachability_constraint<'db>( ) } +/// Evaluates the normal continuation captured by a deferred `finally` predicate. +/// +/// Unlike other reachability predicates, a deferred `finally` predicate recursively evaluates +/// another reachability constraint, which may contain earlier deferred `finally` predicates. +/// Caching these continuations prevents a sequence of `finally` suites from repeatedly evaluating +/// all preceding continuations, which would otherwise take exponential time. +/// +/// Other expensive predicates already use tracked queries, while ordinary reachability +/// constraints are cached within each inference region and at sparse checkpoints. Tracking +/// [`evaluate_reachability_constraint`] itself would instead retain a Salsa query key and memo for +/// every constraint. +#[salsa::tracked( + returns(copy), + cycle_initial = |_, _, _, _| Truthiness::Ambiguous, + heap_size = get_size2::GetSize::get_heap_size +)] +fn evaluate_finally_continuation<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + continuation: ScopedReachabilityConstraintId, +) -> Truthiness { + evaluate_reachability_constraint(db, scope, continuation) +} + fn terminal_reachability(id: ScopedReachabilityConstraintId) -> Option { match id { ScopedReachabilityConstraintId::ALWAYS_TRUE => Some(Truthiness::AlwaysTrue), @@ -717,19 +749,36 @@ fn terminal_reachability(id: ScopedReachabilityConstraintId) -> Option, predicate: ScopedPredicateId, + visited: usize, ) -> bool { - call_predicates - .binary_search(&predicate) - .is_ok_and(|index| (index + 1) % REACHABILITY_EVALUATION_CHUNK_SIZE == 0) + if let Some(call_index) = call_predicates.and_then(|calls| calls.binary_search(&predicate).ok()) + { + return (call_index + 1).is_multiple_of(REACHABILITY_EVALUATION_CHUNK_SIZE); + } + + // Folding the adjacent bucket prevents regularly interleaved predicate kinds from always + // missing the same checkpoint positions. + let index = predicate.index(); + let checkpoint_position = index ^ (index / CONTROL_FLOW_REACHABILITY_CHECKPOINT_INTERVAL); + visited >= CONTROL_FLOW_REACHABILITY_CHECKPOINT_INTERVAL + && (checkpoint_position + 1).is_multiple_of(CONTROL_FLOW_REACHABILITY_CHECKPOINT_INTERVAL) } /// Walks a reachability decision diagram until it reaches a terminal or reusable checkpoint. /// /// `use_checkpoint` is false only when entering from a checkpoint query. In that case, the first /// node is evaluated directly to prevent the query from immediately calling itself again. +/// +/// General checkpoints are created only after traversing a genuinely long path. Their positions +/// depend on stable predicate IDs, so adjacent roots reuse the same suffix without requiring an +/// additional retained scope-wide index or allocating tracked queries for short, ordinary paths. fn evaluate_reachability_path<'db>( db: &'db dyn Db, scope: ScopeId<'db>, @@ -740,6 +789,7 @@ fn evaluate_reachability_path<'db>( mut use_checkpoint: bool, ) -> Truthiness { let env = ProgramEnvironment::from_scope(scope); + let mut visited = 0; loop { if let Some(reachability) = terminal_reachability(id) { @@ -747,11 +797,7 @@ fn evaluate_reachability_path<'db>( } let node = constraints.get_interior_node(id); - if use_checkpoint - && call_predicates.is_some_and(|call_predicates| { - is_reachability_checkpoint(call_predicates, node.atom()) - }) - { + if use_checkpoint && is_reachability_checkpoint(call_predicates, node.atom(), visited) { return evaluate_reachability_checkpoint(db, scope, id); } @@ -761,14 +807,16 @@ fn evaluate_reachability_path<'db>( Truthiness::AlwaysFalse => node.if_false(), }; use_checkpoint = true; + visited += 1; } } /// Evaluates a canonical suffix of a reachability decision diagram. /// -/// Only every [`REACHABILITY_EVALUATION_CHUNK_SIZE`]th non-terminal-call predicate is a checkpoint. -/// This lets later statements reuse the constraints accumulated by earlier statements without -/// retaining a Salsa query key and memo for every reachability constraint in the scope. +/// Statement calls retain their existing sparse checkpoints; other predicates become checkpoints +/// only after a long path demonstrates that reuse is worthwhile. This lets later statements reuse +/// constraints accumulated by earlier statements without retaining an additional scope-wide index +/// or a Salsa query key and memo for every constraint. #[salsa::tracked( returns(copy), cycle_initial = |_, _, _, _| Truthiness::Ambiguous, @@ -780,12 +828,19 @@ fn evaluate_reachability_checkpoint<'db>( id: ScopedReachabilityConstraintId, ) -> Truthiness { let use_def = use_def_map(db, scope); + let predicates = use_def.predicates(); + let has_many_calls = predicates + .iter() + .filter(|predicate| matches!(predicate.node, PredicateNode::IsNonTerminalCall(_))) + .nth(NON_TERMINAL_CALL_CHUNK_SIZE) + .is_some(); + let call_predicates = has_many_calls.then(|| non_terminal_call_predicates(db, scope)); evaluate_reachability_path( db, scope, use_def.reachability_constraints(), - use_def.predicates(), - Some(non_terminal_call_predicates(db, scope)), + predicates, + call_predicates, id, false, ) @@ -830,29 +885,27 @@ impl<'db> ReachabilityConstraintsExtension<'db> for ReachabilityConstraints { pub(crate) fn narrow_type_by_constraint<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, - constraints: &NarrowingConstraints, - predicates: &IndexSlice>, - id: ScopedNarrowingConstraint, + evaluator: &NarrowingEvaluator<'_, 'db>, base_ty: Type<'db>, place: ScopedPlaceId, ) -> Type<'db> { + let id = evaluator.constraint(); match id { ScopedNarrowingConstraint::ALWAYS_TRUE => return base_ty, ScopedNarrowingConstraint::ALWAYS_FALSE => return Type::Never, _ => {} } - let mut projector = NarrowingProjector::new(db, env, constraints, predicates, place); - let projected_root = projector.project(id); - let mut context = ProjectedNarrowingContext { + NarrowingProjector::new( db, env, + evaluator.narrowing_constraints(), + evaluator.predicates(), + evaluator.predicate_narrowing_targets(), + place, base_ty, - graph: &projector.graph, - joins: projector.graph.joins(projected_root), - join_cache: FxHashMap::default(), - }; - context.narrow(projected_root, None) + ) + .narrow(id, base_ty) } fn apply_accumulated_narrowing<'db>( @@ -893,10 +946,23 @@ struct ProjectedNarrowingNode { if_false: ProjectedNarrowingNodeId, } +/// A projected predicate or a suffix whose projection can be deferred until it is needed. +#[derive(Clone, Copy, Debug)] +enum ProjectedNarrowingEntry<'db> { + Predicate(ProjectedNarrowingNode), + /// A nonterminal suffix. Constant suffixes use the graph's existing terminal IDs instead. + Checkpoint { + constraint: ScopedNarrowingConstraint, + ty: Type<'db>, + }, +} + /// Narrowing graph containing only predicates that can narrow one place. #[derive(Default)] struct ProjectedNarrowingGraph<'db> { - nodes: Vec, + nodes: Vec>, + referenced: Vec, + joins: Vec, node_cache: FxHashMap, or_cache: FxHashMap<(ProjectedNarrowingNodeId, ProjectedNarrowingNodeId), ProjectedNarrowingNodeId>, @@ -909,12 +975,211 @@ struct ProjectedNarrowingGraph<'db> { >, } -impl ProjectedNarrowingGraph<'_> { +impl<'db> ProjectedNarrowingGraph<'db> { /// Returns an interior projected node by ID. - fn node(&self, id: ProjectedNarrowingNodeId) -> ProjectedNarrowingNode { + fn node(&self, id: ProjectedNarrowingNodeId) -> ProjectedNarrowingEntry<'db> { self.nodes[id.0] } + /// Marks a projected node as shared once multiple paths or binding roots reach it. + fn record_reference(&mut self, id: ProjectedNarrowingNodeId) { + if !id.is_terminal() && std::mem::replace(&mut self.referenced[id.0], true) { + self.joins[id.0] = true; + } + } +} + +/// A cached type together with the terminal shape of its canonical projected graph. +/// +/// Joins need to recognize an unconstrained suffix before applying `TypeGuard` replacement. +/// Similarly, an unreachable graph must be eliminated before a later predicate can replace `Never`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +enum ProjectedNarrowingCheckpoint<'db> { + Unreachable, + Unconstrained, + Narrowed(Type<'db>), +} + +impl<'db> ProjectedNarrowingCheckpoint<'db> { + fn ty(self, base_ty: Type<'db>) -> Type<'db> { + match self { + Self::Unreachable => Type::Never, + Self::Unconstrained => base_ty, + Self::Narrowed(ty) => ty, + } + } +} + +/// Evaluates a stable suffix with the canonical projected-graph evaluator. +/// +/// The root is projected directly to avoid querying its own checkpoint. Descendant checkpoints +/// contribute their cached types and terminal shape. Nonterminal suffixes are expanded locally only +/// when simplifying a join requires their predicates. +#[salsa::tracked( + returns(copy), + cycle_initial = |_, id, _, _, _, _| ProjectedNarrowingCheckpoint::Narrowed(Type::divergent(id)), + cycle_fn = |db: &'db dyn Db, cycle, previous: &ProjectedNarrowingCheckpoint<'db>, result: ProjectedNarrowingCheckpoint<'db>, scope: ScopeId<'db>, _, _, base_ty| { + match result { + ProjectedNarrowingCheckpoint::Narrowed(ty) => ProjectedNarrowingCheckpoint::Narrowed( + ty.cycle_normalized(db, &ProgramEnvironment::from_scope(scope), previous.ty(base_ty), cycle) + ), + _ => result, + } + }, + heap_size = get_size2::GetSize::get_heap_size +)] +fn evaluate_projected_narrowing_checkpoint<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + place: ScopedPlaceId, + constraint: ScopedNarrowingConstraint, + base_ty: Type<'db>, +) -> ProjectedNarrowingCheckpoint<'db> { + let env = ProgramEnvironment::from_scope(scope); + let use_def = use_def_map(db, scope); + let evaluator = use_def.narrowing_evaluator(constraint); + let mut projector = NarrowingProjector::new( + db, + &env, + evaluator.narrowing_constraints(), + use_def.predicates(), + evaluator.predicate_narrowing_targets(), + place, + base_ty, + ); + let root = projector.project(constraint, false); + match root { + ProjectedNarrowingNodeId::ALWAYS_FALSE => ProjectedNarrowingCheckpoint::Unreachable, + ProjectedNarrowingNodeId::ALWAYS_TRUE => ProjectedNarrowingCheckpoint::Unconstrained, + _ => ProjectedNarrowingCheckpoint::Narrowed(projector.narrow_projected(root, base_ty)), + } +} + +/// Narrows bindings of one place while reusing their shared constraint suffixes. +pub(crate) struct NarrowingProjector<'a, 'db> { + db: &'db dyn Db, + env: &'a ProgramEnvironment<'db>, + constraints: &'a NarrowingConstraints, + predicates: &'a IndexSlice>, + predicate_narrowing_targets: &'a PredicateNarrowingTargets, + place: ScopedPlaceId, + base_ty: Type<'db>, + /// Checkpoint entries retain narrowed types, so projections are specific to the binding type. + project_cache: FxHashMap<(ScopedNarrowingConstraint, Type<'db>), ProjectedNarrowingNodeId>, + graph: ProjectedNarrowingGraph<'db>, + narrowed_cache: FxHashMap<(ProjectedNarrowingNodeId, Type<'db>), Type<'db>>, +} + +impl<'a, 'db> NarrowingProjector<'a, 'db> { + /// Creates a projector for narrowing `place`. + pub(crate) fn new( + db: &'db dyn Db, + env: &'a ProgramEnvironment<'db>, + constraints: &'a NarrowingConstraints, + predicates: &'a IndexSlice>, + predicate_narrowing_targets: &'a PredicateNarrowingTargets, + place: ScopedPlaceId, + base_ty: Type<'db>, + ) -> Self { + Self { + db, + env, + constraints, + predicates, + predicate_narrowing_targets, + place, + base_ty, + project_cache: FxHashMap::default(), + graph: ProjectedNarrowingGraph::default(), + narrowed_cache: FxHashMap::default(), + } + } + + /// Narrows a binding while reusing projections and shared suffixes from earlier bindings. + pub(crate) fn narrow( + &mut self, + constraint: ScopedNarrowingConstraint, + base_ty: Type<'db>, + ) -> Type<'db> { + self.base_ty = base_ty; + match constraint { + ScopedNarrowingConstraint::ALWAYS_TRUE => return base_ty, + ScopedNarrowingConstraint::ALWAYS_FALSE => return Type::Never, + _ => {} + } + + // Reachability gates can mention predicates that do not narrow this place. + // Avoid evaluating unrelated expressions, which can introduce inference cycles. + if !self.predicate_narrowing_targets.contains_place(self.place) { + return base_ty; + } + + let root = self.project(constraint, true); + self.narrow_projected(root, base_ty) + } + + /// Narrows a projected constraint while reusing suffix results for its original binding type. + /// + /// Registering each root lets the graph recognize shared joins incrementally. + fn narrow_projected( + &mut self, + root: ProjectedNarrowingNodeId, + base_ty: Type<'db>, + ) -> Type<'db> { + if root == ProjectedNarrowingNodeId::ALWAYS_TRUE { + return base_ty; + } + if root == ProjectedNarrowingNodeId::ALWAYS_FALSE { + return Type::Never; + } + self.graph.record_reference(root); + + let key = (root, base_ty); + if let Some(cached) = self.narrowed_cache.get(&key) { + return *cached; + } + + let mut context = ProjectedNarrowingContext { + db: self.db, + env: self.env, + base_ty, + graph: &self.graph, + join_cache: &mut self.narrowed_cache, + }; + let narrowed = context.narrow(root, None); + self.narrowed_cache.insert(key, narrowed); + narrowed + } + + /// Returns the cached positive and negative narrowing constraints for a predicate. + fn predicate_constraints( + &mut self, + predicate_id: ScopedPredicateId, + ) -> ( + Option>, + Option>, + ) { + if !self + .predicate_narrowing_targets + .contains(predicate_id, self.place) + { + return (None, None); + } + + let env = self.env; + let db = self.db; + if let Some(cached) = self.graph.predicate_constraints_cache.get(&predicate_id) { + return cached.clone(); + } + + let constraints = + infer_narrowing_constraints(db, env, self.predicates[predicate_id], self.place); + self.graph + .predicate_constraints_cache + .insert(predicate_id, constraints.clone()); + constraints + } + /// Interns a projected node, collapsing nodes with identical branches. fn add_node(&mut self, node: ProjectedNarrowingNode) -> ProjectedNarrowingNodeId { if node.if_uncertain == ProjectedNarrowingNodeId::ALWAYS_TRUE { @@ -957,43 +1222,23 @@ impl ProjectedNarrowingGraph<'_> { }); } - if let Some(cached) = self.node_cache.get(&node) { + if let Some(cached) = self.graph.node_cache.get(&node) { return *cached; } - let id = ProjectedNarrowingNodeId(self.nodes.len()); - self.nodes.push(node); - self.node_cache.insert(node, id); - id - } - - /// Returns the projected nodes that join multiple incoming paths. - /// - /// Projection interns equivalent subgraphs into a DAG. Caching each join lets narrowing - /// evaluate a shared suffix once and apply each incoming prefix constraint afterward. - fn joins(&self, root: ProjectedNarrowingNodeId) -> Vec { - let mut referenced = vec![false; self.nodes.len()]; - let mut joins = vec![false; self.nodes.len()]; - let mut visited = vec![false; self.nodes.len()]; - let mut pending = vec![root]; - - while let Some(id) = pending.pop() { - if id.is_terminal() || std::mem::replace(&mut visited[id.0], true) { - continue; - } - - let node = self.node(id); - for next in [node.if_true, node.if_uncertain, node.if_false] { - if !next.is_terminal() { - if std::mem::replace(&mut referenced[next.0], true) { - joins[next.0] = true; - } - pending.push(next); - } - } + let id = ProjectedNarrowingNodeId(self.graph.nodes.len()); + self.graph + .nodes + .push(ProjectedNarrowingEntry::Predicate(node)); + self.graph.referenced.push(false); + self.graph.joins.push(false); + self.graph.node_cache.insert(node, id); + + for next in [node.if_true, node.if_uncertain, node.if_false] { + self.graph.record_reference(next); } - joins + id } /// Combines two paths without copying one path into both outcomes of the other's predicate. @@ -1020,12 +1265,24 @@ impl ProjectedNarrowingGraph<'_> { } else { (right, left) }; - if let Some(cached) = self.or_cache.get(&key) { + if let Some(cached) = self.graph.or_cache.get(&key) { return *cached; } - let left_node = self.node(left); - let right_node = self.node(right); + let (left_node, right_node) = match (self.graph.node(left), self.graph.node(right)) { + (ProjectedNarrowingEntry::Checkpoint { constraint, .. }, _) => { + let expanded = self.expand_checkpoint(left, constraint); + return self.or(expanded, right); + } + (_, ProjectedNarrowingEntry::Checkpoint { constraint, .. }) => { + let expanded = self.expand_checkpoint(right, constraint); + return self.or(left, expanded); + } + ( + ProjectedNarrowingEntry::Predicate(left), + ProjectedNarrowingEntry::Predicate(right), + ) => (left, right), + }; let result = match left_node.atom.cmp(&right_node.atom).reverse() { std::cmp::Ordering::Equal => { let if_true = self.or(left_node.if_true, right_node.if_true); @@ -1058,66 +1315,35 @@ impl ProjectedNarrowingGraph<'_> { } }; - self.or_cache.insert(key, result); + self.graph.or_cache.insert(key, result); result } -} - -/// Removes predicates that cannot narrow one place from a narrowing constraint. -struct NarrowingProjector<'a, 'db> { - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - constraints: &'a NarrowingConstraints, - predicates: &'a IndexSlice>, - place: ScopedPlaceId, - project_cache: FxHashMap, - graph: ProjectedNarrowingGraph<'db>, -} - -impl<'a, 'db> NarrowingProjector<'a, 'db> { - /// Creates a projector for narrowing `place`. - fn new( - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - constraints: &'a NarrowingConstraints, - predicates: &'a IndexSlice>, - place: ScopedPlaceId, - ) -> Self { - Self { - db, - env, - constraints, - predicates, - place, - project_cache: FxHashMap::default(), - graph: ProjectedNarrowingGraph::default(), - } - } - /// Returns the cached positive and negative narrowing constraints for a predicate. - fn predicate_constraints( + /// Expands a deferred suffix when canonicalizing a join requires its predicates. + /// + /// Keeping checkpoints opaque during evaluation avoids repeated work. During projection, + /// however, inspecting their predicates lets complementary branches cancel before `TypeGuard` + /// replacement or ordinary narrowing is applied. + fn expand_checkpoint( &mut self, - predicate_id: ScopedPredicateId, - ) -> ( - Option>, - Option>, - ) { - let env = self.env; - let db = self.db; - if let Some(cached) = self.graph.predicate_constraints_cache.get(&predicate_id) { - return cached.clone(); + id: ProjectedNarrowingNodeId, + constraint: ScopedNarrowingConstraint, + ) -> ProjectedNarrowingNodeId { + if let Some(cached) = self.project_cache.get(&(constraint, self.base_ty)).copied() + && cached != id + { + return cached; } - - let constraints = - infer_narrowing_constraints(db, env, self.predicates[predicate_id], self.place); - self.graph - .predicate_constraints_cache - .insert(predicate_id, constraints.clone()); - constraints + self.project_cache.remove(&(constraint, self.base_ty)); + self.project(constraint, false) } /// Projects one constraint node into the graph for this place. - fn project(&mut self, root: ScopedNarrowingConstraint) -> ProjectedNarrowingNodeId { + fn project( + &mut self, + root: ScopedNarrowingConstraint, + use_root_checkpoint: bool, + ) -> ProjectedNarrowingNodeId { type Id = ScopedNarrowingConstraint; enum Action { Visit(Id), @@ -1133,14 +1359,63 @@ impl<'a, 'db> NarrowingProjector<'a, 'db> { while let Some(action) = actions.pop() { match action { Action::Visit(id) => { - if id.is_terminal() || self.project_cache.contains_key(&id) { + if id.is_terminal() || self.project_cache.contains_key(&(id, self.base_ty)) { continue; } let node = self.constraints.get_interior_node(id); let predicate = self.predicates[node.atom]; - - if matches!(predicate.node, PredicateNode::IsNonTerminalCall(_)) { + let index = node.atom.index(); + let checkpoint_position = + index ^ (index / NARROWING_EVALUATION_CHECKPOINT_INTERVAL); + if (id != root || use_root_checkpoint) + && (checkpoint_position + 1) + .is_multiple_of(NARROWING_EVALUATION_CHECKPOINT_INTERVAL) + && (self + .predicate_narrowing_targets + .contains(node.atom, self.place) + || matches!( + predicate.node, + PredicateNode::ContextManagerSuppresses { .. } + | PredicateNode::FinallyNormalPathImpossible { .. } + )) + { + let checkpoint = evaluate_projected_narrowing_checkpoint( + db, + predicate_scope(db, &predicate), + self.place, + id, + self.base_ty, + ); + let projected = match checkpoint { + ProjectedNarrowingCheckpoint::Unreachable => { + ProjectedNarrowingNodeId::ALWAYS_FALSE + } + ProjectedNarrowingCheckpoint::Unconstrained => { + ProjectedNarrowingNodeId::ALWAYS_TRUE + } + ProjectedNarrowingCheckpoint::Narrowed(ty) => { + let projected = ProjectedNarrowingNodeId(self.graph.nodes.len()); + self.graph.nodes.push(ProjectedNarrowingEntry::Checkpoint { + constraint: id, + ty, + }); + self.graph.referenced.push(false); + self.graph.joins.push(false); + projected + } + }; + self.project_cache.insert((id, self.base_ty), projected); + continue; + } + let is_control_flow_gate = matches!( + predicate.node, + PredicateNode::IsNonTerminalCall(_) + | PredicateNode::ContextManagerSuppresses { .. } + | PredicateNode::FinallyNormalPathImpossible { .. } + ); + + if is_control_flow_gate { actions.push(Action::AnalyzeNonTerminal(id)); actions.push(Action::Visit(node.if_uncertain)); } else { @@ -1157,7 +1432,9 @@ impl<'a, 'db> NarrowingProjector<'a, 'db> { Truthiness::AlwaysTrue => node.if_true, Truthiness::AlwaysFalse => node.if_false, Truthiness::Ambiguous => { - unreachable!("`IsNonTerminalCall` predicates should never be Ambiguous") + unreachable!( + "statically decidable predicates should never be Ambiguous" + ) } }; @@ -1168,8 +1445,8 @@ impl<'a, 'db> NarrowingProjector<'a, 'db> { let node = self.constraints.get_interior_node(id); let branch = self.projected_node(branch); let if_uncertain = self.projected_node(node.if_uncertain); - let projected = self.graph.or(branch, if_uncertain); - self.project_cache.insert(id, projected); + let projected = self.or(branch, if_uncertain); + self.project_cache.insert((id, self.base_ty), projected); } Action::FinishPredicate(id) => { let node = self.constraints.get_interior_node(id); @@ -1179,17 +1456,26 @@ impl<'a, 'db> NarrowingProjector<'a, 'db> { let (pos_constraint, neg_constraint) = self.predicate_constraints(node.atom); let projected = if pos_constraint.is_none() && neg_constraint.is_none() { - let either = self.graph.or(if_true, if_false); - self.graph.or(either, if_uncertain) + // This node represents `if_uncertain || (P && if_true) || (!P && if_false)`. + // Since the predicate `P` cannot narrow this place, remove it while retaining only branches that `P` can take. + // Including a statically unreachable branch could erase narrowing from the reachable branch. + match analyze_single(self.db, self.env, &self.predicates[node.atom]) { + Truthiness::AlwaysTrue => self.or(if_true, if_uncertain), + Truthiness::AlwaysFalse => self.or(if_false, if_uncertain), + Truthiness::Ambiguous => { + let either = self.or(if_true, if_false); + self.or(either, if_uncertain) + } + } } else { - self.graph.add_node(ProjectedNarrowingNode { + self.add_node(ProjectedNarrowingNode { atom: node.atom, if_true, if_uncertain, if_false, }) }; - self.project_cache.insert(id, projected); + self.project_cache.insert((id, self.base_ty), projected); } } } @@ -1201,7 +1487,7 @@ impl<'a, 'db> NarrowingProjector<'a, 'db> { match id { ScopedNarrowingConstraint::ALWAYS_TRUE => ProjectedNarrowingNodeId::ALWAYS_TRUE, ScopedNarrowingConstraint::ALWAYS_FALSE => ProjectedNarrowingNodeId::ALWAYS_FALSE, - _ => self.project_cache[&id], + _ => self.project_cache[&(id, self.base_ty)], } } } @@ -1212,25 +1498,24 @@ struct ProjectedNarrowingContext<'a, 'db> { env: &'a ProgramEnvironment<'db>, base_ty: Type<'db>, graph: &'a ProjectedNarrowingGraph<'db>, - /// Marks join boundaries in the projected DAG. - joins: Vec, - /// Caches each join's narrowed suffix type from its boundary. - join_cache: FxHashMap>, + /// Caches each shared suffix for the binding type being narrowed. + join_cache: &'a mut FxHashMap<(ProjectedNarrowingNodeId, Type<'db>), Type<'db>>, } impl<'db> ProjectedNarrowingContext<'_, 'db> { fn is_join(&self, id: ProjectedNarrowingNodeId) -> bool { - !id.is_terminal() && self.joins[id.0] + !id.is_terminal() && self.graph.joins[id.0] } /// Evaluates one projected join from its boundary and caches its narrowed suffix type. fn narrow_join(&mut self, id: ProjectedNarrowingNodeId) -> Type<'db> { - if let Some(cached) = self.join_cache.get(&id) { + let key = (id, self.base_ty); + if let Some(cached) = self.join_cache.get(&key) { return *cached; } let result = self.narrow_uncached(id, None); - self.join_cache.insert(id, result); + self.join_cache.insert(key, result); result } @@ -1265,7 +1550,12 @@ impl<'db> ProjectedNarrowingContext<'_, 'db> { if id == ProjectedNarrowingNodeId::ALWAYS_TRUE { apply_accumulated_narrowing(db, self.env, self.base_ty, accumulated) } else { - let node = self.graph.node(id); + let node = match self.graph.node(id) { + ProjectedNarrowingEntry::Predicate(node) => node, + ProjectedNarrowingEntry::Checkpoint { ty, .. } => { + return apply_accumulated_narrowing(db, self.env, ty, accumulated); + } + }; let (pos_constraint, neg_constraint) = self.graph.predicate_constraints_cache[&node.atom].clone(); @@ -1495,11 +1785,15 @@ fn analyze_single_pattern_predicate_kind<'db>( #[salsa::tracked( returns(copy), cycle_initial = |_, _, _, _, _| Truthiness::AlwaysTrue, - cycle_fn = |_, _, previous: &Truthiness, value, _, _, _| { - if previous.is_always_true() { - Truthiness::AlwaysTrue + cycle_fn = |_, cycle: &salsa::Cycle, previous: &Truthiness, result: Truthiness, _, _, _| { + // A call can determine whether its own target is reachable, as with `sys.exit()` before + // `import sys` in a loop. Expression inference can lose its previous result when it stops + // being a cycle head, so widen the predicate itself to ensure convergence. Delay widening + // to allow the optimistic initial value to resolve to a terminal call. + if cycle.iteration() > crate::TAINTED_CYCLES { + previous.or(result) } else { - value + result } }, heap_size = get_size2::GetSize::get_heap_size @@ -1592,6 +1886,89 @@ fn analyze_non_empty_iterable(db: &dyn Db, iterable: Expression) -> Truthiness { } } +/// Evaluate a condition without re-testing intermediate short-circuit results. +/// +/// `None` means evaluation cannot produce a result, as for an operand narrowed to `Never`. +/// This differs from ambiguous truthiness: in `flag and raises()`, where `raises()` returns +/// `Never`, only the falsy short-circuit path can complete. For `flag or raises()`, only the +/// truthy path can complete. Callers that cannot represent the absence of a result can +/// conservatively map `None` to [`Truthiness::Ambiguous`]. +pub(crate) fn analyze_condition_expression( + node: &ast::Expr, + leaf_truthiness: &impl Fn(&ast::Expr) -> Option, +) -> Option { + match node { + ast::Expr::BoolOp(ast::ExprBoolOp { op, values, .. }) => { + let short_circuit = Truthiness::from(op.is_or()); + let mut result = short_circuit.negate(); + for value in values { + let Some(truthiness) = analyze_condition_expression(value, leaf_truthiness) else { + return result.is_ambiguous().then_some(short_circuit); + }; + if truthiness == short_circuit { + return Some(short_circuit); + } + if truthiness.is_ambiguous() { + result = Truthiness::Ambiguous; + } + } + Some(result) + } + ast::Expr::UnaryOp(ast::ExprUnaryOp { + op: ast::UnaryOp::Not, + operand, + .. + }) => analyze_condition_expression(operand, leaf_truthiness).map(Truthiness::negate), + ast::Expr::If(ast::ExprIf { + test, body, orelse, .. + }) => match analyze_condition_expression(test, leaf_truthiness)? { + Truthiness::AlwaysTrue => analyze_condition_expression(body, leaf_truthiness), + Truthiness::AlwaysFalse => analyze_condition_expression(orelse, leaf_truthiness), + Truthiness::Ambiguous => { + let body_truthiness = analyze_condition_expression(body, leaf_truthiness); + let orelse_truthiness = analyze_condition_expression(orelse, leaf_truthiness); + match (body_truthiness, orelse_truthiness) { + (None, truthiness) | (truthiness, None) => truthiness, + (Some(body), Some(orelse)) => Some(if body == orelse { + body + } else { + Truthiness::Ambiguous + }), + } + } + }, + _ => leaf_truthiness(node), + } +} + +#[salsa::tracked( + returns(copy), + cycle_initial = |_, _, _| Truthiness::Ambiguous, + cycle_fn = |_, cycle: &salsa::Cycle, previous: &Truthiness, result: Truthiness, _| { + // A condition can control whether one of its own inputs is reachable. Expression inference + // can lose its previous result when it ceases to be a cycle head, so its type widening alone + // does not ensure that the condition's truthiness converges. Delay widening here to avoid + // retaining imprecise results from the first few iterations. + if cycle.iteration() > crate::TAINTED_CYCLES && *previous != result { + Truthiness::Ambiguous + } else { + result + } + }, + heap_size = get_size2::GetSize::get_heap_size +)] +fn analyze_condition<'db>(db: &'db dyn Db, expression: Expression<'db>) -> Truthiness { + let env = ProgramEnvironment::from_scope(expression.scope(db)); + let module = parsed_module(db, expression.python_file(db)).load(db); + let inference = infer_expression_types(db, expression, TypeContext::default()); + analyze_condition_expression(expression.node_ref(db).node(&module), &|node| { + inference + .comparison_truthiness(node) + .or_else(|| inference.expression_type(node).bool_if_inhabited(db, &env)) + }) + .unwrap_or(Truthiness::Ambiguous) +} + fn analyze_single(db: &dyn Db, env: &ProgramEnvironment<'_>, predicate: &Predicate) -> Truthiness { let _span = tracing::trace_span!("analyze_single", ?predicate).entered(); @@ -1601,6 +1978,32 @@ fn analyze_single(db: &dyn Db, env: &ProgramEnvironment<'_>, predicate: &Predica .bool(db, env) .negate_if(!predicate.is_positive) } + PredicateNode::Condition(test_expr) => { + analyze_condition(db, test_expr).negate_if(!predicate.is_positive) + } + PredicateNode::ChainedComparisonCondition(test_expr) => { + let inference = infer_expression_types(db, test_expr, TypeContext::default()); + let expression = test_expr.node_ref(db); + inference + .comparison_truthiness(expression) + .unwrap_or_else(|| inference.expression_type(expression).bool(db, env)) + .negate_if(!predicate.is_positive) + } + PredicateNode::ContextManagerSuppresses { + expression, + is_async, + } => Truthiness::from( + infer_same_file_expression_type(db, expression, TypeContext::default()) + .can_suppress_exceptions(db, env, EvaluationMode::from_is_async(is_async)), + ) + .negate_if(!predicate.is_positive), + PredicateNode::FinallyNormalPathImpossible { + scope, + continuation, + } => Truthiness::from( + evaluate_finally_continuation(db, scope, continuation).is_always_false(), + ) + .negate_if(!predicate.is_positive), PredicateNode::IsNonTerminalCall(CallableAndCallExpr { callable, call_expr, @@ -2026,16 +2429,19 @@ class TargetB: let constraints = NarrowingConstraints::from_test_nodes(nodes); let x = index.place_table(function_scope).symbol_id("x").unwrap(); let env = db.program_environment(); + let evaluator = use_def.narrowing_evaluator(ScopedNarrowingConstraint::ALWAYS_TRUE); let mut projector = NarrowingProjector::new( &db, &env, &constraints, &predicates, + evaluator.predicate_narrowing_targets(), ScopedPlaceId::Symbol(x), + Type::unknown(), ); assert_eq!( - projector.project(ScopedNarrowingConstraint::new(DEPTH - 1)), + projector.project(ScopedNarrowingConstraint::new(DEPTH - 1), false), ProjectedNarrowingNodeId::ALWAYS_TRUE ); Ok(()) diff --git a/crates/ty_python_semantic/src/reified.rs b/crates/ty_python_semantic/src/reified.rs index bda9ae2991..0af235c034 100644 --- a/crates/ty_python_semantic/src/reified.rs +++ b/crates/ty_python_semantic/src/reified.rs @@ -346,7 +346,7 @@ fn body_span(function: &ast::StmtFunctionDef) -> Option { } /// names of the class's type parameters that are reified, in declaration order -pub fn reified_class_type_param_names( +pub(crate) fn reified_class_type_param_names( source: &str, source_type: PySourceType, class: &ast::StmtClassDef, diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 9461fc04fb..a8a9ef15bd 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -17,6 +17,7 @@ use ty_module_resolver::{ use crate::Db; use crate::place::implicit_globals::all_implicit_module_globals; use crate::place::imported_symbol; +use crate::place::{builtins_module_scope, implicit_builtins_symbol_scope}; use crate::types::ide_support::{ImportAliasResolution, definition_for_name}; use crate::types::implicit_names::implicit_name; use crate::types::list_members::{all_members, all_reachable_members}; @@ -1072,6 +1073,39 @@ impl<'db> SemanticModel<'db> { line_index(self.db, self.file()) } + /// Returns whether `name` refers to a standard builtin in the scope containing `node`. + /// + /// This method uses a simplified implementation of name resolution: any binding or declaration + /// in a visible scope shadows the builtin, even if it does not reach `node`. As a result, it + /// can return `false` when the builtin is actually available. That is acceptable when deciding + /// whether to offer an autofix: we can safely omit the fix in edge cases where resolving the + /// name precisely would require more complex analysis. + /// + /// Definitions in a project-level `__builtins__.pyi` also shadow standard builtins. + pub(crate) fn definitely_has_builtin_binding( + &self, + name: &str, + node: ast::AnyNodeRef<'_>, + ) -> bool { + let index = semantic_index(self.db, self.program_file()); + let Some(scope) = self.scope(node) else { + return false; + }; + + if index.visible_ancestor_scopes(scope).any(|(scope, _)| { + index + .place_table(scope) + .symbol_by_name(name) + .is_some_and(|symbol| symbol.is_bound() || symbol.is_declared()) + }) { + return false; + } + + let env = self.program_environment(); + implicit_builtins_symbol_scope(self.db, &env, name) + .is_some_and(|scope| Some(scope) == builtins_module_scope(self.db, &env)) + } + /// Returns a map from symbol name to that symbol's /// type and definition site (if available). /// @@ -1983,7 +2017,7 @@ pub trait HasDefinition { fn definition<'db>(&self, model: &SemanticModel<'db>) -> Definition<'db>; } -pub(crate) trait HasOptionalDefinition { +trait HasOptionalDefinition { /// Returns the definition of `self`, if it has one. /// /// ## Panics diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index a2ed00284a..3cea501ce3 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1,7 +1,7 @@ use compact_str::{CompactString, ToCompactString}; use itertools::{Either, Itertools}; use ruff_diagnostics::{Edit, Fix}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use std::borrow::Cow; use std::cell::OnceCell; @@ -21,21 +21,29 @@ use ruff_python_ast::helpers::TypeModifier; use ruff_python_ast::name::Name; use ruff_text_size::Ranged; use smallvec::smallvec_inline; -use ty_module_resolver::{ImportingFile, KnownModule, Module, ModuleName, resolve_module}; +use ty_module_resolver::{ + ImportingFile, KnownModule, Module, ModuleName, file_to_module, resolve_module, +}; pub(crate) use self::callable::UpcastPolicy; use self::class::ClassInstanceFlags; pub use self::cyclic::CycleDetector; pub(crate) use self::cyclic::TypeTransformer; +use self::cyclic::{ActiveRecursionDetector, TypeIdentity}; +pub use self::dedicated::pytest::{ + FixtureBinding, FixtureExposure, FixtureNameSource, fixture_bindings_for_parameter, + fixture_exposures_for_definition, pytest_global_plugin_files, +}; pub(crate) use self::diagnostic::TypeCheckDiagnostics; pub(crate) use self::diagnostic::register_lints; pub use self::diagnostic::{ MISPLACED_DEPENDENCY, UNDECLARED_DEPENDENCY, UNDEFINED_REVEAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, }; +use self::infer::infer_function_default_types; pub(crate) use self::infer::{ - InferredDeclaration, TypeContext, infer_complete_scope_types, infer_deferred_types, - infer_definition_types, infer_expression_type, infer_expression_types, + ArgumentContextOrigin, InferredDeclaration, TypeContext, infer_complete_scope_types, + infer_deferred_types, infer_definition_types, infer_expression_type, infer_expression_types, infer_same_file_expression_type, infer_scope_types, is_discarded_dict_key_assignment, }; pub(crate) use self::iteration::{ @@ -50,14 +58,14 @@ pub(crate) use self::match_pattern::{ starred_sequence_pattern_type, typed_dict_matches_class_pattern, }; pub(crate) use self::relation_error::{ErrorContext, ErrorContextTree, ParameterDescription}; -use self::set_theoretic::KnownUnion; use self::set_theoretic::NegativeIntersectionElements; pub(crate) use self::set_theoretic::builder::{ IntersectionBuilder, UnionAccumulator, UnionBuilder, }; pub use self::set_theoretic::{IntersectionType, UnionType}; -pub use self::signatures::ParameterKind; +use self::set_theoretic::{KnownUnion, RecursivelyDefined}; pub(crate) use self::signatures::Signature; +pub use self::signatures::{ParameterDefault, ParameterKind}; pub(crate) use self::subclass_of::{SubclassOfInner, SubclassOfType}; pub(crate) use self::type_expansion::expand_type; pub(crate) use crate::diagnostic::add_inferred_python_version_hint_to_diagnostic; @@ -75,14 +83,15 @@ pub(crate) use crate::types::callable::{CallableType, CallableTypes}; pub(crate) use crate::types::class_base::ClassBase; use crate::types::constraints::ConstraintSetBuilder; use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder}; +pub(crate) use crate::types::dedicated::role::function_framework_role; pub use crate::types::dedicated::role::{ - FrameworkRole, FunctionFrameworkRole, class_body_annotation_is_semantic, class_framework_role, - function_framework_role, + FrameworkRole, class_body_annotation_is_semantic, class_framework_role, }; -pub use crate::types::deferred::{DeferredOperation, DeferredType}; +pub(crate) use crate::types::deferred::DeferredOperation; +pub use crate::types::deferred::DeferredType; use crate::types::diagnostic::{ AttributeAccessMethod, INVALID_AWAIT, INVALID_TYPE_FORM, report_bad_attribute_access_call, - report_bad_dunder_get_call, + report_bad_dunder_get_call, report_bad_import_call, }; pub use crate::types::display::{DisplaySettings, SourceSpelling, TypeDetail, TypeDisplayDetails}; pub use crate::types::enums::basedpython_is_keeps_identity; @@ -90,7 +99,7 @@ pub(crate) use crate::types::enums::{EnumClassLiteral, EnumComplementType, enum_ pub(crate) use crate::types::equality::{ComparisonSoundnessPolicy, equality_truthiness}; use crate::types::function::{ DataclassTransformerFlags, DataclassTransformerParams, FunctionDecorators, FunctionSpans, - FunctionType, KnownFunction, + FunctionType, KnownFunction, OverloadLiteral, }; pub(crate) use crate::types::generics::GenericContext; use crate::types::generics::{ApplySpecialization, Specialization, bind_typevar}; @@ -106,28 +115,32 @@ use crate::types::newtype::NewType; pub use crate::types::overlapping::OverlappingType; use crate::types::regex::RegexGroups; pub use crate::types::restricted::RestrictedType; -use crate::types::signatures::{ConcatenateTail, walk_signature}; +use crate::types::signatures::{ + ConcatenateTail, walk_signature, walk_signature_without_return_type, +}; pub(crate) use crate::types::signatures::{Parameter, Parameters}; use crate::types::special_form::TypeQualifier; use crate::types::tuple::TupleSpec; pub use crate::types::type_alias::TypeAliasType; pub use crate::types::type_form::TypeFormType; pub(crate) use crate::types::typed_dict::TypedDictType; -pub(crate) use crate::types::typevar::TypeVarBoundOrConstraints; -pub use crate::types::typevar::{ - BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, ParamSpecAttrKind, TypeVarKind, +pub(crate) use crate::types::typevar::{ + BindingContext, BoundTypeVarIdentity, ParamSpecAttrKind, TypeVarBoundOrConstraints, TypeVarNonce, }; +pub use crate::types::typevar::{BoundTypeVarInstance, TypeVarKind}; use crate::types::typevar::{TypeVarInstance, TypeVarSet}; pub use crate::types::unsafe_union::UnsafeUnionType; pub use crate::types::variance::TypeVarVariance; -use crate::types::variance::VarianceInferable; -use crate::types::visitor::{any_over_type, dynamic_content}; +use crate::types::variance::{VarianceInferable, VarianceTerm}; +use crate::types::visitor::{ + any_over_type, any_over_type_including_alias_arguments, dynamic_content, +}; use crate::{Db, FxOrderSet, HasType, Program, SemanticModel}; pub(crate) use class::{ ClassLiteral, ClassLiteralFlags, ClassType, GenericAlias, StaticClassLiteral, }; -pub use class::{KnownClass, MethodDecorator}; +pub use class::{KnownClass, MethodDecorator, SlotDescriptorType}; use instance::Protocol; pub use instance::{NominalInstanceType, ProtocolInstanceType}; use protocol_class::ReifiedMember; @@ -150,7 +163,6 @@ pub(crate) use literal::{ }; use ruff_db::files::File; pub use special_form::SpecialFormType; -pub(crate) use special_form::TypedDictModule; use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::place::ScopedPlaceId; use ty_python_core::scope::ScopeId; @@ -240,6 +252,7 @@ pub(crate) mod visibility; mod visitor; mod definition; +pub(crate) mod definition_resolution; #[cfg(test)] mod property_tests; pub(crate) mod subscript; @@ -341,7 +354,7 @@ pub(crate) fn binding_type<'db>(db: &'db dyn Db, definition: Definition<'db>) -> inference.binding_type(definition) } -/// Returns whether a definition represents a value that exists at runtime. +/// Returns whether a definition may represent a value that exists at runtime. /// /// Type-checking-only decorators and guards never represent runtime values. Private type-variable /// declarations, explicit aliases, and unambiguous typing aliases in stub files are also @@ -354,8 +367,27 @@ pub(crate) fn binding_type<'db>(db: &'db dyn Db, definition: Definition<'db>) -> /// _runtime_callback = callbacks[0] # Runtime value. /// ``` #[salsa::tracked(returns(copy))] -pub(crate) fn exists_at_runtime<'db>(db: &'db dyn Db, definition: Definition<'db>) -> bool { +pub(crate) fn may_exist_at_runtime<'db>(db: &'db dyn Db, definition: Definition<'db>) -> bool { let file = definition.program_file(db); + let parsed = parsed_module(db, file.python_file(db)); + let module = parsed.load(db); + + // Definitions inside an `if TYPE_CHECKING` block are never available at runtime. + if semantic_index(db, file).is_in_type_checking_block( + definition.file_scope(db), + definition.full_range(db, &module).range(), + ) { + return false; + } + + // A declaration (without binding) can describe a value initialized elsewhere, but inference + // only records its declared type. Treat it as a possible runtime value without querying the + // type of its binding. + let is_stub = file.file(db).is_stub(db); + if !definition.kind(db).category(is_stub, &module).is_binding() { + return true; + } + let inference = infer_definition_types(db, definition); let ty = inference.binding_type(definition); @@ -368,19 +400,8 @@ pub(crate) fn exists_at_runtime<'db>(db: &'db dyn Db, definition: Definition<'db return false; } - let parsed = parsed_module(db, file.python_file(db)); - let module = parsed.load(db); - - // Definitions inside an `if TYPE_CHECKING` block are never available at runtime. - if semantic_index(db, file).is_in_type_checking_block( - definition.file_scope(db), - definition.full_range(db, &module).range(), - ) { - return false; - } - // The remaining heuristics only apply to stub definitions. - if !file.file(db).is_stub(db) { + if !is_stub { return true; } @@ -464,8 +485,14 @@ fn definition_expression_type<'db>( let inference = infer_definition_types(db, definition); if let Some(ty) = inference.try_expression_type(expression) { ty + } else if let Some(ty) = + infer_deferred_types(db, definition).try_expression_type(expression) + { + ty + } else if matches!(definition.kind(db), DefinitionKind::Function(_)) { + infer_function_default_types(db, definition).expression_type(expression) } else { - infer_deferred_types(db, definition).expression_type(expression) + Type::unknown() } } else { // expression is in a type-params sub-scope @@ -503,6 +530,34 @@ fn definition_expression_annotation<'db>( } } +/// Active recursion state shared across nested type operations. +/// +/// A transformation cache belongs to one mapping, but recursion can span specialization, +/// materialization, and meta-type projection. Preserve this context when starting a new mapping +/// visitor. Each operation keeps its own guards because its recursion keys and cycle fallbacks +/// differ. +#[derive(Default)] +struct TypeRecursionContext<'db> { + meta_type: MetaTypeRecursion<'db>, +} + +/// Guards shared by meta-type projections and the specializations they trigger. +/// +/// Each projection also tracks direct alias recursion locally: those cycles add no new classes, +/// whereas re-entering through another projection can introduce metaclasses. +#[derive(Default)] +struct MetaTypeRecursion<'db> { + aliases: ActiveRecursionDetector<(Program<'db>, TypeAliasType<'db>)>, + growing_aliases: ActiveRecursionDetector<(Program<'db>, Definition<'db>)>, + typevars: ActiveRecursionDetector<(Program<'db>, BoundTypeVarIdentity<'db>)>, +} + +impl MetaTypeRecursion<'_> { + fn is_active(&self) -> bool { + !self.aliases.is_empty() || !self.growing_aliases.is_empty() || !self.typevars.is_empty() + } +} + struct ApplyTypeMappingTag; struct ApplyMaterializationEquivalence; @@ -516,6 +571,9 @@ type MaterializationEquivalenceVisitor<'db> = /// reuse the result of another. pub(crate) struct ApplyTypeMappingVisitor<'env, 'db> { env: &'env ProgramEnvironment<'db>, + recursion_context: Option<&'env TypeRecursionContext<'db>>, + /// Whether materialization also transforms type-variable bounds and defaults. + materialize_typevar_bounds_and_defaults: bool, default: OnceCell>>, top_materialization: OnceCell>>, bottom_materialization: OnceCell>>, @@ -530,6 +588,8 @@ impl<'env, 'db> ApplyTypeMappingVisitor<'env, 'db> { fn new(env: &'env ProgramEnvironment<'db>) -> Self { Self { env, + recursion_context: None, + materialize_typevar_bounds_and_defaults: true, default: OnceCell::default(), top_materialization: OnceCell::default(), bottom_materialization: OnceCell::default(), @@ -541,6 +601,18 @@ impl<'env, 'db> ApplyTypeMappingVisitor<'env, 'db> { } } + fn with_recursion_context(mut self, context: Option<&'env TypeRecursionContext<'db>>) -> Self { + self.recursion_context = context; + self + } + + fn project_meta_type(&self, db: &'db dyn Db, ty: Type<'db>) -> Type<'db> { + match self.recursion_context { + Some(context) => ty.to_meta_type_with_recursion(db, self.env, context), + None => ty.to_meta_type(db, self.env), + } + } + fn materialization_equivalence(&self) -> &MaterializationEquivalenceVisitor<'db> { self.materialization_equivalence .get_or_init(|| Rc::new(CycleDetector::new(true))) @@ -593,6 +665,8 @@ impl<'env, 'db> ApplyTypeMappingVisitor<'env, 'db> { Self { materialization_equivalence, + recursion_context: self.recursion_context, + materialize_typevar_bounds_and_defaults: self.materialize_typevar_bounds_and_defaults, ..Self::new(self.env) } } @@ -609,6 +683,61 @@ pub(crate) struct FindLegacyTypeVars; type SpecializationVisitor<'db> = CycleDetector<'db, VisitSpecialization, Type<'db>, (), 3>; struct VisitSpecialization; +/// The standard-library `typing` module or its `typing_extensions` backport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] +pub enum TypingModule { + /// The standard-library `typing` module. + Typing, + /// The `typing_extensions` backport. + TypingExtensions, +} + +impl TypingModule { + /// Return the module for a `TypedDict` special form, including a union of the special forms + /// exported by `typing` and `typing_extensions`. + fn from_typed_dict_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option { + match ty { + Type::SpecialForm(SpecialFormType::TypedDict(module)) => Some(module), + Type::Union(union) => { + let mut elements = union.elements(db).iter(); + let Type::SpecialForm(SpecialFormType::TypedDict(module)) = elements.next()? else { + return None; + }; + elements.try_fold(*module, |module, element| { + let Type::SpecialForm(SpecialFormType::TypedDict(element_module)) = element + else { + return None; + }; + // `typing_extensions.TypedDict` always offers strictly more functionality than `typing.TypedDict`. + // If any element is from `typing`, we therefore infer that the type is a `typing.TypedDict`, + // since an operation on a union is only valid if the operation is valid on all elements in the + // union. + Some(match (module, element_module) { + (Self::TypingExtensions, Self::TypingExtensions) => Self::TypingExtensions, + _ => Self::Typing, + }) + }) + } + _ => None, + } + } + + const fn from_type_alias_class(class: KnownClass) -> Option { + match class { + KnownClass::TypeAliasType => Some(Self::Typing), + KnownClass::ExtensionsTypeAliasType => Some(Self::TypingExtensions), + _ => None, + } + } + + const fn type_alias_class(self) -> KnownClass { + match self { + Self::Typing => KnownClass::TypeAliasType, + Self::TypingExtensions => KnownClass::ExtensionsTypeAliasType, + } + } +} + /// Whether a type represents the upper or lower bound of a gradual type. /// /// For generic specializations, this matters only if there is at least one invariant or constrained @@ -740,6 +869,12 @@ enum MemberLookupErrorKind<'db> { name: Type<'db>, }, + /// An invalid module-level `__getattr__` call, stored without its call bindings. + ModuleGetAttr { + callable: Type<'db>, + name: Type<'db>, + }, + /// An invalid attribute-interception call, represented by its receiver and attribute name. GetAttribute { receiver: Type<'db>, @@ -751,7 +886,7 @@ enum MemberLookupErrorKind<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] struct MemberLookupError<'db> { #[returns(copy)] - fallback_member: PlaceAndQualifiers<'db>, + fallback_member: ResolvedMember<'db>, #[returns(copy)] kind: MemberLookupErrorKind<'db>, } @@ -819,22 +954,200 @@ impl<'db> MemberLookupError<'db> { ); } } - MemberLookupErrorKind::DescriptorGet(_) => {} + MemberLookupErrorKind::ModuleGetAttr { .. } + if assigned_type.is_none() + && let Some(failure) = self.module_getattr_call_failure(db, env) => + { + report_bad_attribute_access_call( + context, + &failure, + object_type, + target, + AttributeAccessMethod::GetAttr, + ); + } + MemberLookupErrorKind::DescriptorGet(_) + | MemberLookupErrorKind::ModuleGetAttr { .. } => {} + } + } + + /// Reports a failed module `__getattr__` call on a `from` import. + /// + /// Imports defer this diagnostic until they have ruled out a real submodule: + /// + /// ```python + /// from package import missing # Calls package.__getattr__("missing"). + /// ``` + fn report_module_getattr_import_diagnostic( + self, + context: &InferContext<'db, '_>, + module: ModuleLiteralType<'db>, + target: &ast::Alias, + name: &str, + ) { + if let Some(failure) = + self.module_getattr_call_failure(context.db(), context.program_environment()) + { + report_bad_import_call(context, &failure, module, target, name); } } + + /// Recreates a failed module `__getattr__` call without caching its call bindings. + fn module_getattr_call_failure( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option> { + let MemberLookupErrorKind::ModuleGetAttr { callable, name } = self.kind(db) else { + return None; + }; + + callable + .try_call(db, env, &CallArguments::positional([name])) + .err() + } } /// A resolved member or an implicit-call error that retains its recovery value. /// /// Unlike [`crate::place::LookupResult`], errors here describe failed attribute-access operations, /// not undefined or possibly undefined places. -type MemberLookupResult<'db> = Result, MemberLookupError<'db>>; +type MemberLookupResult<'db> = Result, MemberLookupError<'db>>; + +/// A member and the property accessors needed to report deprecations at its use site. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +enum ResolvedMember<'db> { + /// A member with no deprecated property accessors. + Plain(PlaceAndQualifiers<'db>), + /// A member with deprecated property accessors, stored separately to keep ordinary lookups compact. + WithDeprecations(DeprecatedMember<'db>), +} + +/// Only members with deprecated property accessors need this additional storage. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct DeprecatedMember<'db> { + #[returns(copy)] + member: PlaceAndQualifiers<'db>, + #[returns(copy)] + properties: PropertyDeprecations<'db>, +} + +impl get_size2::GetSize for DeprecatedMember<'_> {} + +/// Deprecated property accessors retained independently of descriptor types. Distinct property +/// objects are disjoint types, but either can implement an attribute on an intersection. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct PropertyDeprecations<'db> { + #[returns(ref)] + getters: Box<[OverloadLiteral<'db>]>, + #[returns(ref)] + setters: Box<[OverloadLiteral<'db>]>, + #[returns(ref)] + deleters: Box<[OverloadLiteral<'db>]>, +} + +impl get_size2::GetSize for PropertyDeprecations<'_> {} + +impl<'db> PropertyDeprecations<'db> { + fn functions(self, db: &'db dyn Db, access: ast::ExprContext) -> &'db [OverloadLiteral<'db>] { + match access { + ast::ExprContext::Load => self.getters(db), + ast::ExprContext::Store => self.setters(db), + ast::ExprContext::Del => self.deleters(db), + ast::ExprContext::Invalid => &[], + } + } + + fn getters_only(self, db: &'db dyn Db) -> Self { + Self::new(db, self.getters(db), [].as_slice(), [].as_slice()) + } + + /// Retain either alternative's deprecations: a union can invoke either accessor. + fn union(self, db: &'db dyn Db, other: Self) -> Self { + self.combine(db, other, false) + } + + /// Retain deprecations only for access kinds deprecated in both alternatives. A + /// non-deprecated getter can suppress read warnings without suppressing write warnings. + fn intersection(self, db: &'db dyn Db, other: Self) -> Self { + self.combine(db, other, true) + } + + fn combine(self, db: &'db dyn Db, other: Self, intersection: bool) -> Self { + let combine = |left: &[OverloadLiteral<'db>], right: &[OverloadLiteral<'db>]| { + if intersection && (left.is_empty() || right.is_empty()) { + Box::<[_]>::default() + } else { + left.iter().chain(right).copied().unique().collect() + } + }; + Self::new( + db, + combine(self.getters(db), other.getters(db)), + combine(self.setters(db), other.setters(db)), + combine(self.deleters(db), other.deleters(db)), + ) + } +} + +impl<'db> ResolvedMember<'db> { + fn member(self, db: &'db dyn Db) -> PlaceAndQualifiers<'db> { + match self { + Self::Plain(member) => member, + Self::WithDeprecations(member) => member.member(db), + } + } + + fn deprecated_properties(self, db: &'db dyn Db) -> Option> { + match self { + Self::WithDeprecations(member) => Some(member.properties(db)), + Self::Plain(_) => None, + } + } + + fn new( + db: &'db dyn Db, + member: PlaceAndQualifiers<'db>, + properties: Option>, + ) -> Self { + match properties { + Some(properties) => { + Self::WithDeprecations(DeprecatedMember::new(db, member, properties)) + } + None => Self::Plain(member), + } + } + + /// Transform the member's value type without changing its property accessor deprecations. + fn map_type(self, db: &'db dyn Db, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + Self::new( + db, + self.member(db).map_type(f), + self.deprecated_properties(db), + ) + } +} + +/// Combine accessor deprecations from alternative lookup paths. A non-deprecated path (`None`) +/// does not suppress deprecations from another possible path. +fn union_deprecated_properties<'db>( + db: &'db dyn Db, + left: Option>, + right: Option>, +) -> Option> { + match (left, right) { + (Some(left), Some(right)) => Some(left.union(db, right)), + _ => left.or(right), + } +} fn member_lookup_result<'db>( db: &'db dyn Db, member: PlaceAndQualifiers<'db>, error: Option>, + properties: Option>, ) -> MemberLookupResult<'db> { + let member = ResolvedMember::new(db, member, properties); match error { Some(kind) => Err(MemberLookupError::new(db, member, kind)), None => Ok(member), @@ -847,22 +1160,63 @@ fn map_member_lookup_type<'db>( f: impl FnOnce(Type<'db>) -> Type<'db>, ) -> MemberLookupResult<'db> { match result { - Ok(member) => Ok(member.map_type(f)), + Ok(member) => Ok(member.map_type(db, f)), Err(error) => Err(MemberLookupError::new( db, - error.fallback_member(db).map_type(f), + error.fallback_member(db).map_type(db, f), error.kind(db), )), } } +fn distribute_member_lookup_over_bound_or_constraints<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + bound_or_constraints: TypeVarBoundOrConstraints<'db>, + symbolic_receiver: Type<'db>, + name: &str, + policy: MemberLookupPolicy, +) -> MemberLookupResult<'db> { + match bound_or_constraints { + TypeVarBoundOrConstraints::UpperBound(bound) => bound + .member_lookup_with_policy_and_receiver(db, env, name, policy, Some(symbolic_receiver)), + TypeVarBoundOrConstraints::Constraints(constraints) => { + let mut error = None; + let mut properties = None; + let member = constraints.map_with_boundness_and_qualifiers(db, env, |constraint| { + let result = constraint.member_lookup_with_policy_and_receiver( + db, + env, + name, + policy, + Some(*constraint), + ); + let result = + map_member_lookup_type(db, result, |ty| match ty { + Type::BoundMethod(method) => Type::BoundMethod( + method.with_signature_receiver(db, symbolic_receiver, *constraint), + ), + _ => ty, + }); + error = error.or_else(|| result.err().map(|error| error.kind(db))); + let member = result.unwrap_or_else(|error| error.fallback_member(db)); + properties = + union_deprecated_properties(db, properties, member.deprecated_properties(db)); + member.member(db) + }); + member_lookup_result(db, member, error, properties) + } + } +} + fn member_lookup_or_fall_back_to<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, result: MemberLookupResult<'db>, fallback_fn: impl FnOnce() -> MemberLookupResult<'db>, ) -> MemberLookupResult<'db> { - let member = result.unwrap_or_else(|error| error.fallback_member(db)); + let resolved = result.unwrap_or_else(|error| error.fallback_member(db)); + let member = resolved.member(db); match member.place { Place::Undefined => fallback_fn(), Place::Defined(DefinedPlace { @@ -877,11 +1231,16 @@ fn member_lookup_or_fall_back_to<'db>( let fallback_member = fallback.unwrap_or_else(|error| error.fallback_member(db)); member_lookup_result( db, - member.or_fall_back_to(db, env, || fallback_member), + member.or_fall_back_to(db, env, || fallback_member.member(db)), result .err() .map(|error| error.kind(db)) .or_else(|| fallback.err().map(|error| error.kind(db))), + union_deprecated_properties( + db, + resolved.deprecated_properties(db), + fallback_member.deprecated_properties(db), + ), ) } } @@ -900,18 +1259,25 @@ fn cycle_normalized_member_lookup<'db>( .filter(|_| cycle.iteration() <= crate::TAINTED_CYCLES || previous.is_err()); let member = result.unwrap_or_else(|error| error.fallback_member(db)); let previous = previous.unwrap_or_else(|error| error.fallback_member(db)); - member_lookup_result(db, member.cycle_normalized(db, env, previous, cycle), error) + member_lookup_result( + db, + member + .member(db) + .cycle_normalized(db, env, previous.member(db), cycle), + error, + member.deprecated_properties(db), + ) } impl<'db> From> for MemberLookupResult<'db> { fn from(member: PlaceAndQualifiers<'db>) -> Self { - Ok(member) + Ok(ResolvedMember::Plain(member)) } } impl<'db> From> for MemberLookupResult<'db> { fn from(place: Place<'db>) -> Self { - Ok(place.into()) + Ok(ResolvedMember::Plain(place.into())) } } @@ -1114,7 +1480,92 @@ pub enum PropertyAccessorRole { Deleter, } -/// Represents an instance of `builtins.property` or `enum.property`. +/// The nominal class of a precise property. Known classes remain lazy so synthesized properties +/// do not need to resolve typeshed just to record their class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub enum PropertyInstanceClass<'db> { + Builtin, + Enum, + Subclass(ClassType<'db>), +} + +impl<'db> PropertyInstanceClass<'db> { + fn from_class(db: &'db dyn Db, class: ClassType<'db>) -> Self { + match class.known(db) { + Some(KnownClass::Property) => Self::Builtin, + Some(KnownClass::EnumProperty) => Self::Enum, + _ => Self::Subclass(class), + } + } + + fn to_class_literal(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + match self { + Self::Builtin => KnownClass::Property.to_class_literal(db, env), + Self::Enum => KnownClass::EnumProperty.to_class_literal(db, env), + Self::Subclass(class) => class.into(), + } + } + + fn to_instance(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + match self { + Self::Builtin => KnownClass::Property.to_instance(db, env), + Self::Enum => KnownClass::EnumProperty.to_instance(db, env), + Self::Subclass(class) => Type::instance(db, env, class), + } + } +} + +/// Identifies the actual implementation, rather than a method with the same name on a subclass. +fn is_property_method<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + function: FunctionType<'db>, +) -> bool { + let class = match file_to_module(db, function.program_file(db).resolver_file(db)) + .and_then(|module| module.known(db)) + { + Some(KnownModule::Builtins) => KnownClass::Property, + Some(KnownModule::Enum | KnownModule::Types) => KnownClass::EnumProperty, + _ => return false, + }; + + class + .try_to_class_literal(db, env) + .and_then(|class| { + ClassLiteral::Static(class) + .class_member(db, env, function.name(db), MemberLookupPolicy::default()) + .place + .ignore_possibly_undefined() + }) + .and_then(Type::as_function_literal) + // Comparing literals avoids the cross-module AST dependency of `FunctionType::definition`. + .is_some_and(|original| original.literal(db) == function.literal(db)) +} + +/// Recognizes inherited property descriptor methods without replacing subclass overrides. +fn property_wrapper_descriptor<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + name: &str, + member: Type<'db>, +) -> Type<'db> { + let wrapper = match name { + "__get__" => WrapperDescriptorKind::PropertyDunderGet, + "__set__" => WrapperDescriptorKind::PropertyDunderSet, + "__delete__" => WrapperDescriptorKind::PropertyDunderDelete, + _ => return member, + }; + if member + .as_function_literal() + .is_some_and(|function| is_property_method(db, env, function)) + { + Type::WrapperDescriptor(wrapper) + } else { + member + } +} + +/// Represents a property with known accessors and the standard descriptor behavior. #[salsa::interned(debug, constructor=new_internal, heap_size=ruff_memory_usage::heap_size)] pub struct PropertyInstanceType<'db> { #[returns(copy)] @@ -1124,7 +1575,7 @@ pub struct PropertyInstanceType<'db> { #[returns(copy)] pub deleter: Option>, #[returns(copy)] - instance_class: KnownClass, + instance_class: PropertyInstanceClass<'db>, } fn walk_property_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( @@ -1132,6 +1583,9 @@ fn walk_property_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( property: PropertyInstanceType<'db>, visitor: &V, ) { + if let PropertyInstanceClass::Subclass(class) = property.instance_class(db) { + visitor.visit_type(db, class.into()); + } if let Some(getter) = property.getter(db) { visitor.visit_type(db, getter); } @@ -1153,16 +1607,23 @@ impl<'db> PropertyInstanceType<'db> { setter: Option>, deleter: Option>, ) -> Self { - Self::new_internal(db, getter, setter, deleter, KnownClass::Property) + Self::new_internal(db, getter, setter, deleter, PropertyInstanceClass::Builtin) } - fn new_enum_property( + fn new_with_class( db: &'db dyn Db, + class: ClassType<'db>, getter: Option>, setter: Option>, deleter: Option>, ) -> Self { - Self::new_internal(db, getter, setter, deleter, KnownClass::EnumProperty) + Self::new_internal( + db, + getter, + setter, + deleter, + PropertyInstanceClass::from_class(db, class), + ) } fn with_accessors( @@ -1227,7 +1688,13 @@ impl<'db> PropertyInstanceType<'db> { let deleter = self .deleter(db) .map(|ty| ty.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)); - self.with_accessors(db, getter, setter, deleter) + let instance_class = match self.instance_class(db) { + PropertyInstanceClass::Subclass(class) => PropertyInstanceClass::Subclass( + class.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor), + ), + class => class, + }; + Self::new_internal(db, getter, setter, deleter, instance_class) } fn recursive_type_normalized_impl( @@ -1261,7 +1728,19 @@ impl<'db> PropertyInstanceType<'db> { ), None => None, }; - Some(self.with_accessors(db, getter, setter, deleter)) + let instance_class = match self.instance_class(db) { + PropertyInstanceClass::Subclass(class) => PropertyInstanceClass::Subclass( + class.recursive_type_normalized_impl(db, env, div, nested)?, + ), + class => class, + }; + Some(Self::new_internal( + db, + getter, + setter, + deleter, + instance_class, + )) } fn find_legacy_typevars_impl( @@ -1272,6 +1751,9 @@ impl<'db> PropertyInstanceType<'db> { typevars: &mut FxOrderSet>, visitor: &FindLegacyTypeVarsVisitor<'db>, ) { + if let PropertyInstanceClass::Subclass(class) = self.instance_class(db) { + class.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); + } if let Some(ty) = self.getter(db) { ty.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); } @@ -1474,8 +1956,10 @@ pub enum Type<'db> { /// created as a result of some runtime operation (e.g. a type-alias statement, /// a typevar definition, or `Generic[T]` in a class's bases list). KnownInstance(KnownInstanceType<'db>), - /// An instance of `builtins.property` + /// A Python property with specialized getter, setter, and deleter types. PropertyInstance(PropertyInstanceType<'db>), + /// An interpreter-created descriptor for an instance slot. + SlotDescriptor(SlotDescriptorType<'db>), /// The set of objects in any of the types in the union Union(UnionType<'db>), /// The set of objects in all of the types in the intersection @@ -1536,6 +2020,33 @@ pub enum Type<'db> { NewTypeInstance(NewType<'db>), } +/// The result of discarding disjoint elements from a union. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DiscardDisjointUnionElementsResult<'db> { + /// The remaining type, or the unchanged input if it is not a union. + Retained(Type<'db>), + /// Every union element is disjoint from the target. + AllDisjoint, +} + +impl<'db> DiscardDisjointUnionElementsResult<'db> { + /// Returns the retained type, or `Never` if every union element was disjoint. + fn or_never(self) -> Type<'db> { + match self { + Self::Retained(ty) => ty, + Self::AllDisjoint => Type::Never, + } + } + + /// Returns the retained type, or `original` if every union element was disjoint. + fn unless_all_disjoint(self, original: Type<'db>) -> Type<'db> { + match self { + Self::Retained(ty) => ty, + Self::AllDisjoint => original, + } + } +} + /// The result of projecting class-object types into the corresponding instance types. /// /// An exact projection preserves all class-object constraints relevant to a `type[T]` relation; @@ -1625,6 +2136,30 @@ fn recursive_type_normalize_type_guard_like<'db, T: TypeGuardLike<'db>>( Some(guard.with_type(db, ty)) } +/// Whether generator-type extraction supplies defaults for iterator annotations. +/// +/// `Iterator[T]` and `AsyncIterator[T]` constrain yielded values but do not declare +/// send or return types. Defaults used to check a generator body do not describe +/// an arbitrary iterator's termination value or establish a send requirement. +#[derive(Clone, Copy)] +enum GeneratorTypeMode { + /// Extract parameters exposed by `Generator` or `AsyncGenerator`, without + /// supplying defaults for plain iterators. + /// + /// Use this when inferring a delegated iterator's `yield from` result or + /// determining whether an outer generator annotation declares a send type. + /// An `Iterator[T]` can terminate with `StopIteration(42)`, so its annotation + /// does not imply that the `yield from` result is `None`. + GeneratorOnly, + /// Also recognize `Iterator[T]` and `AsyncIterator[T]`, using `T` as the yield + /// type and `None` as both the send and return types. + /// + /// These defaults support inference of `yield` expressions and validation of + /// `yield` and `return` statements in generator bodies. Return-type extraction + /// also uses this mode, including when inferring `await` expressions. + IteratorDefaults, +} + #[derive(Debug, Clone, Copy)] #[expect(clippy::struct_field_names)] struct GeneratorTypes<'db> { @@ -1719,11 +2254,7 @@ impl<'db> Type<'db> { /// /// A marker stands for a type a cycle has not reached yet, so a type carrying one is not an /// answer — it is the shape of an answer with a hole where the cycle still is. - pub(crate) fn mentions_divergence( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ) -> bool { + fn mentions_divergence(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { any_over_type(db, env, self, false, |ty| ty.is_divergent()) } @@ -1772,7 +2303,7 @@ impl<'db> Type<'db> { }) } - pub(crate) fn is_fully_static(self, db: &'db dyn Db, env: &ProgramEnvironment) -> bool { + fn is_fully_static(self, db: &'db dyn Db, env: &ProgramEnvironment) -> bool { dynamic_content(db, env, self).is_absent() } @@ -1789,6 +2320,7 @@ impl<'db> Type<'db> { Type::Dynamic( DynamicType::Unknown | DynamicType::UnknownGeneric(_) + | DynamicType::UnknownLambdaParameter | DynamicType::AmbiguousOverload ) ) @@ -1813,7 +2345,9 @@ impl<'db> Type<'db> { return false; } - any_over_type(db, env, self, false, |ty| { + // Type alias bodies cannot declare `Self`, but their explicit type arguments can + // contain the `Self` from an enclosing method or class. + any_over_type_including_alias_arguments(db, env, self, |ty| { ty.as_typevar().is_some_and(|tv| tv.typevar(db).is_self(db)) }) } @@ -1860,6 +2394,11 @@ impl<'db> Type<'db> { matches!(self, Type::Callable(..)) } + /// Returns `true` if `self` is [`Type::ProtocolInstance`]. + const fn is_protocol_instance(&self) -> bool { + matches!(self, Type::ProtocolInstance(..)) + } + pub(crate) fn cycle_normalized( self, db: &'db dyn Db, @@ -1870,7 +2409,7 @@ impl<'db> Type<'db> { self.cycle_normalized_impl(db, env, previous, cycle) } - pub(super) fn cycle_normalized_impl( + fn cycle_normalized_impl( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -2187,13 +2726,13 @@ impl<'db> Type<'db> { } } let element = UnionType::from_elements_cycle_recovery(db, env, element_types); - kept.push(Type::tuple(Some( - crate::types::tuple::TupleType::homogeneous(db, env, element), + kept.push(Type::tuple(crate::types::tuple::TupleType::homogeneous( + db, env, element, ))); UnionType::from_elements_cycle_recovery(db, env, kept) } - pub(crate) fn is_deeply_nested(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + fn is_deeply_nested(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { nesting_depth(db, env, self, NESTING_LIMIT) == NESTING_LIMIT } @@ -2225,6 +2764,7 @@ impl<'db> Type<'db> { | DynamicType::InvalidConcatenateUnknown | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar + | DynamicType::UnknownLambdaParameter | DynamicType::AmbiguousOverload => false, DynamicType::Todo(_) => true, }) @@ -2421,6 +2961,7 @@ impl<'db> Type<'db> { pub fn is_deprecated(&self, db: &'db dyn Db) -> bool { match self { Type::FunctionLiteral(f) => f.implementation_deprecated(db).is_some(), + Type::Callable(callable) => callable.deprecated(db).is_some(), Type::ClassLiteral(c) => c.deprecated(db).is_some(), _ => false, } @@ -2490,6 +3031,9 @@ impl<'db> Type<'db> { Type::PropertyInstance(property) => { property.instance_fallback(db, env).nominal_class(db, env) } + Type::SlotDescriptor(_) => KnownClass::MemberDescriptorType + .to_instance(db, env) + .nominal_class(db, env), _ => None, } } @@ -2615,6 +3159,7 @@ impl<'db> Type<'db> { } } + /// Returns the specialized Python property represented by this type. pub const fn as_property_instance(self) -> Option> { match self { Type::PropertyInstance(property) => Some(property), @@ -2701,7 +3246,7 @@ impl<'db> Type<'db> { /// basedpython: the protocol's data members, as `(name, instance-access type)` pairs — /// the keyword parameters `(**P) -> R` unpacks to. Methods are excluded: they describe /// how the value behaves, not a keyword a caller can pass. - pub(crate) fn protocol_data_members( + fn protocol_data_members( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -2793,7 +3338,7 @@ impl<'db> Type<'db> { ) } - pub(crate) const fn as_literal_value(self) -> Option> { + const fn as_literal_value(self) -> Option> { match self { Type::LiteralValue(literal) => Some(literal), _ => None, @@ -2851,7 +3396,7 @@ impl<'db> Type<'db> { /// basedpython: whether this is a symbolic type whose value is only known once its type /// parameters are, so reducing it before a comparison would lose what it names - pub(crate) const fn is_deferred(self) -> bool { + const fn is_deferred(self) -> bool { matches!(self, Type::Deferred(_)) } @@ -2944,7 +3489,7 @@ impl<'db> Type<'db> { } /// Detects types which are valid to appear inside a `Literal[…]` type annotation. - pub(crate) fn is_literal_or_union_of_literals( + fn is_literal_or_union_of_literals( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -3001,14 +3546,14 @@ impl<'db> Type<'db> { /// basedpython: create an unpromotable float literal, used for explicit /// `float.inf` / `float.nan` annotations that must not widen to `float` - pub(crate) fn unpromotable_float_literal(value: f64) -> Self { + fn unpromotable_float_literal(value: f64) -> Self { Self::LiteralValue(LiteralValueType::unpromotable( literal::FloatLiteralType::from_f64(value), )) } /// basedpython: create a promotable complex literal - pub(crate) fn complex_literal(db: &'db dyn Db, re: f64, im: f64) -> Self { + fn complex_literal(db: &'db dyn Db, re: f64, im: f64) -> Self { Self::LiteralValue(LiteralValueType::promotable( literal::ComplexLiteralType::from_parts(db, re, im), )) @@ -3087,6 +3632,7 @@ impl<'db> Type<'db> { | Type::GenericAlias(_) | Type::SubclassOf(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::LiteralValue(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_) @@ -3147,6 +3693,7 @@ impl<'db> Type<'db> { | Type::TypeIs(_) | Type::TypeGuard(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::FunctionLiteral(_) | Type::ModuleLiteral(_) | Type::WrapperDescriptor(_) @@ -3185,6 +3732,7 @@ impl<'db> Type<'db> { | Type::TypeGuard(_) | Type::TypeForm(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::FunctionLiteral(_) | Type::ModuleLiteral(_) | Type::WrapperDescriptor(_) @@ -3224,6 +3772,7 @@ impl<'db> Type<'db> { DynamicType::Unknown | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar + | DynamicType::UnknownLambdaParameter | DynamicType::Todo(_) | DynamicType::InvalidConcatenateUnknown | DynamicType::AmbiguousOverload => false, @@ -3234,31 +3783,56 @@ impl<'db> Type<'db> { /// If the type is a union (or a type alias that resolves to a union), filters union elements /// based on the provided predicate. /// - /// Otherwise, returns the type unchanged. - fn filter_union(self, db: &'db dyn Db, f: impl FnMut(&Type<'db>) -> bool) -> Type<'db> { - if let Type::Union(union) = self.resolve_type_alias(db) { - union.filter(db, f) - } else { - self - } - } - - /// If the type is a union, removes union elements that are disjoint from `target`. + /// Aliases among the elements are expanded first. An element may itself be an alias for a + /// union, which is otherwise left unexpanded so diagnostics can name it, but filtering is a + /// set operation and has to see the members rather than the name. /// /// Otherwise, returns the type unchanged. - fn filter_disjoint_elements( + fn filter_union( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + mut f: impl FnMut(&Type<'db>) -> bool, + ) -> Type<'db> { + let Type::Union(union) = self.resolve_type_alias(db) else { + return self; + }; + let union = if union.has_aliases(db) { + match union.expand_aliases(db, env) { + Type::Union(expanded) => expanded, + // Expanding collapsed the union to a single type, leaving nothing to filter + // between, so apply the predicate to it directly. + expanded => return if f(&expanded) { expanded } else { Type::Never }, + } + } else { + union + }; + union.filter(db, f) + } + + /// If the type is a union, removes union elements that are disjoint from `target`. + /// + /// Returns [`DiscardDisjointUnionElementsResult::AllDisjoint`] if every union element is removed. + /// Non-union inputs, including `Never`, are returned unchanged as + /// [`DiscardDisjointUnionElementsResult::Retained`]. + fn discard_disjoint_union_elements( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, target: Type<'db>, inferable: TypeVarSet<'db>, - ) -> Type<'db> { + ) -> DiscardDisjointUnionElementsResult<'db> { let constraints = ConstraintSetBuilder::new(); - self.filter_union(db, |elem| { + let filtered = self.filter_union(db, env, |elem| { !elem .when_disjoint_from(db, env, target, &constraints, inferable) .is_always_satisfied(db, env) - }) + }); + if filtered.is_never() && !self.is_never() { + DiscardDisjointUnionElementsResult::AllDisjoint + } else { + DiscardDisjointUnionElementsResult::Retained(filtered) + } } /// basedpython: whether this is a `type def` — a type function, applied with @@ -3285,7 +3859,7 @@ impl<'db> Type<'db> { /// Returns the fallback instance type that a literal is an instance of, or `None` if the type /// is not a literal. - pub(crate) fn literal_fallback_instance( + fn literal_fallback_instance( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -3327,7 +3901,7 @@ impl<'db> Type<'db> { /// A `.by` file has that model by definition rather than by configuration, so it takes /// the strict path whatever `strict-float` says. #[must_use] - pub(crate) fn promote_in( + fn promote_in( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -3347,6 +3921,77 @@ impl<'db> Type<'db> { ) } + /// Finalizes the element type of a mutable collection after combining its element evidence. + /// Literal types supplied by explicit annotations remain unpromotable. Without contextual + /// constraints, singleton types also widen: `[None]` permits later mutation, as does the list + /// created by `*rest, = (None,)`. + /// Evidence from later collection uses also passes through this helper, since those types + /// have not necessarily undergone the promotion applied to literal elements during inference. + fn promote_collection_element_type( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + allow_tuple_size_promotion: bool, + unconstrained: bool, + ) -> Type<'db> { + self.promote_collection_element_type_impl( + db, + env, + None, + allow_tuple_size_promotion, + unconstrained, + ) + } + + /// As [`Type::promote_collection_element_type`], but honouring the numeric model of `file`. + /// + /// basedpython: promotion of an *inferred* element widens through the same numeric special + /// case as an annotation would, so a module that opted out of it has to opt out here too. + fn promote_collection_element_type_in( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + file: ruff_db::files::File, + allow_tuple_size_promotion: bool, + unconstrained: bool, + ) -> Type<'db> { + self.promote_collection_element_type_impl( + db, + env, + Some(file), + allow_tuple_size_promotion, + unconstrained, + ) + } + + fn promote_collection_element_type_impl( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + file: Option, + allow_tuple_size_promotion: bool, + unconstrained: bool, + ) -> Type<'db> { + let ty = if unconstrained { + match file { + Some(file) => self.promote_in(db, env, file), + None => self.promote(db, env), + } + } else { + self + }; + let ty = if allow_tuple_size_promotion { + ty.promote_tuple_size_in_union(db, env) + } else { + ty + }; + if unconstrained { + ty.promote_singletons_recursively(db, env) + } else { + ty + } + } + /// Promote a top-level singleton type (like `None`, `EllipsisType`) to `T | Unknown`. pub(crate) fn promote_singletons( self, @@ -3515,6 +4160,10 @@ impl<'db> Type<'db> { Type::PropertyInstance(property) => property .recursive_type_normalized_impl(db, env, div, nested) .map(Type::PropertyInstance), + Type::SlotDescriptor(descriptor) => descriptor + .value_type(db) + .recursive_type_normalized_impl(db, env, div, true) + .map(|value_type| Type::SlotDescriptor(SlotDescriptorType::new(db, value_type))), Type::KnownBoundMethod(method_kind) => method_kind .recursive_type_normalized_impl(db, env, div, nested) .map(Type::KnownBoundMethod), @@ -3768,7 +4417,7 @@ impl<'db> Type<'db> { } Type::DataclassDecorator(_) | Type::DataclassTransformer(_) => false, Type::NominalInstance(instance) => instance.is_singleton(db), - Type::PropertyInstance(_) => false, + Type::PropertyInstance(_) | Type::SlotDescriptor(_) => false, Type::Union(..) => { // A single-element union, where the sole element was a singleton, would itself // be a singleton type. However, unions with length < 2 should never appear in @@ -3894,7 +4543,11 @@ impl<'db> Type<'db> { .into(), ), - _ => Some(class.class_member(db, env, name, policy)), + _ => Some( + class + .class_member(db, env, name, policy) + .map_type(|member| property_wrapper_descriptor(db, env, name, member)), + ), } } @@ -3902,15 +4555,17 @@ impl<'db> Type<'db> { Some(alias.origin(db).typed_dict_member( db, env, - (name == "__init__").then_some(alias.specialization(db)), + Some(alias.specialization(db)), name, policy, )) } - Type::GenericAlias(alias) => { - Some(ClassType::from(*alias).class_member(db, env, name, policy)) - } + Type::GenericAlias(alias) => Some( + ClassType::from(*alias) + .class_member(db, env, name, policy) + .map_type(|member| property_wrapper_descriptor(db, env, name, member)), + ), Type::SubclassOf(subclass_of_ty) => { subclass_of_ty.find_name_in_mro_with_policy(db, env, name, policy) @@ -3957,6 +4612,7 @@ impl<'db> Type<'db> { | Type::NominalInstance(_) | Type::ProtocolInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::TypeIs(_) | Type::TypeGuard(_) | Type::TypeForm(_) @@ -4266,7 +4922,8 @@ impl<'db> Type<'db> { .find_name_in_mro_with_policy(db, env, name, policy) .expect("The meta-type of an instance-like type should always have an MRO"); let Some(metaclass) = class - .metaclass(db) + .inferred_metaclass(db) + .for_inheritance(db, env) .to_instance_approximation(db, env) .and_then(|metaclass| metaclass.nominal_class(db, env)) else { @@ -4360,20 +5017,14 @@ impl<'db> Type<'db> { else { return dynamic_instance_fallback; }; - let all_arms_are_possible_data_descriptors = declaration - .ty - .resolve_type_alias(db) - .as_union() - .is_none_or(|union| { - union - .elements(db) - .iter() - .all(|ty| ty.may_be_data_descriptor(db, env)) - }); + let mut all_arms_are_possible_data_descriptors = true; + let descriptor_ty = declaration.ty.filter_union(db, env, |ty| { + let is_possible_data_descriptor = ty.may_be_data_descriptor(db, env); + all_arms_are_possible_data_descriptors &= is_possible_data_descriptor; + is_possible_data_descriptor + }); Place::Defined(DefinedPlace { - ty: declaration - .ty - .filter_union(db, |ty| ty.may_be_data_descriptor(db, env)), + ty: descriptor_ty, definedness: if all_arms_are_possible_data_descriptors { declaration.definedness } else { @@ -4506,6 +5157,10 @@ impl<'db> Type<'db> { .to_instance(db, env) .instance_member(db, env, name), + Type::SlotDescriptor(_) => KnownClass::MemberDescriptorType + .to_instance(db, env) + .instance_member(db, env, name), + // Note: `super(pivot, owner).__dict__` refers to the `__dict__` of the `builtins.super` instance, // not that of the owner. // This means we should only look up instance members defined on the `builtins.super()` instance itself. @@ -4540,7 +5195,9 @@ impl<'db> Type<'db> { name: &str, ) -> Place<'db> { if let Type::ModuleLiteral(module) = self { - module.static_member(db, env, name).place + module + .static_member(db, env, name) + .map_or(Place::Undefined, |member| member.member(db).place) } else if let place @ Place::Defined(_) = self.class_member(db, env, name).place { place } else if let Some(place @ Place::Defined(_)) = self @@ -4553,6 +5210,97 @@ impl<'db> Type<'db> { } } + /// Collect deprecated accessor implementations without inferring their signatures or + /// intersecting their function or descriptor types. Retain the declarations so callers can + /// report deprecations after descriptor lookup replaces the property with its value type: + /// + /// ```python + /// from typing_extensions import deprecated + /// + /// class C: + /// @property + /// @deprecated("old getter") + /// def value(self) -> int: ... + /// + /// C().value # Warn about the getter, even though the attribute has type `int`. + /// ``` + /// + /// Overload deprecations require a resolved call and do not apply to accessor references. + fn property_deprecations(self, db: &'db dyn Db) -> Option> { + /// Append deprecated implementations, preserving earlier entries if a non-deprecated + /// intersection alternative suppresses this accessor's deprecations. + fn collect<'db>( + db: &'db dyn Db, + accessor: Type<'db>, + functions: &mut Vec>, + ) { + match accessor { + Type::FunctionLiteral(function) => { + let (_, implementation) = function.overloads_and_implementation(db); + functions.extend( + implementation.filter(|function| function.deprecated(db).is_some()), + ); + } + Type::BoundMethod(method) => { + collect(db, Type::FunctionLiteral(method.function(db)), functions); + } + Type::Union(union) => { + for element in union.elements(db) { + collect(db, *element, functions); + } + } + Type::Intersection(intersection) => { + let start = functions.len(); + for element in intersection.positive(db) { + let element_start = functions.len(); + collect(db, *element, functions); + if functions.len() == element_start { + // A non-deprecated intersection member can supply the accessor. + functions.truncate(start); + break; + } + } + } + _ => {} + } + } + + match self { + Type::PropertyInstance(property) => { + let [getters, setters, deleters] = [ + property.getter(db), + property.setter(db), + property.deleter(db), + ] + .map(|accessor| { + let mut functions = Vec::new(); + if let Some(accessor) = accessor { + collect(db, accessor, &mut functions); + } + functions.into_iter().unique().collect::>() + }); + if getters.is_empty() && setters.is_empty() && deleters.is_empty() { + None + } else { + Some(PropertyDeprecations::new(db, getters, setters, deleters)) + } + } + Type::Union(union) => union + .elements(db) + .iter() + .filter_map(|ty| ty.property_deprecations(db)) + .reduce(|left, right| left.union(db, right)), + Type::Intersection(intersection) => { + let mut elements = intersection.positive(db).iter(); + let first = elements.next()?.property_deprecations(db)?; + elements.try_fold(first, |properties, ty| { + Some(properties.intersection(db, ty.property_deprecations(db)?)) + }) + } + _ => None, + } + } + /// Returns the descriptor result type for directly dynamic values and gradual class-object /// values. fn dynamic_descriptor_type(self) -> Option> { @@ -4766,11 +5514,11 @@ impl<'db> Type<'db> { // for every function and access context. if let Type::FunctionLiteral(function) = self { let return_type = if function.is_classmethod(db) { - Type::BoundMethod(BoundMethodType::new(db, function, owner)) + Type::BoundMethod(BoundMethodType::new(db, function, owner, owner)) } else if let Some(instance) = instance && !function.is_staticmethod(db) { - Type::BoundMethod(BoundMethodType::new(db, function, instance)) + Type::BoundMethod(BoundMethodType::new(db, function, instance, instance)) } else { self }; @@ -4781,6 +5529,15 @@ impl<'db> Type<'db> { })); } + // The interpreter returns the descriptor itself on class access and its stored value on + // instance access; no Python property accessors participate in either operation. + if let Type::SlotDescriptor(descriptor) = self { + return Ok(Some(DescriptorGetResult { + return_type: instance.map_or(self, |_| descriptor.value_type(db)), + kind: AttributeKind::DataDescriptor, + })); + } + try_call_dunder_get_inner(db, env.program(db), self, instance, owner) } @@ -5055,7 +5812,7 @@ impl<'db> Type<'db> { match self { Type::Dynamic(_) => !any_of_union, Type::SubclassOf(_) if self.dynamic_descriptor_type().is_some() => true, - Type::Never | Type::PropertyInstance(_) => true, + Type::Never | Type::PropertyInstance(_) | Type::SlotDescriptor(_) => true, Type::Union(union) if any_of_union => union .elements(db) .iter() @@ -5118,20 +5875,9 @@ impl<'db> Type<'db> { policy: InstanceFallbackShadowsNonDataDescriptor, ) -> MemberLookupResult<'db> { let meta_attr_plain = Self::instance_lookup_class_member_with_policy(db, env, key); - // A TypeVar retains its class identity when lookup is delegated to its bound, including - // after narrowing. Narrowing can also add an unrelated class to a mixin's `Self`, in which - // case the TypeVar alone is not a valid owner for descriptors from that class. - let owner = match receiver { - Type::TypeVar(_) => receiver, - Type::Intersection(intersection) => intersection - .positive(db) - .iter() - .copied() - .find(|element| element.is_type_var() && element.is_subtype_of(db, env, key.ty(db))) - .unwrap_or(key.ty(db)), - _ => key.ty(db), - } - .to_meta_type(db, env); + let meta_attr_ty = meta_attr_plain.place.ignore_possibly_undefined(); + // Preserve the receiver's type variables and all its narrowed class constraints. + let owner = receiver.to_meta_type(db, env); let ( PlaceAndQualifiers { place: meta_attr, @@ -5142,11 +5888,26 @@ impl<'db> Type<'db> { ) = Self::try_call_dunder_get_on_attribute(db, env, meta_attr_plain, Some(receiver), owner); let meta_attr_error = meta_attr_error.map(MemberLookupErrorKind::DescriptorGet); + let meta_properties = meta_attr_ty.and_then(|ty| ty.property_deprecations(db)); let fallback_error = fallback.err().map(|error| error.kind(db)); + let fallback_member = fallback.unwrap_or_else(|error| error.fallback_member(db)); + let fallback_properties = fallback_member.deprecated_properties(db); + let fallback_member = fallback_member.member(db); + + // A slot stores the same instance attribute described by the receiver's declarations. + // Unlike an arbitrary data descriptor, its inherited getter must not hide a more precise + // declaration established by the receiver's class. + if matches!(meta_attr, Place::Defined(_)) + && matches!(meta_attr_ty, Some(Type::SlotDescriptor(_))) + && !fallback_member.place.is_undefined() + { + return fallback; + } + let PlaceAndQualifiers { place: fallback, qualifiers: fallback_qualifiers, - } = fallback.unwrap_or_else(|error| error.fallback_member(db)); + } = fallback_member; match (meta_attr, meta_attr_kind, fallback) { // The fallback type is unbound, so we can just return `meta_attr` unconditionally, @@ -5155,6 +5916,7 @@ impl<'db> Type<'db> { db, meta_attr.with_qualifiers(meta_attr_qualifiers), meta_attr_error, + meta_properties, ), // `meta_attr` is the return type of a data descriptor and definitely bound, so we @@ -5170,6 +5932,7 @@ impl<'db> Type<'db> { db, meta_attr.with_qualifiers(meta_attr_qualifiers), meta_attr_error, + meta_properties, ), // `meta_attr` is the return type of a data descriptor, but the attribute on the @@ -5202,6 +5965,7 @@ impl<'db> Type<'db> { }) .with_qualifiers(meta_attr_qualifiers.union(fallback_qualifiers)), meta_attr_error.or(fallback_error), + union_deprecated_properties(db, meta_properties, fallback_properties), ), // `meta_attr` is *not* a data descriptor. This means that the `fallback` type has @@ -5223,6 +5987,7 @@ impl<'db> Type<'db> { db, fallback.with_qualifiers(fallback_qualifiers), fallback_error, + fallback_properties, ), // `meta_attr` is *not* a data descriptor. The `fallback` symbol is either possibly @@ -5255,6 +6020,7 @@ impl<'db> Type<'db> { }) .with_qualifiers(meta_attr_qualifiers.union(fallback_qualifiers)), meta_attr_error.or(fallback_error), + union_deprecated_properties(db, meta_properties, fallback_properties), ), // If the attribute is not found on the meta-type, we simply return the fallback. @@ -5262,6 +6028,7 @@ impl<'db> Type<'db> { db, fallback.with_qualifiers(fallback_qualifiers), fallback_error, + fallback_properties, ), } } @@ -5280,6 +6047,7 @@ impl<'db> Type<'db> { ) -> PlaceAndQualifiers<'db> { self.try_member_lookup(db, env, name) .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) } /// Performs member lookup while retaining errors from implicit attribute-access methods. @@ -5298,6 +6066,142 @@ impl<'db> Type<'db> { ) } + /// Whether class access exposes an instance attribute whose type depends on the class's + /// type parameters. Specializing a class does not give it separate attribute storage: + /// `Box[int].value` and `Box[str].value` both refer to `Box.value` at runtime. + /// A `type[Box[int]]` receiver can refer to a concrete subclass with its own attributes, + /// so this restriction only applies to class literals and generic aliases. + /// basedpython: the same type seen through `projections`, when it is a generic instance whose + /// parameters they line up with. + /// + /// Substituting into a generic rebuilds its specialization from the arguments, which is where + /// a use-site projection would otherwise be dropped. + fn with_use_site_projections( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + projections: &[Option], + ) -> Self { + if projections.iter().all(Option::is_none) { + return self; + } + let Some(instance) = self.as_nominal_instance() else { + return self; + }; + let ClassType::Generic(alias) = instance.class(db, env) else { + return self; + }; + let specialization = alias.specialization(db); + if specialization.types(db).len() != projections.len() { + return self; + } + let projected = + specialization.with_projections(db, projections.to_vec().into_boxed_slice()); + Type::instance( + db, + env, + ClassType::Generic(GenericAlias::new(db, alias.origin(db), projected)), + ) + } + + /// basedpython: whether this type is a *view* of a generic instance rather than the plain + /// instance — `list[out int]`, which its holder has undertaken only to read. + /// + /// A projection changes what the members of the object offer, not which object it is, so + /// anything asking whether a type *is* something has to see past it. + fn has_use_site_projection(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + self.union_elements(db).any(|element| { + let Some(instance) = element.as_nominal_instance() else { + return false; + }; + let ClassType::Generic(alias) = instance.class(db, env) else { + return false; + }; + alias + .specialization(db) + .projections(db) + .iter() + .any(Option::is_some) + }) + } + + fn has_generic_instance_attribute( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + name: &str, + ) -> bool { + let class = match self { + Type::Union(union) => { + return union + .elements(db) + .iter() + .any(|element| element.has_generic_instance_attribute(db, env, name)); + } + Type::ClassLiteral(class) => class, + Type::GenericAlias(alias) => alias.origin(db).into(), + _ => return false, + }; + let Some(generic_context) = class + .as_static() + .and_then(|class| class.generic_context(db)) + else { + return false; + }; + // A metaclass data descriptor takes precedence over the instance declaration. + if class + .metaclass(db) + .find_name_in_mro_with_policy(db, env, name, MemberLookupPolicy::default()) + .and_then(|member| member.place.ignore_possibly_undefined()) + .is_some_and(|ty| ty.is_data_descriptor(db, env)) + { + return false; + } + let member = Type::from(class.identity_specialization(db)).class_object_member( + db, + env, + name, + MemberLookupPolicy::default(), + ); + let Place::Defined(DefinedPlace { + ty, + origin: TypeOrigin::Declared, + .. + }) = member.place + else { + return false; + }; + if member.is_class_var() { + return false; + } + let ty = match ty.resolve_type_alias(db) { + Type::Union(union) if union.has_aliases(db) => union.expand_aliases(db, env), + ty => ty, + }; + let alternatives = match &ty { + Type::Union(union) => union.elements(db), + _ => std::slice::from_ref(&ty), + }; + alternatives.iter().any(|ty| { + // basedpython: a class is not instance storage, and reading one off the class is how + // it is named. python cannot reach an enclosing class's type parameter from a nested + // class body, so only a lowered construct puts one here — an enum variant, whose + // `Tree.Leaf` mentions `Tree`'s own `T` because the variant subclasses the enum. + if matches!(ty, Type::ClassLiteral(_) | Type::GenericAlias(_)) { + return false; + } + // Descriptors define their own class-access behavior, but do not exempt other + // alternatives in a union from the restriction on generic instance storage. + ty.class_member(db, env, "__get__").is_undefined() + // Variance accounts for aliases without expanding recursive specializations, + // and ignores alias arguments that do not affect the resulting type. + && generic_context.variables(db).any(|typevar| { + ty.variance_of(db, env, typevar.identity(db)).evaluate(db) + != TypeVarVariance::Bivariant + }) + }) + } + /// Similar to [`Type::member`], but allows the caller to specify what policy should be used /// when looking up attributes. See [`MemberLookupPolicy`] for more information. pub(crate) fn member_lookup_with_policy( @@ -5309,6 +6213,7 @@ impl<'db> Type<'db> { ) -> PlaceAndQualifiers<'db> { self.member_lookup_with_policy_and_receiver(db, env, name, policy, None) .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) } /// Perform member lookup while optionally binding descriptors and `Self` to a more precise @@ -5326,7 +6231,7 @@ impl<'db> Type<'db> { ) -> MemberLookupResult<'db> { #[salsa::tracked( returns(copy), - cycle_initial=|_, id, _| Ok(Place::bound(Type::divergent(id)).into()), + cycle_initial=|_, id, _| Place::bound(Type::divergent(id)).into(), cycle_fn=|db, cycle, previous: &MemberLookupResult<'db>, member: MemberLookupResult<'db>, key: MemberLookupKey<'db>| { cycle_normalized_member_lookup(db, &ProgramEnvironment::from_program(key.program(db)), member, *previous, cycle) }, @@ -5341,7 +6246,7 @@ impl<'db> Type<'db> { #[salsa::tracked( returns(copy), - cycle_initial=|_, id, _, _| Ok(Place::bound(Type::divergent(id)).into()), + cycle_initial=|_, id, _, _| Place::bound(Type::divergent(id)).into(), cycle_fn=|db, cycle, previous: &MemberLookupResult<'db>, member: MemberLookupResult<'db>, key: MemberLookupKey<'db>, _| { cycle_normalized_member_lookup(db, &ProgramEnvironment::from_program(key.program(db)), member, *previous, cycle) }, @@ -5365,7 +6270,9 @@ impl<'db> Type<'db> { env: &ProgramEnvironment<'db>, result: MemberLookupResult<'db>, ) -> MemberLookupResult<'db> { - let member = result.unwrap_or_else(|error| error.fallback_member(db)); + let member = result + .unwrap_or_else(|error| error.fallback_member(db)) + .member(db); let should_promote = matches!( member.place, Place::Defined(DefinedPlace { @@ -5424,6 +6331,7 @@ impl<'db> Type<'db> { if result .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) .is_class_var() && this.is_typed_dict() { @@ -5479,14 +6387,21 @@ impl<'db> Type<'db> { ), Type::Union(union) => { let mut error = None; + let mut properties = None; let member = union.map_with_boundness_and_qualifiers(db, env, |elem| { let result = elem.member_lookup_with_policy_and_receiver( db, env, name_str, policy, receiver, ); error = error.or_else(|| result.err().map(|error| error.kind(db))); - result.unwrap_or_else(|error| error.fallback_member(db)) + let member = result.unwrap_or_else(|error| error.fallback_member(db)); + properties = union_deprecated_properties( + db, + properties, + member.deprecated_properties(db), + ); + member.member(db) }); - member_lookup_result(db, member, error) + member_lookup_result(db, member, error, properties) } Type::Intersection(intersection) => { @@ -5498,15 +6413,32 @@ impl<'db> Type<'db> { } else { let receiver = Some(receiver.unwrap_or(this)); let mut error = None; + let mut properties: Option> = None; + let mut all_deprecated = true; let member = intersection.map_with_boundness_and_qualifiers(db, env, |elem| { let result = elem.member_lookup_with_policy_and_receiver( db, env, name_str, policy, receiver, ); error = error.or_else(|| result.err().map(|error| error.kind(db))); - result.unwrap_or_else(|error| error.fallback_member(db)) + let member = + result.unwrap_or_else(|error| error.fallback_member(db)); + if let Some(deprecated) = member.deprecated_properties(db) { + properties = + Some(properties.map_or(deprecated, |properties| { + properties.intersection(db, deprecated) + })); + } else if !member.member(db).place.is_undefined() { + all_deprecated = false; + } + member.member(db) }); - member_lookup_result(db, member, error) + member_lookup_result( + db, + member, + error, + properties.filter(|_| all_deprecated && !member.place.is_undefined()), + ) } } @@ -5518,17 +6450,16 @@ impl<'db> Type<'db> { // The member is available as long as *some* materialization has it. This is the // intersection face of an unsafe union, and the reason `UnsafeUnion[int, str]` // answers both `.imag` and `.upper`. - Type::UnsafeUnion(unsafe_union) => { - let receiver = Some(receiver.unwrap_or(this)); - Ok( - unsafe_union.map_with_boundness_and_qualifiers(db, env, |elem| { - elem.member_lookup_with_policy_and_receiver( - db, env, name_str, policy, receiver, - ) - .unwrap_or_else(|error| error.fallback_member(db)) - }), - ) - } + Type::UnsafeUnion(unsafe_union) => unsafe_union + .map_with_boundness_and_qualifiers(db, env, |elem| { + let receiver = Some(receiver.unwrap_or(this)); + elem.member_lookup_with_policy_and_receiver( + db, env, name_str, policy, receiver, + ) + .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) + }) + .into(), Type::Dynamic(..) | Type::Divergent(_) | Type::Never => Place::bound(this).into(), @@ -5643,14 +6574,6 @@ impl<'db> Type<'db> { )) .into() } - Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) - if name == "satisfied_by_all_typevars" => - { - Place::bound(Type::KnownBoundMethod( - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(tracked), - )) - .into() - } Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) if name == "solutions_for" => { @@ -5727,29 +6650,13 @@ impl<'db> Type<'db> { )) .into() } - Type::ClassLiteral(class) - if name == "__get__" && class.is_known(db, KnownClass::Property) => + Type::ClassLiteral(_) | Type::GenericAlias(_) + if matches!(name_str, "__get__" | "__set__" | "__delete__") + && let Some(wrapper @ Type::WrapperDescriptor(_)) = this + .find_name_in_mro_with_policy(db, env, name_str, policy) + .and_then(|member| member.place.ignore_possibly_undefined()) => { - Place::bound(Type::WrapperDescriptor( - WrapperDescriptorKind::PropertyDunderGet, - )) - .into() - } - Type::ClassLiteral(class) - if name == "__set__" && class.is_known(db, KnownClass::Property) => - { - Place::bound(Type::WrapperDescriptor( - WrapperDescriptorKind::PropertyDunderSet, - )) - .into() - } - Type::ClassLiteral(class) - if name == "__delete__" && class.is_known(db, KnownClass::Property) => - { - Place::bound(Type::WrapperDescriptor( - WrapperDescriptorKind::PropertyDunderDelete, - )) - .into() + Place::bound(wrapper).into() } Type::BoundMethod(bound_method) => match name_str { "__self__" => Place::bound(bound_method.self_instance(db)).into(), @@ -5833,7 +6740,7 @@ impl<'db> Type<'db> { Place::bound(Type::int_literal(i64::from(bool_value))).into() } - Type::ModuleLiteral(module) => module.static_member(db, env, name_str).into(), + Type::ModuleLiteral(module) => module.static_member(db, env, name_str), // If a protocol does not include a member and the policy disables falling back to // `object`, we return `Place::Undefined` here. This short-circuits attribute lookup @@ -5936,17 +6843,14 @@ impl<'db> Type<'db> { if let Some(bound_or_constraints) = typevar.typevar(db).bound_or_constraints(db, env) { - // Use the bound's complete lookup behavior, but retain the original - // receiver so descriptors and `Self` remain correctly specialized. - bound_or_constraints - .as_type(db, env) - .member_lookup_with_policy_and_receiver( - db, - env, - name_str, - policy, - Some(receiver), - ) + distribute_member_lookup_over_bound_or_constraints( + db, + env, + bound_or_constraints, + receiver, + name_str, + policy, + ) } else { instance_like_member_lookup(db, env, key, receiver) } @@ -6001,6 +6905,7 @@ impl<'db> Type<'db> { if name_str == "func" { match nominal_lookup .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) .place { Place::Defined(DefinedPlace { @@ -6031,6 +6936,7 @@ impl<'db> Type<'db> { | Type::SpecialForm(..) | Type::KnownInstance(..) | Type::PropertyInstance(..) + | Type::SlotDescriptor(..) | Type::FunctionLiteral(..) | Type::AlwaysTruthy | Type::AlwaysFalsy @@ -6100,6 +7006,7 @@ impl<'db> Type<'db> { db, class_attr_fallback, class_attr_error.map(MemberLookupErrorKind::DescriptorGet), + None, ), InstanceFallbackShadowsNonDataDescriptor::Yes, ); @@ -6203,6 +7110,16 @@ impl<'db> Type<'db> { } } + if let Type::LiteralValue(literal) = self + && let Some(length) = match literal.kind() { + LiteralValueTypeKind::String(string) => Some(string.python_len(db)), + LiteralValueTypeKind::Bytes(bytes) => Some(bytes.python_len(db)), + _ => None, + } + { + return i64::try_from(length).ok().map(Type::int_literal); + } + let return_ty = match self.try_call_dunder( db, env, @@ -6313,8 +7230,17 @@ impl<'db> Type<'db> { /// elements. It's usually best to only worry about "callability" relative to a particular /// argument list, via [`try_call`][Self::try_call] and [`CallErrorKind::NotCallable`]. fn bindings(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Bindings<'db> { + self.bindings_impl(db, env, &ActiveRecursionDetector::default()) + } + + fn bindings_impl( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + recursion_guard: &ActiveRecursionDetector>, + ) -> Bindings<'db> { if let Some(fallback) = self.materialized_divergent_fallback() { - return fallback.bindings(db, env); + return fallback.bindings_impl(db, env, recursion_guard); } match self { @@ -6329,14 +7255,16 @@ impl<'db> Type<'db> { Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db, env) { None => CallableBinding::not_callable(self).into(), - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound.bindings(db, env), + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + bound.bindings_impl(db, env, recursion_guard) + } Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { Bindings::from_union( self, constraints .elements(db) .iter() - .map(|ty| ty.bindings(db, env)), + .map(|ty| ty.bindings_impl(db, env, recursion_guard)), ) } } @@ -6345,24 +7273,50 @@ impl<'db> Type<'db> { Type::BoundMethod(bound_method) => { let signature = bound_method.function(db).signature(db); let self_instance = bound_method.self_instance(db); + let signature_receiver = bound_method.signature_receiver(db); // Class-based protocol member lookup has already specialized the method for this // receiver. Bake an implicit positional receiver into the signature instead of // checking it structurally again during call inference. - if self_instance + let protocol_receiver_is_specialized = self_instance .as_protocol_instance() .is_some_and(|protocol| protocol.class_origin(db).is_some()) && signature .overloads .iter() - .all(Signature::has_implicit_positional_receiver_annotation) - { + .all(Signature::has_implicit_positional_receiver_annotation); + if protocol_receiver_is_specialized || signature_receiver != self_instance { let mut binding = CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) - .with_bound_type(bound_method.typing_self_type(db)); + .with_bound_type(signature_receiver); binding.bake_bound_type_into_overloads(db, env); binding.into() } else { - CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) + // Solve exact receiver constraints before checking the other arguments, but + // retain the receiver itself for call inference and receiver diagnostics. + let overloads = signature.overloads.iter().flat_map(|overload| { + if overload.has_receiver_determined_method_typevar(db, env) + && let Some(specialized) = overload.specialize_for_bound_receiver( + db, + env, + self_instance, + bound_method.typing_self_type(db), + ) + { + specialized.overloads + } else { + // basedpython: a method type variable can be bounded by `Self` — + // `def link[T: Self](self, other: T)`. The receiver is known here, so + // the bound is settled here too; leaving `Self` standing in it makes + // the receiver fail a bound derived from itself. + smallvec_inline![overload.with_self_bounded_typevars( + db, + env, + bound_method.typing_self_type(db), + )] + } + }); + + CallableBinding::from_overloads(self, overloads) .with_bound_type(self_instance) .into() } @@ -6536,22 +7490,29 @@ impl<'db> Type<'db> { // TODO this should be called from `constructor_bindings` for better consistency .known_class_literal_bindings(db, env, class) .unwrap_or_else(|| { - self.constructor_bindings(db, env, ClassType::NonGeneric(class)) + self.constructor_bindings( + db, + env, + ClassType::NonGeneric(class), + recursion_guard, + ) }), Type::GenericAlias(alias) => { - self.constructor_bindings(db, env, ClassType::Generic(alias)) + self.constructor_bindings(db, env, ClassType::Generic(alias), recursion_guard) } Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() { SubclassOfInner::Dynamic(dynamic_type) => { Binding::single(self, Signature::dynamic(Type::Dynamic(dynamic_type))).into() } - SubclassOfInner::Class(class) => self.constructor_bindings(db, env, class), + SubclassOfInner::Class(class) => { + self.constructor_bindings(db, env, class, recursion_guard) + } SubclassOfInner::Protocol(protocol) => protocol.class_origin(db).map_or_else( || Binding::single(self, Signature::dynamic(Type::unknown())).into(), |origin| { - let bindings = self.constructor_bindings(db, env, *origin); + let bindings = self.constructor_bindings(db, env, *origin, recursion_guard); if protocol.materialization_kind(db).is_some() { bindings.with_constructed_instance_type( db, @@ -6573,16 +7534,16 @@ impl<'db> Type<'db> { { bindings } else { - constructor.bindings(db, env) + constructor.bindings_impl(db, env, recursion_guard) } } TypeVarBoundOrConstraints::Constraints(constraints) => { Bindings::from_union( self, - constraints - .elements(db) - .iter() - .map(|ty| ty.to_meta_type(db, env).bindings(db, env)), + constraints.elements(db).iter().map(|ty| { + ty.to_meta_type(db, env) + .bindings_impl(db, env, recursion_guard) + }), ) } }; @@ -6626,7 +7587,7 @@ impl<'db> Type<'db> { definedness: boundness, .. }) => { - let mut bindings = dunder_callable.bindings(db, env); + let mut bindings = dunder_callable.bindings_impl(db, env, recursion_guard); bindings.replace_callable_type(dunder_callable, self); if boundness == Definedness::PossiblyUndefined { bindings.set_dunder_call_is_possibly_unbound(); @@ -6649,7 +7610,7 @@ impl<'db> Type<'db> { union .elements(db) .iter() - .map(|element| element.bindings(db, env)), + .map(|element| element.bindings_impl(db, env, recursion_guard)), ), // A narrowed `type[T: Base] & type[Child]` still needs to construct `T & Child`, @@ -6669,7 +7630,11 @@ impl<'db> Type<'db> { && let Type::NominalInstance(lookup_instance) = instance_type.flatten_typevars(db, env) && let Some(bindings) = { - let bindings = lookup_instance.to_meta_type(db, env).bindings(db, env); + let bindings = lookup_instance.to_meta_type(db, env).bindings_impl( + db, + env, + recursion_guard, + ); bindings.has_only_constructor_items().then_some(bindings) } => { @@ -6682,7 +7647,7 @@ impl<'db> Type<'db> { self, intersection .positive_elements_or_object(db) - .map(|element| element.bindings(db, env)), + .map(|element| element.bindings_impl(db, env, recursion_guard)), ), // Callable as long as *some* materialization is, like an intersection; but the @@ -6697,7 +7662,9 @@ impl<'db> Type<'db> { ), Type::EnumComplement(complement) => { - complement.to_intersection(db, env).bindings(db, env) + complement + .to_intersection(db, env) + .bindings_impl(db, env, recursion_guard) } Type::DataclassDecorator(_) => { @@ -6727,9 +7694,9 @@ impl<'db> Type<'db> { Type::SpecialForm(_) => CallableBinding::not_callable(self).into(), Type::LiteralValue(literal) => match literal.kind() { - LiteralValueTypeKind::Enum(enum_literal) => { - enum_literal.enum_class_instance(db, env).bindings(db, env) - } + LiteralValueTypeKind::Enum(enum_literal) => enum_literal + .enum_class_instance(db, env) + .bindings_impl(db, env, recursion_guard), _ => CallableBinding::not_callable(self).into(), }, @@ -6746,15 +7713,16 @@ impl<'db> Type<'db> { Type::KnownInstance( KnownInstanceType::FunctoolsPartial(partial) | KnownInstanceType::FunctoolsPartialCall(partial), - ) => Type::Callable(partial.partial(db)).bindings(db, env), + ) => Type::Callable(partial.partial(db)).bindings_impl(db, env, recursion_guard), - Type::KnownInstance(known_instance) => { - known_instance.instance_fallback(db, env).bindings(db, env) - } + Type::KnownInstance(known_instance) => known_instance + .instance_fallback(db, env) + .bindings_impl(db, env, recursion_guard), - Type::TypeAlias(alias) => alias.value_type(db).bindings(db, env), + Type::TypeAlias(alias) => alias.value_type(db).bindings_impl(db, env, recursion_guard), Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysFalsy | Type::AlwaysTruthy | Type::BoundSuper(_) @@ -6888,7 +7856,7 @@ impl<'db> Type<'db> { ) } - KnownClass::TypeAliasType => { + KnownClass::TypeAliasType | KnownClass::ExtensionsTypeAliasType => { // ```py // def __new__( // cls, @@ -7093,6 +8061,7 @@ impl<'db> Type<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, class: ClassType<'db>, + recursion_guard: &ActiveRecursionDetector>, ) -> Bindings<'db> { fn resolve_dunder_new_callable<'db>( db: &'db dyn Db, @@ -7184,6 +8153,7 @@ impl<'db> Type<'db> { | KnownClass::Property | KnownClass::Super | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType | KnownClass::Deprecated ) ) { @@ -7216,166 +8186,178 @@ impl<'db> Type<'db> { _ => self, }; - // Check for a custom `__call__` on the metaclass (excluding `type.__call__`). - // We preserve its full overload set here and defer constructor branching decisions - // until call-time overload resolution. - let metaclass_dunder_call = self_type.member_lookup_with_policy( - db, - env, - "__call__", - MemberLookupPolicy::NO_INSTANCE_FALLBACK - | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, - ); - - let Some(constructor_instance_ty) = self_type.to_instance_approximation(db, env) else { - return fallback_bindings(); + let on_cycle = || { + // Leave the return type unknown so the enclosing constructor supplies its own + // instance type, rather than the class where the cycle happened to be detected. + Binding::single(self_type, Signature::dynamic(Type::unknown())).into() }; + // Key recursion by the full receiver type. Descriptor overloads can distinguish `C` from + // `type[C]`, and different specializations need separate expansion even if one contains + // the other, because a constructor may ignore its nested type arguments. + recursion_guard.visit(&self_type, on_cycle, || { + // Check for a custom `__call__` on the metaclass (excluding `type.__call__`). + // We preserve its full overload set here and defer constructor branching decisions + // until call-time overload resolution. + let metaclass_dunder_call = self_type.member_lookup_with_policy( + db, + env, + "__call__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK + | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, + ); - // TypedDict classes inherit `dict.__new__`, whose gradual `**kwargs` signature cannot - // constrain their type variables. Their synthesized `__init__` contains the actual field - // types, including generic extra items, so constructor inference should start there. - let new_method = if class_literal.is_typed_dict(db) { - None - } else { - self_type.lookup_dunder_new(db, env) - }; + let Some(constructor_instance_ty) = self_type.to_instance_approximation(db, env) else { + return fallback_bindings(); + }; - let init_method_no_object = constructor_instance_ty.member_lookup_with_policy( - db, - env, - "__init__", - MemberLookupPolicy::NO_INSTANCE_FALLBACK | MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); + // TypedDict classes inherit `dict.__new__`, whose gradual `**kwargs` signature cannot + // constrain their type variables. Their synthesized `__init__` contains the actual field + // types, including generic extra items, so constructor inference should start there. + let new_method = if class_literal.is_typed_dict(db) { + None + } else { + self_type.lookup_dunder_new(db, env) + }; + + let init_method_no_object = constructor_instance_ty.member_lookup_with_policy( + db, + env, + "__init__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK + | MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); - let (new_bindings, has_any_new) = match new_method.as_ref().map(|method| method.place) { - Some(place) => match resolve_dunder_new_callable(db, env, self_type, place) { - Some((new_callable, definedness)) => { - let mut bindings = - bind_constructor_new(db, env, new_callable.bindings(db, env), self_type) + let (new_bindings, has_any_new) = match new_method.as_ref().map(|method| method.place) { + Some(place) => match resolve_dunder_new_callable(db, env, self_type, place) { + Some((new_callable, definedness)) => { + let bindings = new_callable.bindings_impl(db, env, recursion_guard); + let mut bindings = bind_constructor_new(db, env, bindings, self_type) .into_constructor_bindings( constructor_instance_ty, ConstructorCallableKind::New, ) .with_constructed_instance_type(db, constructor_instance_ty); - if definedness == Definedness::PossiblyUndefined { - bindings.set_implicit_dunder_new_is_possibly_unbound(); + if definedness == Definedness::PossiblyUndefined { + bindings.set_implicit_dunder_new_is_possibly_unbound(); + } + (Some(bindings), true) } - (Some(bindings), true) - } + None => (None, false), + }, None => (None, false), - }, - None => (None, false), - }; + }; - // Only fall back to `object.__init__` when `__new__` is absent. - let init_bindings = match (&init_method_no_object.place, has_any_new) { - ( - Place::Defined(DefinedPlace { - ty: init_method, - definedness, - .. - }), - _, - ) => { - let mut bindings = init_method - .bindings(db, env) - .into_constructor_bindings( - constructor_instance_ty, - ConstructorCallableKind::Init, - ) - .with_constructed_instance_type(db, constructor_instance_ty); - if *definedness == Definedness::PossiblyUndefined { - bindings.set_implicit_dunder_init_is_possibly_unbound(); - } - Some(bindings) - } - (Place::Undefined, false) => { - let init_method_with_object = constructor_instance_ty.member_lookup_with_policy( - db, - env, - "__init__", - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ); - match init_method_with_object.place { + // Only fall back to `object.__init__` when `__new__` is absent. + let init_bindings = match (&init_method_no_object.place, has_any_new) { + ( Place::Defined(DefinedPlace { ty: init_method, definedness, .. - }) => { - let mut bindings = init_method - .bindings(db, env) - .into_constructor_bindings( - constructor_instance_ty, - ConstructorCallableKind::Init, + }), + _, + ) => { + let mut bindings = init_method + .bindings_impl(db, env, recursion_guard) + .into_constructor_bindings( + constructor_instance_ty, + ConstructorCallableKind::Init, + ) + .with_constructed_instance_type(db, constructor_instance_ty); + if *definedness == Definedness::PossiblyUndefined { + bindings.set_implicit_dunder_init_is_possibly_unbound(); + } + Some(bindings) + } + (Place::Undefined, false) => { + let init_method_with_object = constructor_instance_ty + .member_lookup_with_policy( + db, + env, + "__init__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ); + match init_method_with_object.place { + Place::Defined(DefinedPlace { + ty: init_method, + definedness, + .. + }) => { + let mut bindings = init_method + .bindings_impl(db, env, recursion_guard) + .into_constructor_bindings( + constructor_instance_ty, + ConstructorCallableKind::Init, + ) + .with_constructed_instance_type(db, constructor_instance_ty); + if definedness == Definedness::PossiblyUndefined { + bindings.set_implicit_dunder_init_is_possibly_unbound(); + } + Some(bindings) + } + Place::Undefined => { + // If we are using vendored typeshed, it should be impossible to have missing + // or unbound `__init__` method on a class, as all classes have `object` in MRO. + // Thus the following may only trigger if a custom typeshed is used. + // Custom/broken typeshed: no `__init__` available even after falling back + // to `object`. Keep analysis going and surface the missing-implicit-call + // lint via the builder. + let mut bindings: Bindings<'db> = Binding::single( + self_type, + Signature::new(Parameters::gradual_form(), constructor_instance_ty), ) - .with_constructed_instance_type(db, constructor_instance_ty); - if definedness == Definedness::PossiblyUndefined { + .into(); + bindings = bindings + .into_constructor_bindings( + constructor_instance_ty, + ConstructorCallableKind::Init, + ) + .with_constructed_instance_type(db, constructor_instance_ty); bindings.set_implicit_dunder_init_is_possibly_unbound(); + Some(bindings) } - Some(bindings) - } - Place::Undefined => { - // If we are using vendored typeshed, it should be impossible to have missing - // or unbound `__init__` method on a class, as all classes have `object` in MRO. - // Thus the following may only trigger if a custom typeshed is used. - // Custom/broken typeshed: no `__init__` available even after falling back - // to `object`. Keep analysis going and surface the missing-implicit-call - // lint via the builder. - let mut bindings: Bindings<'db> = Binding::single( - self_type, - Signature::new(Parameters::gradual_form(), constructor_instance_ty), - ) - .into(); - bindings = bindings - .into_constructor_bindings( - constructor_instance_ty, - ConstructorCallableKind::Init, - ) - .with_constructed_instance_type(db, constructor_instance_ty); - bindings.set_implicit_dunder_init_is_possibly_unbound(); - Some(bindings) } } - } - (Place::Undefined, true) => None, - }; + (Place::Undefined, true) => None, + }; - let constructor_bindings = if let Some(mut new_bindings) = new_bindings { - // Preserve the full `__new__` signature and defer `__init__` validation until we know - // which `__new__` overload matched at call time. - if let Some(init_bindings) = init_bindings.as_ref() { - new_bindings.set_downstream_constructor(init_bindings); - } - Some(new_bindings) - } else { - init_bindings - }; + let constructor_bindings = if let Some(mut new_bindings) = new_bindings { + // Preserve the full `__new__` signature and defer `__init__` validation until we know + // which `__new__` overload matched at call time. + if let Some(init_bindings) = init_bindings.as_ref() { + new_bindings.set_downstream_constructor(init_bindings); + } + Some(new_bindings) + } else { + init_bindings + }; - let bindings = if let Place::Defined(DefinedPlace { - ty: metaclass_call_method, - .. - }) = metaclass_dunder_call.place - { - let mut metaclass_bindings = metaclass_call_method - .bindings(db, env) - .into_constructor_bindings( - constructor_instance_ty, - ConstructorCallableKind::MetaclassCall, - ) - .with_constructed_instance_type(db, constructor_instance_ty); - if let Some(downstream_bindings) = constructor_bindings.as_ref() { - // Preserve the full metaclass `__call__` signature and defer whether constructor - // downstream checks apply until the matched overload is known. - metaclass_bindings.set_downstream_constructor(downstream_bindings); - } - metaclass_bindings - } else if let Some(constructor_bindings) = constructor_bindings { - constructor_bindings - } else { - return fallback_bindings(); - }; + let bindings = if let Place::Defined(DefinedPlace { + ty: metaclass_call_method, + .. + }) = metaclass_dunder_call.place + { + let mut metaclass_bindings = metaclass_call_method + .bindings_impl(db, env, recursion_guard) + .into_constructor_bindings( + constructor_instance_ty, + ConstructorCallableKind::MetaclassCall, + ) + .with_constructed_instance_type(db, constructor_instance_ty); + if let Some(downstream_bindings) = constructor_bindings.as_ref() { + // Preserve the full metaclass `__call__` signature and defer whether constructor + // downstream checks apply until the matched overload is known. + metaclass_bindings.set_downstream_constructor(downstream_bindings); + } + metaclass_bindings + } else if let Some(constructor_bindings) = constructor_bindings { + constructor_bindings + } else { + return fallback_bindings(); + }; - bindings.with_generic_context(db, class_generic_context) + bindings.with_generic_context(db, class_generic_context) + }) } /// Calls `self`. Returns a [`CallError`] if `self` is (always or possibly) not callable, or if @@ -7572,13 +8554,12 @@ impl<'db> Type<'db> { return false; } - !matches!( - result, - Ok(PlaceAndQualifiers { - place: Place::Defined(place), - .. - }) if place.is_definitely_defined() - ) + !result.is_ok_and(|member| { + matches!( + member.member(db).place, + Place::Defined(place) if place.is_definitely_defined() + ) + }) } /// Apply `__getattr__` / `__getattribute__` fallback to an attribute-lookup result. @@ -7609,6 +8590,17 @@ impl<'db> Type<'db> { return Place::bound(setting).into(); } + if matches!( + self, + Type::KnownInstance(KnownInstanceType::TypeGenericAlias(_)) + ) { + // `GenericAlias.__getattr__` delegates to `__origin__`. For `type[T]`, the + // origin is always `type`, not `T`, even when `T` is `Any`. + return KnownClass::Type + .to_class_literal(db, env) + .member_lookup_with_policy_and_receiver(db, env, name, policy, None); + } + let name_type = Type::string_literal(db, name); match self.try_call_dunder( db, @@ -7625,6 +8617,7 @@ impl<'db> Type<'db> { receiver: self, name: name_type, }), + None, ), Err( CallDunderError::PossiblyUnbound { .. } | CallDunderError::MethodNotAvailable, @@ -7674,6 +8667,7 @@ impl<'db> Type<'db> { receiver: self, name: name_type, }), + None, ), Err(CallDunderError::PossiblyUnbound { .. }) => Place::Undefined.into(), Err(CallDunderError::MethodNotAvailable) => { @@ -7683,11 +8677,14 @@ impl<'db> Type<'db> { if let Err(error) = custom_getattribute { let member = result.unwrap_or_else(|error| error.fallback_member(db)); - return Err(MemberLookupError::new( + return member_lookup_result( db, - member.or_fall_back_to(db, env, || error.fallback_member(db)), - error.kind(db), - )); + member + .member(db) + .or_fall_back_to(db, env, || error.fallback_member(db).member(db)), + Some(error.kind(db)), + member.deprecated_properties(db), + ); } // A custom override runs before the descriptor and might return without invoking it. @@ -7784,14 +8781,12 @@ impl<'db> Type<'db> { } } - /// Get the return type of a `yield from …` expression where `self` is the type of the generator. - /// - /// This corresponds to the `ReturnT` parameter of the generic `typing.Generator[YieldT, SendT, ReturnT]` - /// protocol. + /// Extract the yield, send, and return types of a generator. fn generator_types( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, + mode: GeneratorTypeMode, ) -> Option> { // TODO: Ideally, we would first try to upcast `self` to an instance of `Generator` and *then* // match on the protocol instance to get the `ReturnType` type parameter. For now, implement @@ -7821,8 +8816,9 @@ impl<'db> Type<'db> { send_ty: Some(*send_ty), return_ty: None, }) - } else if (class.is_known(db, KnownClass::Iterator) - || class.is_known(db, KnownClass::AsyncIterator)) + } else if matches!(mode, GeneratorTypeMode::IteratorDefaults) + && (class.is_known(db, KnownClass::Iterator) + || class.is_known(db, KnownClass::AsyncIterator)) && let [yield_ty] = specialization.types(db) { let none = Type::none(db, env); @@ -7849,14 +8845,14 @@ impl<'db> Type<'db> { .materialization_kind(db) .map_or(types, |kind| types.materialize(db, env, kind)) }), - Type::TypeAlias(alias) => alias.value_type(db).generator_types(db, env), + Type::TypeAlias(alias) => alias.value_type(db).generator_types(db, env, mode), Type::Union(union) => { let mut yield_builder = Some(UnionBuilder::new(db, env)); let mut send_builder = Some(UnionBuilder::new(db, env)); let mut return_builder = Some(UnionBuilder::new(db, env)); for ty in union.elements(db) { - let gt = ty.generator_types(db, env)?; + let gt = ty.generator_types(db, env, mode)?; match gt.yield_ty { Some(ty) => yield_builder = yield_builder.map(|b| b.add(ty)), None => yield_builder = None, @@ -7887,7 +8883,7 @@ impl<'db> Type<'db> { let mut any_success = false; for ty in intersection.positive(db) { - let Some(gt) = ty.generator_types(db, env) else { + let Some(gt) = ty.generator_types(db, env, mode) else { continue; }; any_success = true; @@ -7930,22 +8926,63 @@ impl<'db> Type<'db> { } } + /// Extract explicit send constraints from a generator function's return annotation. + /// + /// An iterator annotation does not expose `send`, but its presence in a union must not + /// discard the send constraints from other generator alternatives. + fn generator_annotation_send_type( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option> { + if let Some(union) = self.as_union_like(db) { + let mut send_types = union + .elements(db) + .iter() + .filter_map(|ty| ty.generator_annotation_send_type(db, env)); + let first = send_types.next()?; + return Some( + send_types + .fold(UnionBuilder::new(db, env).add(first), UnionBuilder::add) + .build(), + ); + } + + self.generator_types(db, env, GeneratorTypeMode::GeneratorOnly) + .and_then(|types| types.send_ty) + } + fn generator_return_type( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Option> { - self.generator_types(db, env) + self.generator_types(db, env, GeneratorTypeMode::IteratorDefaults) .and_then(|generator_types| generator_types.return_ty) } - fn generator_send_type( + /// Find a delegated generator's send type that cannot accept `send_ty`. + /// + /// Check union members independently to preserve gradual assignability. Intersecting + /// `list[int]` and `list[str]` would give `Never`, incorrectly rejecting `list[Any]`. + fn incompatible_yield_from_send_type( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, + send_ty: Type<'db>, ) -> Option> { - self.generator_types(db, env) + if let Some(union) = self.as_union_like(db) { + return union + .elements(db) + .iter() + .find_map(|ty| ty.incompatible_yield_from_send_type(db, env, send_ty)); + } + + let inner_send_ty = self + .generator_types(db, env, GeneratorTypeMode::GeneratorOnly) .and_then(|generator_types| generator_types.send_ty) + .unwrap_or_else(|| Type::none(db, env)); + (!send_ty.is_assignable_to(db, env, inner_send_ty)).then_some(inner_send_ty) } /// Return the instance approximation, discarding whether the projection is exact. @@ -8017,6 +9054,7 @@ impl<'db> Type<'db> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::ModuleLiteral(_) | Type::LiteralValue(_) | Type::BoundSuper(_) @@ -8154,6 +9192,7 @@ impl<'db> Type<'db> { | Type::BoundSuper(_) | Type::ProtocolInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::TypeIs(_) | Type::TypeGuard(_) | Type::TypeForm(_) @@ -8520,101 +9559,234 @@ impl<'db> Type<'db> { /// See `Self::dunder_class` for more details. #[must_use] fn to_meta_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { - match self { - Type::Overlapping(overlapping) => overlapping.value_type(db, env).to_meta_type(db, env), - Type::Restricted(restricted) => restricted.value_type(db).to_meta_type(db, env), - Type::Deferred(deferred) => deferred.reduced(db, env).to_meta_type(db, env), - Type::Never => Type::Never, - Type::NominalInstance(instance) => instance.to_meta_type(db, env), - Type::KnownInstance(known_instance) => known_instance.to_meta_type(db, env), - Type::SpecialForm(special_form) => special_form.to_meta_type(db, env), - Type::PropertyInstance(property) => { - property.instance_class(db).to_class_literal(db, env) - } - Type::Union(union) => union.map(db, env, |ty| ty.to_meta_type(db, env)), - Type::UnsafeUnion(unsafe_union) => { - unsafe_union.map_elements(db, env, |element| element.to_meta_type(db, env)) - } - Type::TypeIs(_) | Type::TypeGuard(_) => KnownClass::Bool.to_class_literal(db, env), - Type::TypeForm(_) => Type::object().to_meta_type(db, env), - Type::LiteralValue(literal) => match literal.kind() { - LiteralValueTypeKind::Bool(_) => KnownClass::Bool.to_class_literal(db, env), - LiteralValueTypeKind::Bytes(_) => KnownClass::Bytes.to_class_literal(db, env), - LiteralValueTypeKind::Int(_) => KnownClass::Int.to_class_literal(db, env), - LiteralValueTypeKind::Enum(enum_literal) => { - // a based enum's unit variant is a singleton instance of its - // own subclass, not of the enum — only the all-unit `Enum` - // shape makes a member's class the enum itself - let variant_class = enum_literal.enum_class(db).as_static().and_then(|class| { - crate::types::class::based_enum_unit_variant_class( - db, - class, - enum_literal.name(db), + self.to_meta_type_with_recursion(db, env, &TypeRecursionContext::default()) + } + + fn to_meta_type_with_recursion( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + context: &TypeRecursionContext<'db>, + ) -> Type<'db> { + fn to_meta_type_inner<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + context: &TypeRecursionContext<'db>, + visitor: &ActiveRecursionDetector>, + ) -> Type<'db> { + match ty { + Type::Overlapping(overlapping) => { + to_meta_type_inner(db, env, overlapping.value_type(db, env), context, visitor) + } + Type::Restricted(restricted) => { + to_meta_type_inner(db, env, restricted.value_type(db), context, visitor) + } + Type::Deferred(deferred) => { + to_meta_type_inner(db, env, deferred.reduced(db, env), context, visitor) + } + Type::Never => Type::Never, + Type::NominalInstance(instance) => instance.to_meta_type(db, env), + Type::KnownInstance(known_instance) => known_instance.to_meta_type(db, env), + Type::SpecialForm(special_form) => special_form.to_meta_type(db, env), + Type::PropertyInstance(property) => { + property.instance_class(db).to_class_literal(db, env) + } + Type::SlotDescriptor(_) => { + KnownClass::MemberDescriptorType.to_class_literal(db, env) + } + Type::Union(union) => union.map(db, env, |ty| { + to_meta_type_inner(db, env, *ty, context, visitor) + }), + Type::UnsafeUnion(unsafe_union) => unsafe_union.map_elements(db, env, |element| { + to_meta_type_inner(db, env, element, context, visitor) + }), + Type::TypeIs(_) | Type::TypeGuard(_) => KnownClass::Bool.to_class_literal(db, env), + Type::TypeForm(_) => to_meta_type_inner(db, env, Type::object(), context, visitor), + Type::LiteralValue(literal) => match literal.kind() { + LiteralValueTypeKind::Bool(_) => KnownClass::Bool.to_class_literal(db, env), + LiteralValueTypeKind::Bytes(_) => KnownClass::Bytes.to_class_literal(db, env), + LiteralValueTypeKind::Int(_) => KnownClass::Int.to_class_literal(db, env), + LiteralValueTypeKind::Enum(enum_literal) => { + // a based enum's unit variant is a singleton instance of its + // own subclass, not of the enum — only the all-unit `Enum` + // shape makes a member's class the enum itself + let variant_class = + enum_literal.enum_class(db).as_static().and_then(|class| { + crate::types::class::based_enum_unit_variant_class( + db, + class, + enum_literal.name(db), + ) + }); + Type::ClassLiteral( + variant_class.unwrap_or_else(|| enum_literal.enum_class(db)), ) - }); - Type::ClassLiteral(variant_class.unwrap_or_else(|| enum_literal.enum_class(db))) + } + LiteralValueTypeKind::String(_) + | LiteralValueTypeKind::LiteralString + | LiteralValueTypeKind::Template(_) => { + KnownClass::Str.to_class_literal(db, env) + } + LiteralValueTypeKind::Float(_) => KnownClass::Float.to_class_literal(db, env), + LiteralValueTypeKind::Complex(_) => { + KnownClass::Complex.to_class_literal(db, env) + } + }, + Type::FunctionLiteral(_) => KnownClass::FunctionType.to_class_literal(db, env), + Type::BoundMethod(_) => KnownClass::MethodType.to_class_literal(db, env), + Type::KnownBoundMethod(method) => method.class().to_class_literal(db, env), + Type::WrapperDescriptor(_) => { + KnownClass::WrapperDescriptorType.to_class_literal(db, env) } - LiteralValueTypeKind::String(_) - | LiteralValueTypeKind::LiteralString - | LiteralValueTypeKind::Template(_) => KnownClass::Str.to_class_literal(db, env), - LiteralValueTypeKind::Float(_) => KnownClass::Float.to_class_literal(db, env), - LiteralValueTypeKind::Complex(_) => KnownClass::Complex.to_class_literal(db, env), - }, - Type::FunctionLiteral(_) => KnownClass::FunctionType.to_class_literal(db, env), - Type::BoundMethod(_) => KnownClass::MethodType.to_class_literal(db, env), - Type::KnownBoundMethod(method) => method.class().to_class_literal(db, env), - Type::WrapperDescriptor(_) => { - KnownClass::WrapperDescriptorType.to_class_literal(db, env) - } - Type::DataclassDecorator(_) => KnownClass::FunctionType.to_class_literal(db, env), - Type::Callable(callable) if callable.is_function_like(db) => { - KnownClass::FunctionType.to_class_literal(db, env) - } - Type::Callable(_) | Type::DataclassTransformer(_) => { - KnownClass::Type.to_instance(db, env) - } - Type::ModuleLiteral(_) => KnownClass::ModuleType.to_class_literal(db, env), - Type::TypeVar(bound_typevar) => { - SubclassOfType::from(db, env, SubclassOfInner::TypeVar(bound_typevar)) - } - Type::ClassLiteral(class) => class.metaclass(db), - Type::GenericAlias(alias) => ClassType::from(alias).metaclass(db), - Type::SubclassOf(subclass_of_ty) => subclass_of_ty.to_meta_type(db, env), - Type::Dynamic(dynamic) => { - SubclassOfType::from(db, env, SubclassOfInner::Dynamic(dynamic)) - } - Type::Divergent(_) => self, - // TODO intersections - Type::Intersection(intersection) => { - if let Some(alternatives) = intersection.finite_alternative_union(db, env) { - alternatives.to_meta_type(db, env) - } else { - SubclassOfType::try_from_type(db, env, todo_type!("Intersection meta-type")) - .expect("Type::Todo should be a valid `SubclassOfInner`") + Type::DataclassDecorator(_) => KnownClass::FunctionType.to_class_literal(db, env), + Type::Callable(callable) if callable.is_function_like(db) => { + KnownClass::FunctionType.to_class_literal(db, env) } - } - Type::EnumComplement(complement) => complement - .remaining_literal_union(db, env) - .to_meta_type(db, env), - Type::AlwaysTruthy | Type::AlwaysFalsy => KnownClass::Type.to_instance(db, env), - Type::BoundSuper(_) => KnownClass::Super.to_class_literal(db, env), - // Class-member lookup on a protocol instance must use the protocol's nominal class. - // The structural `type[Protocol]` view is exposed by `dunder_class` and explicit - // `type[Protocol]` annotations instead. - Type::ProtocolInstance(protocol) => protocol.to_nominal_meta_type(db, env), - // `TypedDict` instances are instances of `dict` at runtime, but its important that we - // understand a more specific meta type in order to correctly handle `__getitem__`. - Type::TypedDict(typed_dict) => match typed_dict { - TypedDictType::Class(class) => SubclassOfType::from(db, env, class), - TypedDictType::Synthesized(_) => SubclassOfType::from( + Type::Callable(_) | Type::DataclassTransformer(_) => { + KnownClass::Type.to_instance(db, env) + } + Type::ModuleLiteral(_) => KnownClass::ModuleType.to_class_literal(db, env), + Type::TypeVar(bound_typevar) => { + SubclassOfType::from(db, env, SubclassOfInner::TypeVar(bound_typevar)) + } + Type::ClassLiteral(class) => class.metaclass(db), + Type::GenericAlias(alias) => ClassType::from(alias).metaclass(db), + Type::SubclassOf(subclass_of_ty) + if let SubclassOfInner::TypeVar(typevar) = subclass_of_ty.subclass_of() => + { + // Transposition changes a type variable's bounds but preserves its bound + // identity. Guard by that identity so newly transposed instances still match. + context.meta_type.typevars.visit( + &(env.program(db), typevar.identity(db)), + || KnownClass::Type.to_instance(db, env), + || subclass_of_ty.to_meta_type_with_recursion(db, env, context), + ) + } + Type::SubclassOf(subclass_of_ty) => { + subclass_of_ty.to_meta_type_with_recursion(db, env, context) + } + Type::Dynamic(dynamic) => { + SubclassOfType::from(db, env, SubclassOfInner::Dynamic(dynamic)) + } + Type::Divergent(_) => ty, + Type::Intersection(intersection) => { + if let Some(alternatives) = intersection.finite_alternative_union(db, env) { + to_meta_type_inner(db, env, alternatives, context, visitor) + } else { + // Negative constraints do not generally constrain classes: `int & ~Literal[0]` + // still has meta-type `type[int]`. Pure negations are bounded by `object`. + let mut builder = IntersectionBuilder::new(db, env); + for positive in intersection.positive_elements_or_object(db) { + builder.add_positive_in_place(to_meta_type_inner( + db, env, positive, context, visitor, + )); + } + + // An exclusion can narrow a type variable's union bound to a definite class: + // `(T: C | None) & ~None` has meta-type `type[T] & type[C]`. + // If the remaining bound is a class object, retain its metaclass instead. + // Structural bounds need separate runtime-class handling (see `dunder_class`). + if !intersection.negative(db).is_empty() + && intersection + .iter_positive(db) + .any(|positive| matches!(positive, Type::TypeVar(_))) + && let Some(narrowed_bound) = + match intersection.with_expanded_typevars_and_newtypes(db, env) { + bound @ (Type::NominalInstance(_) + | Type::ClassLiteral(_) + | Type::GenericAlias(_)) => Some(bound), + bound @ Type::SubclassOf(subclass_of) + if let SubclassOfInner::Class(_) = + subclass_of.subclass_of() => + { + Some(bound) + } + _ => None, + } + { + builder.add_positive_in_place(to_meta_type_inner( + db, + env, + narrowed_bound, + context, + visitor, + )); + } + + builder.build() + } + } + Type::EnumComplement(complement) => to_meta_type_inner( db, env, - todo_type!("TypedDict synthesized meta-type").expect_dynamic(), + complement.remaining_literal_union(db, env), + context, + visitor, ), - }, - Type::TypeAlias(alias) => alias.value_type(db).to_meta_type(db, env), - Type::NewTypeInstance(newtype) => newtype.concrete_base_type(db).to_meta_type(db, env), + Type::AlwaysTruthy | Type::AlwaysFalsy => KnownClass::Type.to_instance(db, env), + Type::BoundSuper(_) => KnownClass::Super.to_class_literal(db, env), + // Class-member lookup on a protocol instance must use the protocol's nominal class. + // The structural `type[Protocol]` view is exposed by `dunder_class` and explicit + // `type[Protocol]` annotations instead. + Type::ProtocolInstance(protocol) => protocol.to_nominal_meta_type(db, env), + // `TypedDict` instances are instances of `dict` at runtime, but its important that we + // understand a more specific meta type in order to correctly handle `__getitem__`. + Type::TypedDict(typed_dict) => match typed_dict { + TypedDictType::Class(class) => SubclassOfType::from(db, env, class), + TypedDictType::Synthesized(_) => SubclassOfType::from( + db, + env, + todo_type!("TypedDict synthesized meta-type").expect_dynamic(), + ), + }, + Type::TypeAlias(alias) => { + // A repeated specialization adds no new classes to a recursive union. Changing + // type arguments can introduce other classes, so use an unconstrained metatype. + // Do not cache results: a projection made while another alias is active can omit + // classes that are only encountered later in that alias's union. + visitor.visit( + &alias, + || Type::Never, + || { + context.meta_type.aliases.visit( + &(env.program(db), alias), + || KnownClass::Type.to_instance(db, env), + || { + let project_alias = || { + to_meta_type_inner( + db, + env, + alias.value_type_with_recursion(db, Some(context)), + context, + visitor, + ) + }; + // Identity analysis can itself expand aliases, so establish the + // exact-alias guard before checking for growing specializations. + if let TypeIdentity::GrowingTypeAlias(definition) = + Type::TypeAlias(alias).to_type_identity(db) + { + context.meta_type.growing_aliases.visit( + &(env.program(db), definition), + || KnownClass::Type.to_instance(db, env), + project_alias, + ) + } else { + project_alias() + } + }, + ) + }, + ) + } + Type::NewTypeInstance(newtype) => { + to_meta_type_inner(db, env, newtype.concrete_base_type(db), context, visitor) + } + } } + + to_meta_type_inner(db, env, self, context, &ActiveRecursionDetector::default()) } /// Get the type of the `__class__` attribute of this type. @@ -8624,31 +9796,59 @@ impl<'db> Type<'db> { /// Class-backed protocols return their structural `type[Protocol]` view. #[must_use] fn dunder_class(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { - match self { - Type::Union(union) => union.map(db, env, |element| element.dunder_class(db, env)), - Type::UnsafeUnion(unsafe_union) => { - unsafe_union.map_elements(db, env, |element| element.dunder_class(db, env)) + fn dunder_class_inner<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + visitor: &ActiveRecursionDetector>, + ) -> Type<'db> { + match ty { + Type::Union(union) => union.map(db, env, |element| { + dunder_class_inner(db, env, *element, visitor) + }), + Type::UnsafeUnion(unsafe_union) => unsafe_union.map_elements(db, env, |element| { + dunder_class_inner(db, env, element, visitor) + }), + Type::Intersection(intersection) => intersection + .try_dunder_class(db, env) + .unwrap_or_else(|| ty.to_meta_type(db, env)), + Type::ProtocolInstance(protocol) => protocol.to_meta_type(db, env), + Type::TypedDict(_) => KnownClass::Dict + .to_specialized_class_type( + db, + env, + &[KnownClass::Str.to_instance(db, env), Type::object()], + ) + .map(Type::from) + // Guard against user-customized typesheds with a broken `dict` class + .unwrap_or_else(Type::unknown), + // An alias is only a name for its value, so `__class__` has to be + // answered by the value — otherwise the fallback below gives the + // *meta* type, which for a `TypedDict` is the class it is modelled + // by rather than the `dict` its inhabitants really are. + // + // An alias whose value names it again — `type R[T, U] = T | R[U, T]` — would + // expand forever, so each one is entered once. It is invalid either way, and + // the class of what it does hold is answered from the members that terminate. + // + // One whose arguments *grow* — `type R[T] = T | R[list[T]]` — is never the same + // alias twice, so entering each once is no bound at all. The meta type handles + // that shape already, and reaching it here means giving up the `TypedDict` + // reading above, which such an alias does not have anyway. + Type::TypeAlias(alias) + if !matches!(ty.to_type_identity(db), TypeIdentity::GrowingTypeAlias(_)) => + { + visitor.visit( + &alias, + || Type::Never, + || dunder_class_inner(db, env, alias.value_type(db), visitor), + ) + } + _ => ty.to_meta_type(db, env), } - Type::Intersection(intersection) => intersection - .try_dunder_class(db, env) - .unwrap_or_else(|| self.to_meta_type(db, env)), - Type::ProtocolInstance(protocol) => protocol.to_meta_type(db, env), - Type::TypedDict(_) => KnownClass::Dict - .to_specialized_class_type( - db, - env, - &[KnownClass::Str.to_instance(db, env), Type::object()], - ) - .map(Type::from) - // Guard against user-customized typesheds with a broken `dict` class - .unwrap_or_else(Type::unknown), - // An alias is only a name for its value, so `__class__` has to be - // answered by the value — otherwise the fallback below gives the - // *meta* type, which for a `TypedDict` is the class it is modelled - // by rather than the `dict` its inhabitants really are - Type::TypeAlias(alias) => alias.value_type(db).dunder_class(db, env), - _ => self.to_meta_type(db, env), } + + dunder_class_inner(db, env, self, &ActiveRecursionDetector::default()) } #[must_use] @@ -8664,45 +9864,87 @@ impl<'db> Type<'db> { } } - /// basedpython: applies `specialization` to a member type, honoring any - /// use-site variance projections it carries. with no projections this is - /// exactly [`Type::apply_specialization`]; with projections, projected - /// typevars are substituted directionally by position — see - /// [`TypeMapping::ProjectUseSiteVariance`]. this is how `Container[out T]` - /// rejects writes (and `Container[in T]` rejects reads) through *methods*, - /// not just subscripts and attributes + /// basedpython: applies `specialization` at a member-projection boundary. + /// + /// This is the projecting counterpart of + /// [`Type::apply_optional_owner_specialization_to_member`], and carries the same extra duty: + /// the domain of a retained synthetic `Self` is rewritten along with everything else. Without + /// it a method reached through `Box[int]` keeps `Box[T@Box]` as its `Self` bound, and the + /// receiver then fails to satisfy a bound naming a variable the receiver has already fixed. #[must_use] - pub(crate) fn apply_projected_specialization( + fn apply_projected_owner_specialization_to_member( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + specialization: Specialization<'db>, + ) -> Type<'db> { + self.apply_projected_specialization_impl(db, env, specialization, true) + } + + /// basedpython: applies `specialization` to a member type, honoring any use-site variance + /// projections it carries. with no projections this is exactly + /// [`Type::apply_specialization`]; with projections, projected typevars are substituted + /// directionally by position — see [`TypeMapping::ProjectUseSiteVariance`]. this is how + /// `Container[out T]` rejects writes (and `Container[in T]` rejects reads) through + /// *methods*, not just subscripts and attributes + /// + /// `specialize_self_domain` additionally rewrites the domain of a retained synthetic `Self`, + /// which is what a projection boundary needs and what ordinary specialization must not do + fn apply_projected_specialization_impl( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, specialization: Specialization<'db>, + specialize_self_domain: bool, ) -> Type<'db> { if specialization.projections(db).iter().any(Option::is_some) { + let specialization = if specialize_self_domain { + ApplySpecialization::specialization_for_member(specialization) + } else { + ApplySpecialization::specialization(specialization) + }; self.apply_type_mapping( db, env, &TypeMapping::ProjectUseSiteVariance { - specialization: ApplySpecialization::Specialization(specialization), + specialization, position: TypeVarVariance::Covariant, }, TypeContext::default(), ) } else { - self.apply_specialization(db, specialization) + self.apply_specialization_impl(db, specialization, specialize_self_domain) } } - /// basedpython: the `Option` analogue of [`Type::apply_projected_specialization`] + /// basedpython: the `Option` analogue of + /// [`Type::apply_projected_owner_specialization_to_member`] #[must_use] - pub(crate) fn apply_projected_optional_specialization( + fn apply_projected_optional_owner_specialization_to_member( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, specialization: Option>, ) -> Type<'db> { if let Some(specialization) = specialization { - self.apply_projected_specialization(db, env, specialization) + self.apply_projected_owner_specialization_to_member(db, env, specialization) + } else { + self + } + } + + /// Projects a member from its generic owner, applying the owner's specialization to both + /// ordinary occurrences and the domain of any retained synthetic `Self` variable. + /// + /// Rewriting the `Self` domain is specific to this projection boundary. Inference and other + /// ordinary specializations must preserve that domain as fixed evidence. + fn apply_optional_owner_specialization_to_member( + self, + db: &'db dyn Db, + specialization: Option>, + ) -> Type<'db> { + if let Some(specialization) = specialization { + self.apply_specialization_impl(db, specialization, true) } else { self } @@ -8718,6 +9960,19 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, specialization: Specialization<'db>, + ) -> Type<'db> { + self.apply_specialization_impl(db, specialization, false) + } + + /// Applies either an ordinary specialization or an enclosing-owner specialization. + /// + /// Both modes share the same leaf fast paths. They differ only in whether a retained synthetic + /// `Self` domain is part of the substitution. + fn apply_specialization_impl( + self, + db: &'db dyn Db, + specialization: Specialization<'db>, + specialize_self_domain: bool, ) -> Type<'db> { if matches!( self, @@ -8760,7 +10015,6 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) @@ -8777,13 +10031,13 @@ impl<'db> Type<'db> { return self; } - self.apply_specialization_inner(db, specialization) + self.apply_specialization_inner(db, specialization, specialize_self_domain) } #[salsa::tracked( returns(copy), - cycle_initial=|_, id, _, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _, specialization: Specialization<'db>| { + cycle_initial=|_, id, _, _, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _, specialization: Specialization<'db>, _| { let env = ProgramEnvironment::from_program( specialization.generic_context(db).program(db), ); @@ -8795,14 +10049,17 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, specialization: Specialization<'db>, + specialize_self_domain: bool, ) -> Type<'db> { let env = &ProgramEnvironment::from_program(specialization.generic_context(db).program(db)); + let apply_specialization = ApplySpecialization::Specialization { + specialization, + specialize_self_domain, + }; let type_mapping = match specialization.materialization_kind(db) { - None => TypeMapping::ApplySpecialization(ApplySpecialization::Specialization( - specialization, - )), + None => TypeMapping::ApplySpecialization(apply_specialization), Some(materialization_kind) => TypeMapping::ApplySpecializationWithMaterialization { - specialization: ApplySpecialization::Specialization(specialization), + specialization: apply_specialization, materialization_kind, }, }; @@ -8861,6 +10118,98 @@ impl<'db> Type<'db> { return SubclassOfType::from(db, visitor.env, class.default_specialization(db)); } + // Expand union-valued `ParamSpec`s before specializing a given callable. + if let TypeMapping::ApplySpecialization(specialization) + | TypeMapping::ApplySpecializationWithMaterialization { specialization, .. } = + type_mapping + { + let function_signatures = |function: FunctionType<'db>| { + if specialization.preserves_lazy_signatures() { + function.updated_signature(db) + } else { + Some(function.signature(db)) + } + }; + + let signatures = match self { + Type::FunctionLiteral(function) => function_signatures(function), + Type::BoundMethod(method) => function_signatures(method.function(db)), + Type::Callable(callable) => Some(callable.signatures(db)), + _ => None, + }; + + let mut seen = FxHashSet::default(); + let union_paramspecs = signatures + .into_iter() + .flat_map(|signatures| signatures.iter()) + .filter_map(|signature| { + let (_, typevar) = signature.parameters().as_paramspec_with_prefix()?; + let Type::Union(union) = specialization.get(db, typevar)? else { + return None; + }; + + Some((typevar, union)) + }) + .filter(|(typevar, _)| seen.insert(typevar.identity(db))) + .collect::>(); + + if !union_paramspecs.is_empty() { + // Independent union-valued `ParamSpec`s produce a Cartesian product. Bound + // the expansion to avoid exponential blowup. + const MAX_PARAMSPEC_EXPANSION: usize = 64; + + let mut expanded_callables = UnionBuilder::new(db, visitor.env); + let mut expansion_size = 1usize; + for (_, union) in &union_paramspecs { + expansion_size = expansion_size.saturating_mul(union.elements(db).len()); + if expansion_size > MAX_PARAMSPEC_EXPANSION { + return Type::unknown(); + } + + if union.recursively_defined(db).is_yes() { + expanded_callables = + expanded_callables.recursively_defined(RecursivelyDefined::Yes); + } + } + + return visitor.visit(db, self, type_mapping, || { + let expanded_paramspecs = union_paramspecs + .iter() + .map(|(typevar, union)| { + union.elements(db).iter().map(move |ty| (*typevar, *ty)) + }) + .multi_cartesian_product(); + + for bindings in expanded_paramspecs { + // Override the specialization with a specific parameter-list assigned to + // each `ParamSpec` from the union expansion. + let specialization = ApplySpecialization::WithBindings { + specialization, + bindings: &bindings, + }; + + let mapping = match type_mapping { + TypeMapping::ApplySpecializationWithMaterialization { + materialization_kind, + .. + } => TypeMapping::ApplySpecializationWithMaterialization { + specialization, + materialization_kind: *materialization_kind, + }, + _ => TypeMapping::ApplySpecialization(specialization), + }; + + // Use a fresh visitor, as the visitor cache does not distinguish + // between these specialization bindings. + let callable = self.apply_type_mapping(db, visitor.env, &mapping, tcx); + expanded_callables.add_in_place(callable); + } + + expanded_callables.build() + }); + } + } + match self { Type::TypeVar(bound_typevar) => { bound_typevar.apply_type_mapping_impl(db, env, type_mapping, visitor) @@ -8913,6 +10262,13 @@ impl<'db> Type<'db> { tcx, visitor, ), + method.signature_receiver(db).apply_type_mapping_impl( + db, + env, + type_mapping, + tcx, + visitor, + ), )), // `RegularStrictNumeric` is deliberately absent: it promotes everything @@ -9017,6 +10373,17 @@ impl<'db> Type<'db> { property.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor), ), + Type::SlotDescriptor(descriptor) => Type::SlotDescriptor(SlotDescriptorType::new( + db, + descriptor.value_type(db).apply_type_mapping_impl( + db, + env, + type_mapping, + tcx, + visitor, + ), + )), + Type::Union(union) => union.map_leave_aliases(db, visitor.env, |element| { element.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor) }), @@ -9164,7 +10531,9 @@ impl<'db> Type<'db> { match type_mapping { TypeMapping::Materialize(_) if alias.materialization_kind(db).is_some() => self, TypeMapping::EagerExpansion if alias.materialization_kind(db).is_some() => { - alias.value_type(db).expand_eagerly(db, visitor.env) + alias + .value_type_with_recursion(db, visitor.recursion_context) + .expand_eagerly(db, visitor.env) } // For EagerExpansion, expand the raw value type. This path relies on Salsa's cycle // detection rather than the visitor's cycle detection, because the visitor tracks @@ -9177,15 +10546,9 @@ impl<'db> Type<'db> { TypeMapping::ApplySpecialization(specialization) | TypeMapping::ApplySpecializationWithMaterialization { specialization, .. - } if matches!( - specialization, - ApplySpecialization::Specialization(_) - | ApplySpecialization::TypeAlias(_) - | ApplySpecialization::Partial { .. } - ) => + } if let Some(mut current_specialization) = + specialization.as_specialization(db) => { - let mut current_specialization = - specialization.as_specialization(db).unwrap(); if let TypeMapping::ApplySpecializationWithMaterialization { materialization_kind, .. @@ -9198,7 +10561,11 @@ impl<'db> Type<'db> { alias .specialization(db) .unwrap_or_else(|| generic_context.default_specialization(db, None)) - .apply_specialization(db, current_specialization) + .apply_specialization_with_recursion( + db, + current_specialization, + visitor.recursion_context, + ) })) } _ => { @@ -9206,25 +10573,21 @@ impl<'db> Type<'db> { // this same TypeAlias again (e.g., in `type RecursiveT = int | tuple[RecursiveT, ...]`), the visitor // will detect the cycle and return the fallback value. let mapped = visitor.visit(db, self, type_mapping, || { - alias.value_type(db).apply_type_mapping_impl( - db, - env, - type_mapping, - tcx, - visitor, - ) + alias + .value_type_with_recursion(db, visitor.recursion_context) + .apply_type_mapping_impl(db, env, type_mapping, tcx, visitor) }); // If the type mapping does not result in any change to this type alias, keep the // alias node instead of eagerly expanding it. A recursive backedge also returns // the alias itself, and fully static aliases must retain their original identity. - if mapped == self || alias.value_type(db) == mapped { + if mapped == self + || alias.value_type_with_recursion(db, visitor.recursion_context) + == mapped + { self } else if let TypeMapping::Materialize(materialization_kind) = type_mapping - && matches!( - self.to_type_identity(db), - cyclic::TypeIdentity::RecursiveTypeAlias(_) - ) + && alias.is_recursive(db) { Type::TypeAlias( alias.with_materialization_kind(db, Some(*materialization_kind)), @@ -9327,7 +10690,6 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), @@ -9457,6 +10819,16 @@ impl<'db> Type<'db> { property.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); }), + Type::SlotDescriptor(descriptor) => visitor.visit(db, self, || { + descriptor.value_type(db).find_legacy_typevars_impl( + db, + env, + binding_context, + typevars, + visitor, + ); + }), + Type::Union(union) => { for element in union.elements(db) { element.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); @@ -9499,6 +10871,14 @@ impl<'db> Type<'db> { instance.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); } + Type::TypedDict(TypedDictType::Class(class)) => { + class.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); + } + + // Synthesized schemas can contain type variables, but their internal narrowing and + // update constraints inherit those variables from an existing generic context. + Type::TypedDict(TypedDictType::Synthesized(_)) => {} + Type::NewTypeInstance(_) => { // A newtype can never be constructed from an unspecialized generic class, so it is // impossible that we could ever find any legacy typevars in a newtype instance or @@ -9656,7 +11036,6 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), @@ -9666,8 +11045,7 @@ impl<'db> Type<'db> { | Type::ModuleLiteral(_) | Type::ClassLiteral(_) | Type::BoundSuper(_) - | Type::SpecialForm(_) - | Type::TypedDict(_) => {} + | Type::SpecialForm(_) => {} // basedpython: a template's holes are types, and a legacy typevar // can be spelled in one @@ -9885,6 +11263,12 @@ impl<'db> Type<'db> { .and_then(|deleter| deleter.definition(db, env)) }), + // Navigating to the type of `Slotted.value` should open the `MemberDescriptorType` + // class in typeshed, rather than the slot's instance-value annotation. + Self::SlotDescriptor(_) => KnownClass::MemberDescriptorType + .to_instance(db, env) + .definition(db, env), + Self::LiteralValue(literal) => literal .as_enum() .and_then(|enum_lit| enum_lit.definition(db)) @@ -9931,6 +11315,7 @@ impl<'db> Type<'db> { Self::Dynamic( DynamicType::Unknown | DynamicType::UnknownGeneric(_) + | DynamicType::UnknownLambdaParameter | DynamicType::AmbiguousOverload, ) => Type::SpecialForm(SpecialFormType::Unknown).definition(db, env), Self::Divergent(_) => Type::SpecialForm(SpecialFormType::Divergent).definition(db, env), @@ -10259,7 +11644,7 @@ impl<'db> VarianceInferable<'db> for Type<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { tracing::trace!( "Checking variance of '{tvar}' in `{ty:?}`", tvar = typevar.identity.name(db), @@ -10289,26 +11674,33 @@ impl<'db> VarianceInferable<'db> for Type<'db> { // A type variable is always covariant in itself. Type::TypeVar(other_typevar) if other_typevar.identity(db) == typevar => { // type variables are covariant in themselves - TypeVarVariance::Covariant + TypeVarVariance::Covariant.into() } Type::ProtocolInstance(protocol_instance_type) => { protocol_instance_type.variance_of(db, env, typevar) } + Type::TypedDict(typed_dict) => typed_dict.variance_of(db, env, typevar), // unions are covariant in their disjuncts - Type::Union(union_type) => union_type - .elements(db) - .iter() - .map(|ty| ty.variance_of(db, env, typevar)) - .collect(), + Type::Union(union_type) => VarianceTerm::join( + db, + union_type + .elements(db) + .iter() + .map(|ty| ty.variance_of(db, env, typevar)), + ), Type::UnsafeUnion(unsafe_union) => unsafe_union.variance_of(db, env, typevar), // basedpython: a template's holes each render through `str()`, which // reads its argument and produces a fresh string — a covariant use - Type::LiteralValue(literal) if let Some(template) = literal.as_template() => template - .holes(db) - .map(|hole| hole.variance_of(db, env, typevar)) - .collect(), + Type::LiteralValue(literal) if let Some(template) = literal.as_template() => { + VarianceTerm::join( + db, + template + .holes(db) + .map(|hole| hole.variance_of(db, env, typevar)), + ) + } // Products are covariant in their conjuncts. For negative // conjuncts, they're contravariant. To see this, suppose we have @@ -10316,25 +11708,42 @@ impl<'db> VarianceInferable<'db> for Type<'db> { // `A`, and so is not assignable to `~A`. On the other hand, a value // of type `~A` excludes all `A`s, and thus all `B`s, and so _is_ // assignable to `~B`. - Type::Intersection(intersection_type) => intersection_type - .positive(db) - .iter() - .map(|ty| ty.variance_of(db, env, typevar)) - .chain(intersection_type.negative(db).iter().map(|ty| { - ty.with_polarity(TypeVarVariance::Contravariant) - .variance_of(db, env, typevar) - })) - .collect(), + Type::Intersection(intersection_type) => VarianceTerm::join( + db, + intersection_type + .positive(db) + .iter() + .map(|ty| ty.variance_of(db, env, typevar)) + .chain(intersection_type.negative(db).iter().map(|ty| { + ty.with_polarity(TypeVarVariance::Contravariant) + .variance_of(db, env, typevar) + })), + ), Type::EnumComplement(complement) => complement .to_intersection(db, env) .variance_of(db, env, typevar), - Type::PropertyInstance(property_instance_type) => property_instance_type - .getter(db) - .iter() - .chain(&property_instance_type.setter(db)) - .chain(&property_instance_type.deleter(db)) - .map(|ty| ty.variance_of(db, env, typevar)) - .collect(), + Type::PropertyInstance(property_instance_type) => VarianceTerm::join( + db, + [ + Some(property_instance_type.instance_fallback(db, env)), + property_instance_type.getter(db), + property_instance_type.setter(db), + property_instance_type.deleter(db), + ] + .into_iter() + .flatten() + .map(|ty| ty.variance_of(db, env, typevar)), + ), + // A generic class can store another class's slot descriptor directly: + // + // class Owner[T]: + // descriptor = Slotted[T].value + // + // The descriptor's value can be both read and written, so `Owner` is invariant in T. + Type::SlotDescriptor(descriptor) => descriptor + .value_type(db) + .with_polarity(TypeVarVariance::Invariant) + .variance_of(db, env, typevar), Type::SubclassOf(subclass_of_type) => subclass_of_type.variance_of(db, env, typevar), Type::TypeIs(type_is_type) => type_is_type.variance_of(db, env, typevar), Type::TypeGuard(type_guard_type) => type_guard_type.variance_of(db, env, typevar), @@ -10344,7 +11753,6 @@ impl<'db> VarianceInferable<'db> for Type<'db> { Type::Deferred(deferred) => deferred.reduced(db, env).variance_of(db, env, typevar), Type::KnownInstance(known_instance) => known_instance.variance_of(db, env, typevar), Type::TypeAlias(alias) => alias.variance_of(db, env, typevar), - Type::TypedDict(typed_dict) => typed_dict.variance_of(db, env, typevar), Type::Dynamic(_) | Type::Divergent(_) | Type::Never @@ -10359,7 +11767,7 @@ impl<'db> VarianceInferable<'db> for Type<'db> { | Type::AlwaysTruthy | Type::BoundSuper(_) | Type::TypeVar(_) - | Type::NewTypeInstance(_) => TypeVarVariance::Bivariant, + | Type::NewTypeInstance(_) => VarianceTerm::BIVARIANT, }; tracing::trace!( @@ -10590,22 +11998,33 @@ impl<'db> TypeMapping<'_, 'db> { | TypeMapping::ProjectUseSiteVariance { specialization, .. } => { // Filter out type variables that are already specialized // (i.e., mapped to a non-TypeVar type) - GenericContext::from_typevar_instances( - db, - env, - context.variables(db).filter(|bound_typevar| { - // Keep the type variable if it's not in the specialization - // or if it's mapped to itself (still a TypeVar) - match specialization.get(db, *bound_typevar) { - None => true, - Some(Type::TypeVar(mapped_typevar)) => { - // Still a TypeVar, keep it if it's mapping to itself - mapped_typevar.identity(db) == bound_typevar.identity(db) - } - Some(_) => false, // Specialized to a concrete type, filter out + let kept = context.variables(db).filter(|bound_typevar| { + // Keep the type variable if it's not in the specialization + // or if it's mapped to itself (still a TypeVar) + match specialization.get(db, *bound_typevar) { + None => true, + Some(Type::TypeVar(mapped_typevar)) => { + // Still a TypeVar, keep it if it's mapping to itself + mapped_typevar.identity(db) == bound_typevar.identity(db) } - }), - ) + Some(_) => false, // Specialized to a concrete type, filter out + } + }); + if specialization.specialize_self_domain() { + let kept = kept.filter_map(|bound_typevar| { + Type::TypeVar(bound_typevar) + .apply_type_mapping( + db, + env, + &TypeMapping::ApplySpecialization(*specialization), + TypeContext::default(), + ) + .as_typevar() + }); + GenericContext::from_typevar_instances(db, env, kept) + } else { + GenericContext::from_typevar_instances(db, env, kept) + } } TypeMapping::Promote(..) | TypeMapping::BindLegacyTypevars(_) @@ -10733,6 +12152,8 @@ pub enum DynamicType<'db> { /// calls. For now, we replace unspecialized type variables with this marker type, and ignore them /// during generic inference. UnspecializedTypeVar, + /// A provisional marker inferred for a lambda parameter before access to its declared type. + UnknownLambdaParameter, /// A special variant that represents that `Unknown` was inferred due to an invalid use of /// `Concatenate` in a type expression. /// @@ -10762,6 +12183,13 @@ impl DynamicType<'_> { fn is_todo(&self) -> bool { matches!(self, Self::Todo(_)) } + + const fn is_provisional_marker(self) -> bool { + matches!( + self, + Self::UnspecializedTypeVar | Self::UnknownLambdaParameter + ) + } } impl std::fmt::Display for DynamicType<'_> { @@ -10770,6 +12198,7 @@ impl std::fmt::Display for DynamicType<'_> { DynamicType::Any => f.write_str("Any"), DynamicType::Unknown | DynamicType::UnknownGeneric(_) + | DynamicType::UnknownLambdaParameter | DynamicType::InvalidConcatenateUnknown | DynamicType::AmbiguousOverload => f.write_str("Unknown"), DynamicType::UnspecializedTypeVar => f.write_str("UnspecializedTypeVar"), @@ -11502,48 +12931,76 @@ impl<'db> ModuleLiteralType<'db> { Some(Type::module_literal(db, importing_file, submodule)) } + /// Resolves a missing member through the module's `__getattr__` function. + /// + /// Invalid calls retain their declared return type for recovery while deferring the diagnostic + /// until the caller determines whether the fallback actually takes precedence. + /// + /// ```python + /// # example.py + /// def __getattr__() -> str: ... + /// + /// # Another module: + /// import example + /// example.missing # Invalid call; the recovery type is str. + /// ``` fn try_module_getattr( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, name: &str, - ) -> PlaceAndQualifiers<'db> { - // For module literals, we want to try calling the module's own `__getattr__` function - // if it exists. First, we need to look up the `__getattr__` function in the module's scope. - let module = self.module(db); - if let Some(file) = module + ) -> MemberLookupResult<'db> { + if let Some(file) = self + .module(db) .file(db) .map(|file| ProgramFile::new(db, file, env.program(db))) + && let Place::Defined(place) = + imported_symbol(db, env, Some(file), "__getattr__", None).place { - let getattr_symbol = imported_symbol(db, env, Some(file), "__getattr__", None); - // If we found a __getattr__ function, try to call it with the name argument - if let Place::Defined(place) = getattr_symbol.place - && let Ok(outcome) = place.ty.try_call( - db, - env, - &CallArguments::positional([Type::string_literal(db, name)]), - ) - { - return PlaceAndQualifiers { + let name_type = Type::string_literal(db, name); + let (return_type, error) = + match place + .ty + .try_call(db, env, &CallArguments::positional([name_type])) + { + Ok(outcome) => (outcome.return_type(db, env), None), + Err(CallError(_, bindings)) => ( + bindings.return_type(db, env), + Some(MemberLookupErrorKind::ModuleGetAttr { + callable: place.ty, + name: name_type, + }), + ), + }; + + return member_lookup_result( + db, + PlaceAndQualifiers { place: Place::Defined(DefinedPlace { - ty: outcome.return_type(db, env), + ty: return_type, provenance: Provenance::Unknown, ..place }), qualifiers: TypeQualifiers::FROM_MODULE_GETATTR, - }; - } + }, + error, + None, + ); } Place::Undefined.into() } + /// Looks up a module member while preserving failed module-level `__getattr__` calls. + /// + /// The failed call and its recovery type are retained so direct attribute access and `from` + /// imports can report the error after resolving lookup precedence. fn static_member( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, name: &str, - ) -> PlaceAndQualifiers<'db> { + ) -> MemberLookupResult<'db> { let module = self.module(db); // `__dict__` is a very special member that is never overridden by module globals; // we should always look it up directly as an attribute on `types.ModuleType`, @@ -11551,7 +13008,8 @@ impl<'db> ModuleLiteralType<'db> { if name == "__dict__" { return KnownClass::ModuleType .to_instance(db, env) - .member(db, env, "__dict__"); + .member(db, env, "__dict__") + .into(); } // If the file that originally imported the module has also imported a submodule @@ -11595,11 +13053,12 @@ impl<'db> ModuleLiteralType<'db> { ..defined }), qualifiers: place_and_qualifiers.qualifiers, - }; + } + .into(); } } - place_and_qualifiers + place_and_qualifiers.into() } } @@ -11607,7 +13066,9 @@ impl<'db> ModuleLiteralType<'db> { #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct MetaclassCandidate<'db> { metaclass: ClassType<'db>, - explicit_metaclass_of: StaticClassLiteral<'db>, + /// The base that supplied this candidate, including the `Protocol` pseudo-base, + /// or `None` for the class's own metaclass. + base: Option>, } /// Information about a `@dataclass_transform`-decorated metaclass. @@ -11688,7 +13149,7 @@ impl<'db> VarianceInferable<'db> for TypeIsType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.type_argument(db) .with_polarity(TypeVarVariance::Invariant) .variance_of(db, env, typevar) @@ -11760,7 +13221,7 @@ impl<'db> VarianceInferable<'db> for TypeGuardType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.return_type(db).variance_of(db, env, typevar) } } @@ -11827,18 +13288,21 @@ impl<'db> TypeGuardLike<'db> for TypeGuardType<'db> { /// Walk the MRO of this class and return the last class just before the specified known base. /// This can be used to determine upper bounds for `Self` type variables on methods that are /// being added to the given class. +/// +/// Preserve the class's specialization so that a method on `Child[int]` has a bound such as +/// `Base[int]`, rather than retaining the type variable in `Base[T@Child]`. pub(super) fn determine_upper_bound<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, - class_literal: ClassLiteral<'db>, + class: ClassType<'db>, is_known_base: impl Fn(ClassBase<'db>) -> bool, ) -> Type<'db> { - let upper_bound = class_literal + let upper_bound = class .iter_mro(db) .take_while(|base| !is_known_base(*base)) .filter_map(ClassBase::into_class) .last() - .unwrap_or_else(|| class_literal.unknown_specialization(db)); + .unwrap_or(class); Type::instance(db, env, upper_bound) } diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index 2df682715f..4ad942b4f9 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -14,7 +14,10 @@ use ty_python_core::use_def_map; use super::call::CallArguments; use super::callable::CallableTypeKind; use super::safe_variance::private_member_write_type; -use super::{KnownClass, KnownInstanceType, MemberLookupPolicy, Type, TypeQualifiers}; +use super::{ + IntersectionType, KnownClass, KnownInstanceType, MemberLookupPolicy, Parameter, Signature, + Type, TypeQualifiers, TypeVarBoundOrConstraints, UnionType, +}; use crate::ProgramEnvironment; use crate::place::{ DefinedPlace, Definedness, Place, PlaceAndQualifiers, builtins_symbol, place_from_bindings, @@ -299,6 +302,7 @@ pub(super) fn attribute_write_requirement<'db>( | Type::SpecialForm(..) | Type::KnownInstance(..) | Type::PropertyInstance(..) + | Type::SlotDescriptor(..) | Type::FunctionLiteral(..) | Type::Callable(..) | Type::BoundMethod(_) @@ -339,7 +343,9 @@ pub(super) fn attribute_write_requirement<'db>( { builtins_symbol(db, env, attribute) } else { - module.static_member(db, env, attribute) + module + .static_member(db, env, attribute) + .map_or_else(|_| Place::Undefined.into(), |member| member.member(db)) }; AttributeWriteRequirement::Module(match symbol.place { Place::Defined(DefinedPlace { ty, .. }) => Some(ty), @@ -392,19 +398,37 @@ fn instance_attribute_write_member_requirement<'db>( PlaceAndQualifiers { place: Place::Defined(DefinedPlace { ty, .. }), qualifiers, - } => InstanceAttributeWriteMember::Explicit { - member: explicit_attribute_write_requirement( + } => { + let member = explicit_attribute_write_requirement( db, env, object_ty, attribute, ty.bind_self_typevars(db, env, object_ty), qualifiers, - ), - fallback: receiver_fallback.map(|fallback| { - instance_fallback_write_requirement(db, env, object_ty, attribute, fallback) - }), - }, + ); + + // Built-in classes can expose writable C-level descriptors that their stubs model as + // plain annotations. Only a known slot layout rules out that additional storage. + if matches!( + member, + ExplicitAttributeWriteRequirement::AssignableTo { .. } + ) && ty.is_definitely_non_data_descriptor(db, env) + && object_ty + .nominal_class(db, env) + .and_then(|class| class.static_class_literal(db)) + .is_some_and(|(class, _)| class.lacks_instance_storage(db, attribute)) + { + return InstanceAttributeWriteMember::SetAttr; + } + + InstanceAttributeWriteMember::Explicit { + member, + fallback: receiver_fallback.map(|fallback| { + instance_fallback_write_requirement(db, env, object_ty, attribute, fallback) + }), + } + } PlaceAndQualifiers { place: Place::Undefined, .. @@ -557,6 +581,9 @@ fn possible_class_attribute_descriptor<'db>( /// Convert an explicitly resolved member into either a descriptor call or a direct type check. /// +/// A slot descriptor writes directly to instance storage, so the receiver's instance declaration +/// determines its write type even when a subclass overrides the slot owner's annotation. +/// /// Descriptor behavior is used only when `__set__` is found with /// [`MemberLookupPolicy::REQUIRE_CONCRETE`]. An `Any` or `Unknown` base therefore does not cause an /// ordinary attribute to be treated as a data descriptor. @@ -568,6 +595,24 @@ fn explicit_attribute_write_requirement<'db>( attr_ty: Type<'db>, qualifiers: TypeQualifiers, ) -> ExplicitAttributeWriteRequirement<'db> { + if matches!(attr_ty, Type::SlotDescriptor(_)) + && let PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { ty, .. }), + qualifiers: storage_qualifiers, + } = object_ty.instance_member(db, env, attribute) + { + return ExplicitAttributeWriteRequirement::AssignableTo { + ty: effective_write_type( + db, + env, + object_ty, + attribute, + ty.bind_self_typevars(db, env, object_ty), + ), + qualifiers: qualifiers.union(storage_qualifiers), + }; + } + // basedpython safe variance: a private member does not specialize, so a widened view of the // class knows nothing about it — not even whether it is a descriptor whose `__set__` would // govern the write. There is nothing such a view can supply @@ -816,6 +861,7 @@ pub(super) fn assignment_attribute_members<'db>( | Type::SpecialForm(..) | Type::KnownInstance(..) | Type::PropertyInstance(..) + | Type::SlotDescriptor(..) | Type::FunctionLiteral(..) | Type::Callable(..) | Type::BoundMethod(_) @@ -856,3 +902,180 @@ pub(super) fn assignment_attribute_members<'db>( receiver_fallback, }) } + +/// The values accepted by a descriptor setter, when representable as a single type. +#[derive(Copy, Clone)] +pub(super) enum DescriptorSetterDomain<'db> { + Missing, + Known(Type<'db>), + Deferred, +} + +/// Derive the values accepted by every possible descriptor setter when they fit in [`Type`]. +pub(super) fn descriptor_setter_domain<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> DescriptorSetterDomain<'db> { + match descriptor_ty { + Type::Union(union) => { + let mut write_types = Vec::with_capacity(union.elements(db).len()); + for descriptor_ty in union.elements(db) { + match single_descriptor_setter_domain(db, env, *descriptor_ty, receiver_ty) { + DescriptorSetterDomain::Missing => return DescriptorSetterDomain::Missing, + DescriptorSetterDomain::Known(write_ty) => write_types.push(write_ty), + DescriptorSetterDomain::Deferred => return DescriptorSetterDomain::Deferred, + } + } + IntersectionType::bounded_from_elements(db, env, write_types).map_or( + DescriptorSetterDomain::Deferred, + DescriptorSetterDomain::Known, + ) + } + _ => single_descriptor_setter_domain(db, env, descriptor_ty, receiver_ty), + } +} + +/// Derive the values accepted by one possible runtime descriptor. +fn single_descriptor_setter_domain<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> DescriptorSetterDomain<'db> { + let Place::Defined(DefinedPlace { + ty: setter_ty, + definedness: Definedness::AlwaysDefined, + .. + }) = descriptor_ty + .member_lookup_with_policy( + db, + env, + "__set__", + MemberLookupPolicy::REQUIRE_CONCRETE | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + else { + return DescriptorSetterDomain::Missing; + }; + + let Some(callables) = setter_ty.try_upcast_to_callable(db, env) else { + return DescriptorSetterDomain::Deferred; + }; + let mut callable_domains = Vec::with_capacity(callables.iter().len()); + for callable in &callables { + let mut write_types = Vec::new(); + for signature in callable.signatures(db) { + match descriptor_setter_signature_domain(db, env, signature, descriptor_ty, receiver_ty) + { + DescriptorSetterSignatureDomain::Inapplicable => {} + DescriptorSetterSignatureDomain::Known(write_ty) => write_types.push(write_ty), + DescriptorSetterSignatureDomain::Deferred => { + return DescriptorSetterDomain::Deferred; + } + } + } + callable_domains.push(UnionType::from_elements(db, env, write_types)); + } + IntersectionType::bounded_from_elements(db, env, callable_domains).map_or( + DescriptorSetterDomain::Deferred, + DescriptorSetterDomain::Known, + ) +} + +enum DescriptorSetterSignatureDomain<'db> { + Inapplicable, + Known(Type<'db>), + Deferred, +} + +/// Derive the values accepted by one `__set__` overload when they fit in [`Type`]. +fn descriptor_setter_signature_domain<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + signature: &Signature<'db>, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> DescriptorSetterSignatureDomain<'db> { + let parameters = signature.parameters(); + let missing_required_parameter = || { + if parameters.is_gradual() || parameters.as_slice().iter().any(Parameter::is_variadic) { + DescriptorSetterSignatureDomain::Deferred + } else { + DescriptorSetterSignatureDomain::Inapplicable + } + }; + let Some(trailing_parameters) = parameters.as_slice().get(2..) else { + return missing_required_parameter(); + }; + if !trailing_parameters.iter().all(|parameter| { + parameter.has_default() + || ((parameters.is_standard() || parameters.is_gradual()) + && (parameter.is_variadic() || parameter.is_keyword_variadic())) + }) { + return DescriptorSetterSignatureDomain::Inapplicable; + } + + let Some(receiver_parameter) = parameters.get_positional(0) else { + return missing_required_parameter(); + }; + let receiver_parameter = + receiver_parameter + .annotated_type() + .bind_self_typevars(db, env, descriptor_ty); + if contains_signature_typevar(db, env, signature, receiver_parameter) { + return DescriptorSetterSignatureDomain::Deferred; + } + if !receiver_ty.is_assignable_to(db, env, receiver_parameter) { + return DescriptorSetterSignatureDomain::Inapplicable; + } + + let Some(write_parameter) = parameters.get_positional(1) else { + return missing_required_parameter(); + }; + let write_ty = write_parameter + .annotated_type() + .bind_self_typevars(db, env, descriptor_ty); + if !contains_signature_typevar(db, env, signature, write_ty) { + return DescriptorSetterSignatureDomain::Known(write_ty); + } + + let Type::TypeVar(typevar) = write_ty else { + return DescriptorSetterSignatureDomain::Deferred; + }; + let Some(generic_context) = signature.generic_context else { + return DescriptorSetterSignatureDomain::Deferred; + }; + if !generic_context.contains(db, typevar.identity(db)) + || !typevar + .binding_context(db) + .definition() + .is_some_and(|definition| definition.kind(db).is_function_def()) + { + return DescriptorSetterSignatureDomain::Deferred; + } + + match typevar.typevar(db).bound_or_constraints(db, env) { + None => DescriptorSetterSignatureDomain::Known(Type::object()), + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + DescriptorSetterSignatureDomain::Known(bound.bind_self_typevars(db, env, descriptor_ty)) + } + Some(TypeVarBoundOrConstraints::Constraints(_)) => { + DescriptorSetterSignatureDomain::Deferred + } + } +} + +fn contains_signature_typevar<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + signature: &Signature<'db>, + ty: Type<'db>, +) -> bool { + signature.generic_context.is_some_and(|generic_context| { + super::visitor::any_over_type(db, env, ty, true, |ty| { + matches!(ty, Type::TypeVar(typevar) if generic_context.contains(db, typevar.identity(db))) + }) + }) +} diff --git a/crates/ty_python_semantic/src/types/bool.rs b/crates/ty_python_semantic/src/types/bool.rs index 648c18c894..84e29b561a 100644 --- a/crates/ty_python_semantic/src/types/bool.rs +++ b/crates/ty_python_semantic/src/types/bool.rs @@ -5,9 +5,9 @@ use ruff_text_size::{Ranged, TextRange}; use crate::types::{ CallArguments, CallDunderError, ClassType, CycleDetector, KnownClass, KnownInstanceType, - LiteralValueTypeKind, SubclassOfInner, Type, TypeContext, TypeVarBoundOrConstraints, UnionType, - call::CallErrorKind, constraints::ConstraintSetBuilder, context::InferContext, - diagnostic::UNSUPPORTED_BOOL_CONVERSION, typed_dict::TypedDictField, + LiteralValueTypeKind, PropertyInstanceClass, SubclassOfInner, Type, TypeContext, + TypeVarBoundOrConstraints, UnionType, call::CallErrorKind, constraints::ConstraintSetBuilder, + context::InferContext, diagnostic::UNSUPPORTED_BOOL_CONVERSION, typed_dict::TypedDictField, }; use ty_python_core::Truthiness; @@ -27,6 +27,23 @@ impl<'db> Type<'db> { .unwrap_or_else(|err| err.fallback_truthiness()) } + /// Like [`Self::bool`], but returns `None` for a type equivalent to [`Type::Never`]. + /// + /// An uninhabited type cannot produce either boolean outcome, unlike + /// [`Truthiness::Ambiguous`]. Condition analysis uses this distinction to retain the + /// short-circuit outcome of expressions like `flag and stop()`, where `stop` returns `Never`. + /// The equivalence check also handles aliases and type variables bounded by `Never`. + /// + /// This classifies a value type, not a compound condition's evaluation. It preserves + /// [`Self::bool`]'s error fallback and conservative handling of `__bool__` returning `Never`. + pub(crate) fn bool_if_inhabited( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option { + (!self.is_equivalent_to(db, env, Type::Never)).then(|| self.bool(db, env)) + } + /// Resolves the boolean value of a type. /// /// This is used to determine the value that would be returned @@ -226,6 +243,8 @@ impl<'db> Type<'db> { }; let truthiness = match self { + Type::Callable(callable) if callable.is_function_like(db) => Truthiness::AlwaysTrue, + Type::Dynamic(_) | Type::Divergent(_) | Type::Never @@ -268,6 +287,17 @@ impl<'db> Type<'db> { Truthiness::from(*is_non_empty) } + Type::PropertyInstance(property) + if let PropertyInstanceClass::Subclass(class) = property.instance_class(db) => + { + Type::instance(db, env, class).try_bool_impl( + db, + env, + allow_short_circuit, + visitor, + )? + } + Type::FunctionLiteral(_) | Type::BoundMethod(_) | Type::WrapperDescriptor(_) @@ -276,6 +306,7 @@ impl<'db> Type<'db> { | Type::DataclassTransformer(_) | Type::ModuleLiteral(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::BoundSuper(_) | Type::KnownInstance(_) | Type::SpecialForm(_) diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs index 723d8625e7..0df09508ab 100644 --- a/crates/ty_python_semantic/src/types/bound_super.rs +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -904,6 +904,9 @@ impl<'db> BoundSuperType<'db> { Type::PropertyInstance(property) => { return delegate_to(property.instance_fallback(db, env)); } + Type::SlotDescriptor(_) => { + return delegate_to(KnownClass::MemberDescriptorType.to_instance(db, env)); + } Type::BoundSuper(_) => { return delegate_to(KnownClass::Super.to_instance(db, env)); } @@ -991,6 +994,11 @@ impl<'db> BoundSuperType<'db> { db, member, descriptor_error.map(MemberLookupErrorKind::DescriptorGet), + instance + .and_then(|_| attribute.place.ignore_possibly_undefined()) + .and_then(|ty| ty.property_deprecations(db)) + // `super` delegates reads to the owner's descriptors, but not writes or deletions. + .map(|properties| properties.getters_only(db)), )) } diff --git a/crates/ty_python_semantic/src/types/call.rs b/crates/ty_python_semantic/src/types/call.rs index ef954c6497..7a6a9f12a4 100644 --- a/crates/ty_python_semantic/src/types/call.rs +++ b/crates/ty_python_semantic/src/types/call.rs @@ -3,6 +3,7 @@ use super::{ClassType, Signature, Type, TypeContext, UnionType}; use crate::Db; use crate::place::Provenance; use crate::types::call::bind::BindingError; +use crate::types::function::OverloadLiteral; use crate::types::{MemberLookupPolicy, PropertyInstanceType}; use crate::{Program, ProgramEnvironment}; use ruff_python_ast as ast; @@ -14,6 +15,14 @@ pub(super) use bind::{ Binding, Bindings, CallDiagnosticOverride, CallableBinding, MatchedArgument, }; +/// The return type and deprecations retained from binary operator resolution. +#[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct BinaryOperationResult<'db> { + pub(crate) return_type: Type<'db>, + /// Deprecated implementations or overloads selected for the operation. + pub(crate) deprecated_functions: Box<[OverloadLiteral<'db>]>, +} + /// Whether the right operand's reflected method has priority based on the possible runtime /// classes of both operands. /// @@ -149,16 +158,17 @@ impl<'db> Type<'db> { } } - /// Memoize the pure return-type part of binary dunder resolution so repeated identical - /// expressions don't re-run overload selection at every call site. - pub(crate) fn try_call_bin_op_return_type( + /// Memoize the return type and deprecations from binary dunder resolution, without retaining + /// the full call bindings or repeating overload selection at each expression. + /// Returns `None` if resolution fails; callers remain responsible for call-site diagnostics. + pub(crate) fn try_call_bin_op_result( db: &'db dyn Db, env: &ProgramEnvironment<'db>, left_ty: Type<'db>, op: ast::Operator, right_ty: Type<'db>, - ) -> Option> { - Self::try_call_bin_op_return_type_with_tcx( + ) -> Option<&'db BinaryOperationResult<'db>> { + Self::try_call_bin_op_result_with_tcx( db, env, left_ty, @@ -168,26 +178,26 @@ impl<'db> Type<'db> { ) } - /// Like [`Self::try_call_bin_op_return_type`], but forwards the outer type context (the - /// expected type of the whole expression) into the dunder call, so that a `Never`-defaulted - /// output parameter can widen to the assignment target — e.g. `x: list[int | None] = a + a`. - pub(crate) fn try_call_bin_op_return_type_with_tcx( + /// Like [`Self::try_call_bin_op_result`], but forwards the outer type context (the expected + /// type of the whole expression) into the dunder call, so that a `Never`-defaulted output + /// parameter can widen to the assignment target — e.g. `x: list[int | None] = a + a`. + pub(crate) fn try_call_bin_op_result_with_tcx( db: &'db dyn Db, env: &ProgramEnvironment<'db>, left_ty: Type<'db>, op: ast::Operator, right_ty: Type<'db>, tcx: TypeContext<'db>, - ) -> Option> { - #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] - fn try_call_bin_op_return_type_impl<'db>( + ) -> Option<&'db BinaryOperationResult<'db>> { + #[salsa::tracked(returns(ref), cycle_initial=|_, _, _, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] + fn try_call_bin_op_result_impl<'db>( db: &'db dyn Db, program: Program<'db>, left_ty: Type<'db>, op: ast::Operator, right_ty: Type<'db>, tcx: TypeContext<'db>, - ) -> Option> { + ) -> Option> { let env = &ProgramEnvironment::from_program(program); Type::try_call_bin_op_with_policy( db, @@ -199,10 +209,16 @@ impl<'db> Type<'db> { MemberLookupPolicy::default(), ) .ok() - .map(|bindings| bindings.return_type(db, env)) + .map(|bindings| BinaryOperationResult { + return_type: bindings.return_type(db, env), + deprecated_functions: bindings + .deprecated_functions(db) + .map(|(_, function)| function) + .collect(), + }) } - try_call_bin_op_return_type_impl(db, env.program(db), left_ty, op, right_ty, tcx) + try_call_bin_op_result_impl(db, env.program(db), left_ty, op, right_ty, tcx).as_ref() } pub(crate) fn try_call_bin_op( diff --git a/crates/ty_python_semantic/src/types/call/arguments.rs b/crates/ty_python_semantic/src/types/call/arguments.rs index fe3467d958..4e12da997c 100644 --- a/crates/ty_python_semantic/src/types/call/arguments.rs +++ b/crates/ty_python_semantic/src/types/call/arguments.rs @@ -81,11 +81,19 @@ impl<'db> CallArgumentTypes<'db> { } /// Returns the type of this argument when inferred against the provided declared type. + /// + /// If the type was not inferred against the declared type directly, this method will fall back to + /// [`Self::get_default`]. + pub(crate) fn try_get_for_declared_type(&self, tcx: Type<'db>) -> Option> { + self.types.get(&tcx).copied().or_else(|| self.get_default()) + } + + /// Returns the type of this argument when inferred against the provided declared type. + /// + /// If the type was not inferred against the declared type directly, this method will fall back to + /// [`Self::get_default`], or to `Unknown` if no fallback type exists. pub(crate) fn get_for_declared_type(&self, tcx: Type<'db>) -> Type<'db> { - self.types - .get(&tcx) - .copied() - .or_else(|| self.get_default()) + self.try_get_for_declared_type(tcx) .unwrap_or(Type::unknown()) } @@ -110,7 +118,7 @@ impl<'db> CallArgumentTypes<'db> { impl<'a, 'db> CallArguments<'a, 'db> { /// Create `CallArguments` from AST arguments. We will use the provided callback to obtain the /// type of each splatted argument, so that we can determine its length. All other arguments - /// will remain uninitialized as `Unknown`. + /// will remain uninitialized. pub(crate) fn from_arguments( arguments: &'a ast::Arguments, mut infer_argument_type: impl FnMut(&ast::ArgOrKeyword, &ast::Expr) -> Type<'db>, @@ -470,85 +478,35 @@ impl<'a, 'db> CallArguments<'a, 'db> { } } - struct DisplayCallArguments<'env, 'a, 'db> { - call_arguments: &'a CallArguments<'a, 'db>, - db: &'db dyn Db, - env: &'env ProgramEnvironment<'db>, - } - - impl std::fmt::Display for DisplayCallArguments<'_, '_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("(")?; - for (index, (argument, types)) in self.call_arguments.iter().enumerate() { - if index > 0 { - write!(f, ", ")?; + std::fmt::from_fn(move |f| { + f.write_str("(")?; + for (index, (argument, types)) in self.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + match argument { + Argument::Synthetic => { + write!(f, "self: {}", DisplayCallArgumentTypes { types, db, env })?; + } + Argument::Positional => { + write!(f, "{}", DisplayCallArgumentTypes { types, db, env })?; + } + Argument::Variadic => { + write!(f, "*{}", DisplayCallArgumentTypes { types, db, env })?; } - match argument { - Argument::Synthetic => { - write!( - f, - "self: {}", - DisplayCallArgumentTypes { - types, - db: self.db, - env: self.env, - } - )?; - } - Argument::Positional => { - write!( - f, - "{}", - DisplayCallArgumentTypes { - types, - db: self.db, - env: self.env, - } - )?; - } - Argument::Variadic => { - write!( - f, - "*{}", - DisplayCallArgumentTypes { - types, - db: self.db, - env: self.env, - } - )?; - } - Argument::Keyword(name) => write!( - f, - "{}={}", - name, - DisplayCallArgumentTypes { - types, - db: self.db, - env: self.env, - } - )?, - Argument::Keywords => { - write!( - f, - "**{}", - DisplayCallArgumentTypes { - types, - db: self.db, - env: self.env, - } - )?; - } + Argument::Keyword(name) => write!( + f, + "{}={}", + name, + DisplayCallArgumentTypes { types, db, env } + )?, + Argument::Keywords => { + write!(f, "**{}", DisplayCallArgumentTypes { types, db, env })?; } } - f.write_str(")") } - } - - DisplayCallArguments { - call_arguments: self, - db, - env, - } + f.write_str(")") + }) } } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index ad5eef816a..a17c9a9bed 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -9,14 +9,14 @@ //! `ty_python_semantic::types::call::bind`. mod constructor; -mod enum_property; +mod property; use std::borrow::Cow; use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::fmt; -use itertools::Itertools; +use itertools::{Either, Itertools}; use ruff_db::parsed::parsed_module; use ruff_python_ast::name::Name; use ruff_text_size::{Ranged, TextRange}; @@ -34,7 +34,8 @@ use crate::types::TypedDictType; use crate::types::call::arguments::{CallArgumentTypes, Expansion, is_expandable_type}; use crate::types::callable::CallableTypeKind; use crate::types::constraints::{ - ConstraintSet, ConstraintSetBuilder, PathBound, PathBounds, Solutions, + ConstraintSet, ConstraintSetBuilder, PathBound, PathBoundSolution, PathBounds, SolutionPaths, + Solutions, }; use crate::types::context::LintDiagnosticGuardBuilder; use crate::types::context_params::{ContextResolution, resolve_context_argument}; @@ -60,7 +61,7 @@ use crate::types::signatures::{ CallableSignature, Parameter, ParameterDisplayName, ParameterKind, Parameters, ParametersKind, PartialApplication, PartialSignatureApplication, }; -use crate::types::tuple::{TupleLength, TupleSpec, TupleType, VariableSegment}; +use crate::types::tuple::{TupleLength, TupleSpec, TupleSpecBuilder, TupleType, VariableSegment}; use crate::types::typed_dict::{ TypedDictFieldBuilder, TypedDictOpenness, TypedDictSchema, extract_unpacked_typed_dict_from_value_type, @@ -77,17 +78,19 @@ enum KeywordAggregateKind { } use crate::types::ProgramEnvironment; use crate::types::typevar::{BoundTypeVarIdentity, TypeVarKind, TypeVarNonceGenerator, TypeVarSet}; +use crate::types::variance::VarianceInferable; use crate::types::visitor::{ TypeCollector, TypeKind, TypeVisitor, any_over_type, walk_non_atomic_type, walk_type_with_recursion_guard, }; use crate::types::{ - BindingContext, BoundMethodType, BoundTypeVarInstance, CallableType, CallableTypes, - ClassLiteral, DATACLASS_FLAGS, DataclassFlags, DataclassParams, DynamicType, GenericAlias, - InternedConstraintSet, IntersectionType, KnownBoundMethodType, KnownClass, KnownInstanceType, - LiteralValueTypeKind, NominalInstanceType, PropertyInstanceType, SelfBinding, SpecialFormType, - TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, UnionAccumulator, - UnionBuilder, UnionType, UnsafeUnionType, WrapperDescriptorKind, enums, list_members, + ArgumentContextOrigin, BindingContext, BoundMethodType, BoundTypeVarInstance, CallableType, + CallableTypes, ClassLiteral, DATACLASS_FLAGS, DataclassFlags, DataclassParams, DynamicType, + GenericAlias, InternedConstraintSet, IntersectionType, KnownBoundMethodType, KnownClass, + KnownInstanceType, LiteralValueTypeKind, NominalInstanceType, PropertyInstanceType, + SelfBinding, SpecialFormType, TypeContext, TypeMapping, TypeVarBoundOrConstraints, + TypeVarVariance, UnionAccumulator, UnionBuilder, UnionType, UnsafeUnionType, + WrapperDescriptorKind, enums, is_property_method, list_members, }; use crate::{DisplaySettings, FxOrderSet}; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; @@ -169,7 +172,7 @@ fn generic_contexts_mentioned_in_type<'db>( } for parameter in signature.parameters() { self.visit_type(db, parameter.annotated_type()); - if let Some(default_ty) = parameter.default_type() { + if let Some(default_ty) = parameter.eager_default_type() { self.visit_type(db, default_ty); } } @@ -248,6 +251,35 @@ fn inferable_typevars_from_tuple<'db>( typevars.map(|typevars| TypeVarSet::from_typevars(db, typevars)) } +/// Converts a bound from an internal `ConstraintSet` constructor to its solver representation. +/// A bare `ParamSpec` requires parameter lists; ordinary typevars and `ParamSpec` components keep +/// their type bounds. `None` indicates an invalid supplied bound, not an omitted endpoint. +fn normalize_constraint_bound<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar: BoundTypeVarInstance<'db>, + bound: Type<'db>, +) -> Option> { + let bound = bound.project_type_form(db, env); + if !typevar.is_paramspec(db) || typevar.paramspec_attr(db).is_some() { + return Some(bound); + } + match bound.resolve_type_alias(db) { + Type::Callable(callable) + if let [signature] = callable.signatures(db).overloads.as_slice() + && signature.generic_context.is_none() + && let Some(paramspec) = signature.parameters().as_paramspec() => + { + Some(Type::TypeVar(paramspec)) + } + Type::Callable(callable) => Some(Type::Callable(callable.into_paramspec_value(db))), + Type::TypeVar(bound) if bound.is_paramspec(db) && bound.paramspec_attr(db).is_none() => { + Some(Type::TypeVar(bound)) + } + _ => None, + } +} + /// Priority levels for call errors in intersection types. /// Higher values indicate more specific errors that should take precedence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -462,7 +494,7 @@ struct BindingsElement<'db> { /// The callable type associated with this union element. For an intersection, retain the /// complete source type because its bindings can omit negative contributions or represent /// constructor methods instead of the called class objects. - pub(crate) callable_type: Type<'db>, + callable_type: Type<'db>, items: SmallVec<[CallableItem<'db>; 1]>, combination: ItemCombination, } @@ -503,7 +535,7 @@ impl<'db> BindingsElement<'db> { self.items.iter_mut() } - fn callables(&self) -> impl Iterator> { + fn callables(&self) -> impl Iterator> + Clone { self.items.iter().map(CallableItem::callable) } @@ -971,6 +1003,66 @@ impl<'db> Bindings<'db> { self.implicit_dunder_init_is_possibly_unbound } + /// Returns the deprecated functions invoked by each union alternative, including downstream + /// constructor methods. An intersection only reports deprecations if every member that could + /// implement the call is deprecated. + /// The same source can appear in multiple alternatives; callers deduplicate per expression. + /// + /// For call-site diagnostics, finalize argument inference first so skipped constructor + /// methods have been removed. For example, this call does not invoke the deprecated initializer: + /// + /// ```python + /// from typing_extensions import deprecated + /// + /// class C: + /// def __new__(cls) -> int: + /// return 0 + /// + /// @deprecated("old initializer") + /// def __init__(self) -> None: ... + /// + /// C() # No initializer deprecation: `__new__` returns an unrelated type. + /// ``` + pub(crate) fn deprecated_functions( + &self, + db: &'db dyn Db, + ) -> impl Iterator, OverloadLiteral<'db>)> { + /// Append deprecations without discarding earlier union alternatives when a + /// non-deprecated intersection member suppresses the current alternative's warnings. + fn collect<'a, 'db>( + db: &'db dyn Db, + bindings: &'a Bindings<'db>, + functions: &mut SmallVec<[(&'a CallableBinding<'db>, OverloadLiteral<'db>); 1]>, + ) { + for element in &bindings.elements { + let start = functions.len(); + for item in &element.items { + let item_start = functions.len(); + let callable = item.callable(); + functions.extend( + callable + .deprecated_functions(db) + .map(|function| (callable, function)), + ); + if let Some(constructor) = item.as_constructor() + && let Some(downstream) = constructor.downstream_constructor() + { + collect(db, downstream, functions); + } + if functions.len() == item_start { + // This intersection member provides a non-deprecated alternative. + functions.truncate(start); + break; + } + } + } + } + + let mut functions = SmallVec::new(); + collect(db, self, &mut functions); + functions.into_iter() + } + /// Returns an iterator over all `CallableBinding`s, flattening the two-level structure. /// /// Note: This loses the union/intersection distinction. The returned iterator yields @@ -1192,7 +1284,7 @@ impl<'db> Bindings<'db> { .bindings(db, env) .match_parameters(db, env, &bound_call_arguments); for binding in partial_bindings.iter_flat_mut() { - binding.clear_missing_argument_errors_for_partial_application(); + binding.prepare_for_partial_application(); } for constructor in partial_bindings.iter_constructor_items_mut() { if let Some(downstream) = constructor.downstream_constructor_mut() { @@ -1571,7 +1663,7 @@ impl<'db> Bindings<'db> { if let Some(maximum) = &mut demand.maximum { *maximum += 1; } - if parameter.default_type().is_none() { + if !parameter.has_default() { demand.required += 1; } } @@ -1787,7 +1879,7 @@ impl<'db> Bindings<'db> { match overload.parameter_types() { [_, Some(owner)] => { overload.set_return_type(Type::BoundMethod( - BoundMethodType::new(db, function, *owner), + BoundMethodType::new(db, function, *owner, *owner), )); } [Some(instance), None] => { @@ -1796,6 +1888,7 @@ impl<'db> Bindings<'db> { db, function, instance.to_meta_type(db, env), + instance.to_meta_type(db, env), ), )); } @@ -1808,7 +1901,7 @@ impl<'db> Bindings<'db> { overload.set_return_type(Type::FunctionLiteral(function)); } else { overload.set_return_type(Type::BoundMethod(BoundMethodType::new( - db, function, *first, + db, function, *first, *first, ))); } } @@ -1822,7 +1915,7 @@ impl<'db> Bindings<'db> { match overload.parameter_types() { [_, _, Some(owner)] => { overload.set_return_type(Type::BoundMethod( - BoundMethodType::new(db, *function, *owner), + BoundMethodType::new(db, *function, *owner, *owner), )); } @@ -1832,6 +1925,7 @@ impl<'db> Bindings<'db> { db, *function, instance.to_meta_type(db, env), + instance.to_meta_type(db, env), ), )); } @@ -1847,7 +1941,9 @@ impl<'db> Bindings<'db> { } [_, Some(instance), _] => { overload.set_return_type(Type::BoundMethod( - BoundMethodType::new(db, *function, *instance), + BoundMethodType::new( + db, *function, *instance, *instance, + ), )); } @@ -1925,6 +2021,13 @@ impl<'db> Bindings<'db> { overload.set_return_type(Type::Never); } } + [ + Some(property @ Type::NominalInstance(_)), + Some(instance), + .., + ] if instance.is_none(db) => { + overload.set_return_type(*property); + } _ => {} } } @@ -2168,7 +2271,8 @@ impl<'db> Bindings<'db> { Type::BoundMethod(bound_method) if let Type::PropertyInstance(property) = - bound_method.self_instance(db) => + bound_method.self_instance(db) + && is_property_method(db, env, bound_method.function(db)) => { match bound_method.function(db).name(db).as_str() { "setter" => { @@ -2222,7 +2326,7 @@ impl<'db> Bindings<'db> { // instead of specifying `init`, `default` etc. explicitly). let get_argument_type = |name, fallback_to_default| -> Option> { if let Ok(ty) = - overload.parameter_type_by_name(name, fallback_to_default) + overload.parameter_type_by_name(db, name, fallback_to_default) { return ty; } @@ -2282,7 +2386,11 @@ impl<'db> Bindings<'db> { .map(|init| !init.bool(db, env).is_always_false()) .unwrap_or(true); - let kw_only = if env.python_version(db) >= PythonVersion::PY310 { + // Only the standard-library field specifier requires Python 3.10 for + // `kw_only`; third-party field specifiers can support it earlier. + let kw_only = if env.python_version(db) >= PythonVersion::PY310 + || !function_type.is_known(db, KnownFunction::Field) + { match kw_only.and_then(Type::as_literal_value_kind) { // We are more conservative here when turning the type for `kw_only` // into a bool, because a field specifier in a stub might use @@ -2893,19 +3001,19 @@ impl<'db> Bindings<'db> { let mut flags = DataclassTransformerFlags::empty(); let eq_default = overload - .parameter_type_by_name("eq_default", false) + .parameter_type_by_name(db, "eq_default", false) .ok() .flatten(); let order_default = overload - .parameter_type_by_name("order_default", false) + .parameter_type_by_name(db, "order_default", false) .ok() .flatten(); let kw_only_default = overload - .parameter_type_by_name("kw_only_default", false) + .parameter_type_by_name(db, "kw_only_default", false) .ok() .flatten(); let frozen_default = overload - .parameter_type_by_name("frozen_default", false) + .parameter_type_by_name(db, "frozen_default", false) .ok() .flatten(); @@ -2925,12 +3033,12 @@ impl<'db> Bindings<'db> { // Accept both `field_specifiers` (current name) and // `field_descriptors` (legacy name). let field_specifiers_param = overload - .parameter_type_by_name("field_specifiers", false) + .parameter_type_by_name(db, "field_specifiers", false) .ok() .flatten() .or_else(|| { overload - .parameter_type_by_name("field_descriptors", false) + .parameter_type_by_name(db, "field_descriptors", false) .ok() .flatten() }); @@ -3090,13 +3198,16 @@ impl<'db> Bindings<'db> { let [Some(lower), Some(typevar)] = overload.parameter_types() else { return; }; - let lower = lower.project_type_form(db, env); let typevar = typevar.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { + let Some(lower) = normalize_constraint_bound(db, env, typevar, *lower) + else { + return ConstraintSet::from_bool(constraints, false); + }; ConstraintSet::constrain_typevar_lower_bound( db, env, @@ -3116,12 +3227,15 @@ impl<'db> Bindings<'db> { return; }; let typevar = typevar.project_type_form(db, env); - let upper = upper.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { + let Some(upper) = normalize_constraint_bound(db, env, typevar, *upper) + else { + return ConstraintSet::from_bool(constraints, false); + }; ConstraintSet::constrain_typevar_upper_bound( db, env, @@ -3141,12 +3255,15 @@ impl<'db> Bindings<'db> { return; }; let typevar = typevar.project_type_form(db, env); - let value = value.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { + let Some(value) = normalize_constraint_bound(db, env, typevar, *value) + else { + return ConstraintSet::from_bool(constraints, false); + }; ConstraintSet::constrain_typevar( db, env, @@ -3167,14 +3284,18 @@ impl<'db> Bindings<'db> { else { return; }; - let lower = lower.project_type_form(db, env); let typevar = typevar.project_type_form(db, env); - let upper = upper.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { + let (Some(lower), Some(upper)) = ( + normalize_constraint_bound(db, env, typevar, *lower), + normalize_constraint_bound(db, env, typevar, *upper), + ) else { + return ConstraintSet::from_bool(constraints, false); + }; ConstraintSet::constrain_typevar( db, env, @@ -3312,40 +3433,6 @@ impl<'db> Bindings<'db> { )); } - Type::KnownBoundMethod( - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(tracked), - ) => { - let extract_inferable = |instance: &NominalInstanceType<'db>| { - if instance.has_known_class(db, KnownClass::NoneType) { - // Caller explicitly passed None, so no typevars are inferable. - return Some(TypeVarSet::None); - } - inferable_typevars_from_tuple(db, env, instance) - }; - - let inferable = match overload.parameter_types() { - // Caller did not provide argument, so no typevars are inferable. - [None] => TypeVarSet::None, - [Some(ty)] => { - let Type::NominalInstance(instance) = ty.project_type_form(db, env) - else { - continue; - }; - match extract_inferable(&instance) { - Some(inferable) => inferable, - None => continue, - } - } - _ => continue, - }; - - let constraints = ConstraintSetBuilder::new(); - let set = constraints.load(db, env, tracked.constraints(db)); - let result = - set.satisfied_by_all_typevars(db, env, &constraints, inferable); - overload.set_return_type(Type::bool_literal(result)); - } - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetSolutionsFor( tracked, )) => { @@ -3366,11 +3453,11 @@ impl<'db> Bindings<'db> { let constraints = ConstraintSetBuilder::new(); let set = constraints.load(db, env, tracked.constraints(db)); - let result = match set.solutions(db, env, &constraints, inferable) { - Solutions::Constrained(paths) => Type::heterogeneous_tuple( + let result = match set.solutions(db, env, inferable) { + Ok(Solutions::Constrained(paths)) => Type::heterogeneous_tuple( db, env, - paths.into_iter().map(|path| { + paths.into_vec().into_iter().map(|path| { let path: Box<[_]> = path .into_iter() .filter(|binding| binding.bound_typevar == typevar) @@ -3380,8 +3467,9 @@ impl<'db> Bindings<'db> { )) }), ), - Solutions::Unsatisfiable => Type::none(db, env), - Solutions::Unconstrained => Type::empty_tuple(db, env), + Ok(Solutions::Unsatisfiable) => Type::none(db, env), + Ok(Solutions::Unconstrained) => Type::empty_tuple(db, env), + Err(_) => Type::unknown(), }; overload.set_return_type(result); } @@ -3403,11 +3491,11 @@ impl<'db> Bindings<'db> { let constraints = ConstraintSetBuilder::new(); let set = constraints.load(db, env, tracked.constraints(db)); - let result = match set.solutions(db, env, &constraints, inferable) { - Solutions::Constrained(paths) => Type::heterogeneous_tuple( + let result = match set.solutions(db, env, inferable) { + Ok(Solutions::Constrained(paths)) => Type::heterogeneous_tuple( db, env, - paths.into_iter().map(|path| { + paths.into_vec().into_iter().map(|path| { Type::KnownInstance(KnownInstanceType::ConstraintSetSolution( InternedConstraintSetSolution::new( db, @@ -3416,8 +3504,9 @@ impl<'db> Bindings<'db> { )) }), ), - Solutions::Unsatisfiable => Type::none(db, env), - Solutions::Unconstrained => Type::empty_tuple(db, env), + Ok(Solutions::Unsatisfiable) => Type::none(db, env), + Ok(Solutions::Unconstrained) => Type::empty_tuple(db, env), + Err(_) => Type::unknown(), }; overload.set_return_type(result); } @@ -3515,7 +3604,7 @@ impl<'db> Bindings<'db> { } } - self.evaluate_enum_property_calls(db, call_arguments); + self.evaluate_property_calls(db, env, call_arguments); } } @@ -3544,7 +3633,7 @@ impl<'db> From> for Bindings<'db> { signature_type, dunder_call_is_possibly_unbound: false, bound_type: None, - overload_call_return_type: None, + overload_call_result: None, matching_overload_before_type_checking: None, overloads: smallvec_inline![from], }; @@ -3589,21 +3678,24 @@ pub(crate) struct CallableBinding<'db> { /// The type of the bound `self` or `cls` parameter if this signature is for a bound method. pub(crate) bound_type: Option>, - /// The return type of this overloaded callable. + /// The result of evaluating this overloaded callable when a single overload does not + /// determine its return type. /// /// This is [`Some`] only in the following cases: /// 1. Argument type expansion was performed and one of the expansions evaluated successfully /// for all of the argument lists, or /// 2. Overload call evaluation was ambiguous, meaning that multiple overloads matched the - /// argument lists, but they all had different return types + /// argument lists, but their return types were not equivalent, or + /// 3. Argument type expansion reached its limit. /// /// For (1), the final return type is the union of all the return types of the matched - /// overloads for the expanded argument lists. + /// overloads for the expanded argument lists. We also retain the overloads selected for + /// deprecation reporting without discarding the other matches used for argument inference. /// - /// For (2), the final return type is [`Unknown`]. + /// For (2) and (3), the final return type is [`Unknown`]. /// /// [`Unknown`]: crate::types::DynamicType::Unknown - overload_call_return_type: Option>, + overload_call_result: Option>, /// The index of the overload that matched for this overloaded callable before type checking. /// @@ -3678,7 +3770,7 @@ impl<'db> CallableBinding<'db> { signature_type, dunder_call_is_possibly_unbound: false, bound_type: None, - overload_call_return_type: None, + overload_call_result: None, matching_overload_before_type_checking: None, overloads, } @@ -3690,7 +3782,7 @@ impl<'db> CallableBinding<'db> { signature_type, dunder_call_is_possibly_unbound: false, bound_type: None, - overload_call_return_type: None, + overload_call_result: None, matching_overload_before_type_checking: None, overloads: smallvec![], } @@ -3776,13 +3868,13 @@ impl<'db> CallableBinding<'db> { } } - /// Ignore missing-argument errors when constructing `functools.partial(...)`. + /// Prepare these overloads for constructing `functools.partial(...)`. /// /// Partial application intentionally leaves some parameters unbound, so we still want to - /// type-check all explicitly bound arguments against each overload. - fn clear_missing_argument_errors_for_partial_application(&mut self) { + /// type-check all explicitly bound arguments without treating unbound parameters as absent. + fn prepare_for_partial_application(&mut self) { for overload in &mut self.overloads { - overload.clear_missing_argument_errors_for_partial_application(); + overload.prepare_for_partial_application(); } } @@ -3932,11 +4024,12 @@ impl<'db> CallableBinding<'db> { }) } - /// Returns the source overload indexes that should be shown in diagnostics. + /// Returns the distinct source overload indexes that should be shown in diagnostics. /// - /// Bound method overloads preserve their source indexes after receiver filtering. Method - /// wrapper overloads are synthesized from `__get__`, so their indexes do not correspond to - /// the overload declarations of the underlying function. + /// Bound method overloads preserve their source indexes after receiver filtering. Receiver + /// specialization can expand one source overload into multiple signatures. Method wrapper + /// overloads are synthesized from `__get__`, so their indexes do not correspond to the overload + /// declarations of the underlying function. fn diagnostic_overload_indexes( &self, db: &'db dyn Db, @@ -3951,6 +4044,7 @@ impl<'db> CallableBinding<'db> { self.overloads .iter() .map(Binding::source_overload_index) + .unique() .collect() } @@ -4148,13 +4242,31 @@ impl<'db> CallableBinding<'db> { if is_expandable_type(db, env, argument_type) { continue; } - let mut is_argument_assignable_to_any_overload = false; - 'overload: for overload in &self.overloads { - for matched_parameter in &overload.argument_matches[argument_index].parameters { - let parameter_type = - overload.signature.parameters()[matched_parameter.index].annotated_type(); - let argument_type = argument_types.get_for_declared_type(parameter_type); - if argument_type + let is_argument_assignable_to_any_overload = self.overloads.iter().any(|overload| { + let matched_parameters = &overload.argument_matches[argument_index].parameters; + if matched_parameters.is_empty() { + return matches!(argument, Argument::Variadic) + && argument_type.iterate(db, env).len().minimum() == 0; + } + + // A starred argument contributes its individual element types, not the type of + // the iterable itself, and each element must match its corresponding parameter. + matched_parameters.iter().all(|matched_parameter| { + let parameter = &overload.signature.parameters()[matched_parameter.index]; + if parameter.has_starred_annotation() + && matched_parameter.expected_type.is_none() + { + return true; + } + + let parameter_type = matched_parameter + .expected_type + .unwrap_or_else(|| parameter.annotated_type()); + let argument_type = matched_parameter + .argument_type + .unwrap_or_else(|| argument_types.get_for_declared_type(parameter_type)); + + argument_type .when_assignable_to( db, env, @@ -4163,12 +4275,8 @@ impl<'db> CallableBinding<'db> { overload.inferable_typevars, ) .is_always_satisfied(db, env) - { - is_argument_assignable_to_any_overload = true; - break 'overload; - } - } - } + }) + }); if !is_argument_assignable_to_any_overload { tracing::debug!( "Argument at {argument_index} (`{}`) is not assignable to any of the \ @@ -4189,9 +4297,8 @@ impl<'db> CallableBinding<'db> { let expanded_argument_lists = match expansion { Expansion::LimitReached(index) => { snapshotter.restore(self, post_evaluation_snapshot); - self.overload_call_return_type = Some( - OverloadCallReturnType::ArgumentTypeExpansionLimitReached(index), - ); + self.overload_call_result = + Some(OverloadCallResult::ArgumentTypeExpansionLimitReached(index)); return; } Expansion::Expanded(argument_lists) => argument_lists, @@ -4204,6 +4311,7 @@ impl<'db> CallableBinding<'db> { // The return types of each of the expanded argument lists that evaluated successfully. let mut return_types = Vec::new(); + let mut selected_overloads = SmallVec::<[usize; 2]>::new(); for expanded_arguments in &expanded_argument_lists { // The spec mentions that each expanded argument list should be re-evaluated from @@ -4242,6 +4350,7 @@ impl<'db> CallableBinding<'db> { "after step 2", ); + let mut is_ambiguous = false; let return_type = match self.matching_overload_index() { MatchingOverloadIndex::None => None, MatchingOverloadIndex::Single(index) => { @@ -4265,7 +4374,7 @@ impl<'db> CallableBinding<'db> { } MatchingOverloadIndex::Single(_) => Some(self.return_type()), MatchingOverloadIndex::Multiple(indexes) => { - self.filter_overloads_using_any_or_unknown( + is_ambiguous = self.filter_overloads_using_any_or_unknown( db, env, constraints, @@ -4300,6 +4409,19 @@ impl<'db> CallableBinding<'db> { if let Some(return_type) = return_type { return_types.push(return_type); + // The shared call result can still contain ambiguity from an earlier + // expansion. Select overloads using this expansion's result instead. + let matching = self.matching_overloads(); + let selected = if is_ambiguous { + Either::Left(matching) + } else { + Either::Right(matching.take(1)) + }; + for (index, _) in selected { + if !selected_overloads.contains(&index) { + selected_overloads.push(index); + } + } } else { // No need to check the remaining argument lists if the current argument list // doesn't evaluate successfully. Move on to expanding the next argument type. @@ -4320,10 +4442,12 @@ impl<'db> CallableBinding<'db> { // If the number of return types is equal to the number of expanded argument lists, // they all evaluated successfully. So, we need to combine their return types by // union to determine the final return type. - self.overload_call_return_type = - Some(OverloadCallReturnType::ArgumentTypeExpansion( - UnionType::from_elements(db, env, return_types), - )); + self.overload_call_result = Some(OverloadCallResult::ArgumentTypeExpansion( + Box::new(ExpandedOverloadCall { + return_type: UnionType::from_elements(db, env, return_types), + selected_overloads, + }), + )); return; } @@ -4404,6 +4528,9 @@ impl<'db> CallableBinding<'db> { /// `matching_overload_indexes` and are filtered out by marking them as unmatched overloads /// using the [`mark_as_unmatched_overload`] method. /// + /// Returns whether the remaining overloads have non-equivalent return types, leaving the + /// call ambiguous. Otherwise, step 6 selects the first remaining overload. + /// /// [`Any`]: crate::types::DynamicType::Any /// [`Unknown`]: crate::types::DynamicType::Unknown /// [`mark_as_unmatched_overload`]: Binding::mark_as_unmatched_overload @@ -4415,7 +4542,7 @@ impl<'db> CallableBinding<'db> { constraints: &ConstraintSetBuilder<'db>, arguments: &CallArguments<'_, 'db>, matching_overload_indexes: &[usize], - ) { + ) -> bool { struct OverloadFilterSlot<'db> { parameter: Type<'db>, argument: Type<'db>, @@ -4439,7 +4566,7 @@ impl<'db> CallableBinding<'db> { .annotated_type(); let parameter_type = raw_parameter_type.apply_optional_specialization( db, - overload.specialization(db, env), + overload.merged_specialization(db, env), ); OverloadFilterSlot { parameter: parameter_type, @@ -4634,8 +4761,9 @@ impl<'db> CallableBinding<'db> { } } }; - self.overload_call_return_type = Some(OverloadCallReturnType::Ambiguous(return_type)); + self.overload_call_result = Some(OverloadCallResult::Ambiguous(return_type)); } + !are_return_types_equivalent_for_all_matching_overloads } fn as_result(&self) -> Result<(), CallErrorKind> { @@ -4740,6 +4868,55 @@ impl<'db> CallableBinding<'db> { .filter(|(_, overload)| !overload.has_errors_affecting_overload_resolution()) } + /// Returns the overloads selected for deprecation reporting without changing the matches + /// retained for argument inference. Equivalent return types select the first match; + /// ambiguous calls retain every match, and argument expansion combines its selected matches. + fn selected_overloads(&self) -> impl Iterator)> + Clone { + let matching = self.matching_overloads(); + let Some(result) = &self.overload_call_result else { + return Either::Left(matching.take(1)); + }; + Either::Right(matching.filter(move |(index, _)| match result { + OverloadCallResult::ArgumentTypeExpansion(expanded) => { + expanded.selected_overloads.contains(index) + } + OverloadCallResult::Ambiguous(_) => true, + OverloadCallResult::ArgumentTypeExpansionLimitReached(_) => false, + })) + } + + /// Returns the deprecated implementation, taking precedence over any deprecated overloads. + /// Otherwise, returns deprecated overloads selected by this call, using their original source + /// indexes to preserve their identities after receiver compatibility filtering. + fn deprecated_functions( + &self, + db: &'db dyn Db, + ) -> impl Iterator> + Clone { + if let Type::Callable(callable) = self.signature_type { + return Either::Left(callable.deprecated(db).into_iter()); + } + let function = match self.signature_type { + Type::FunctionLiteral(function) => Some(function), + Type::BoundMethod(bound) => Some(bound.function(db)), + _ => None, + }; + let (overloads, implementation) = function + .map(|function| function.overloads_and_implementation(db)) + .unwrap_or_default(); + if let Some(implementation) = + implementation.filter(|function| function.deprecated(db).is_some()) + { + return Either::Left(Some(implementation).into_iter()); + } + + Either::Right(self.selected_overloads().filter_map(move |(_, binding)| { + overloads + .get(binding.source_overload_index()) + .copied() + .filter(|overload| overload.deprecated(db).is_some()) + })) + } + /// Returns the overload which call arguments should be inferred against, if every overload is /// non-matching. pub(crate) fn best_failing_overload(&self) -> Option<&Binding<'db>> { @@ -4782,11 +4959,11 @@ impl<'db> CallableBinding<'db> { /// For an invalid call to an overloaded function, we return `Type::unknown`, since we cannot /// make any useful conclusions about which overload was intended to be called. fn return_type(&self) -> Type<'db> { - if let Some(overload_call_return_type) = self.overload_call_return_type { - return match overload_call_return_type { - OverloadCallReturnType::ArgumentTypeExpansion(return_type) => return_type, - OverloadCallReturnType::ArgumentTypeExpansionLimitReached(_) => Type::unknown(), - OverloadCallReturnType::Ambiguous(return_type) => return_type, + if let Some(overload_call_result) = &self.overload_call_result { + return match overload_call_result { + OverloadCallResult::ArgumentTypeExpansion(expanded) => expanded.return_type, + OverloadCallResult::ArgumentTypeExpansionLimitReached(_) => Type::unknown(), + OverloadCallResult::Ambiguous(return_type) => *return_type, }; } if let Some((_, first_overload)) = self.matching_overloads().next() { @@ -4799,7 +4976,7 @@ impl<'db> CallableBinding<'db> { } /// Whether this call returns `Never` only because it left a type variable unsolved. - pub(crate) fn returns_unsolved_typevar(&self, db: &'db dyn Db) -> bool { + fn returns_unsolved_typevar(&self, db: &'db dyn Db) -> bool { if let Some((_, first_overload)) = self.matching_overloads().next() { return first_overload.returns_unsolved_typevar(db); } @@ -4947,16 +5124,8 @@ impl<'db> CallableBinding<'db> { .unwrap_or_default() )); - if let Some(index) = - self.overload_call_return_type - .and_then( - |overload_call_return_type| match overload_call_return_type { - OverloadCallReturnType::ArgumentTypeExpansionLimitReached( - index, - ) => Some(index), - _ => None, - }, - ) + if let Some(OverloadCallResult::ArgumentTypeExpansionLimitReached(index)) = + &self.overload_call_result { diag.info(format_args!( "Limit of argument type expansion reached at argument {index}" @@ -5000,16 +5169,31 @@ impl<'db> CallableBinding<'db> { function.name(context.db()) )); - for overload in possible_overloads.iter().take(MAXIMUM_OVERLOADS) { - diag.info(format_args!( - " {}", - overload.signature(db).display(db, env) - )); + let possible_signatures = if possible_overloads.is_empty() { + // Receiver specialization can introduce overloads through a `ParamSpec` + // even when the method has no overload declarations of its own. + Either::Left(self.overloads.iter().map(|overload| { + if self.bound_type.is_some() { + overload.signature.bind_self(db, env, None) + } else { + overload.signature.clone() + } + })) + } else { + Either::Right( + possible_overloads + .iter() + .map(|overload| overload.signature(db)), + ) + }; + let possible_overload_count = possible_signatures.len(); + for signature in possible_signatures.take(MAXIMUM_OVERLOADS) { + diag.info(format_args!(" {}", signature.display(db, env))); } - if possible_overloads.len() > MAXIMUM_OVERLOADS { + if possible_overload_count > MAXIMUM_OVERLOADS { diag.info(format_args!( "... omitted {remaining} overloads", - remaining = possible_overloads.len() - MAXIMUM_OVERLOADS + remaining = possible_overload_count - MAXIMUM_OVERLOADS )); } @@ -5051,15 +5235,25 @@ impl<'db> IntoIterator for CallableBinding<'db> { } } -#[derive(Debug, Copy, Clone)] -enum OverloadCallReturnType<'db> { - ArgumentTypeExpansion(Type<'db>), +/// An overload call whose result requires more than the first matching signature. +#[derive(Debug, Clone)] +enum OverloadCallResult<'db> { + /// Successful argument expansion, boxed to keep other call bindings small. + ArgumentTypeExpansion(Box>), + /// Argument expansion stopped at this argument's expansion limit. ArgumentTypeExpansionLimitReached(usize), - /// The call matched several overloads with differing return types, because an argument was + /// Several overloads remain with non-equivalent return types, because an argument was /// gradual. The type is the unsafe union of the possible return types. Ambiguous(Type<'db>), } +/// The combined return type and selected overloads from successful argument expansion. +#[derive(Debug, Clone)] +struct ExpandedOverloadCall<'db> { + return_type: Type<'db>, + selected_overloads: SmallVec<[usize; 2]>, +} + #[derive(Debug)] pub(crate) enum MatchingOverloadIndex { /// No matching overloads found. @@ -5170,13 +5364,22 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { positional: bool, variable_argument_length: bool, ) { - if self.parameter_info[parameter_index].matched { - if !parameter.is_variadic() && !parameter.is_keyword_variadic() { - self.errors.push(BindingError::ParameterAlreadyAssigned { - argument_index: self.get_argument_index(argument_index), - parameter: ParameterContext::new(parameter, parameter_index, positional), - }); - } + if self.parameter_info[parameter_index].matched + && !parameter.is_variadic() + && !parameter.is_keyword_variadic() + // Repeated explicit keywords are already reported as syntax errors. + && !matches!( + argument, + Argument::Keyword(name) + if self.arguments.iter().take(argument_index).any(|(previous, _)| { + matches!(previous, Argument::Keyword(previous_name) if previous_name == name) + }) + ) + { + self.errors.push(BindingError::ParameterAlreadyAssigned { + argument_index: self.get_argument_index(argument_index), + parameter: ParameterContext::new(parameter, parameter_index, positional), + }); } if variable_argument_length && matches!( @@ -5458,7 +5661,7 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { let Some(argument_type) = argument_types.next() else { break; }; - if parameter.default_type().is_none() { + if !parameter.has_default() { return Err(()); } self.match_positional(argument_index, argument, Some(argument_type), is_variable)?; @@ -5794,7 +5997,7 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { } let param = &self.parameters[index]; if paramspec.is_none() && (param.is_variadic() || param.is_keyword_variadic()) - || param.default_type().is_some() + || param.has_default() { // variadic/keywords and defaulted arguments are not required // (unless the parameters represent a ParamSpec) @@ -5828,6 +6031,7 @@ struct ArgumentTypeChecker<'a, 'db> { call_expression_tcx: TypeContext<'db>, return_ty: Type<'db>, errors: &'a mut Vec>, + is_partial_application: bool, inferable_typevars: TypeVarSet<'db>, inference: Option>, @@ -5841,6 +6045,44 @@ struct ArgumentTypeChecker<'a, 'db> { constraint_set_errors: Vec, } +/// The formal and actual types associated with one matched argument-parameter pair. +/// +/// An unpacked argument can produce multiple relations, each with its own matched parameter and +/// actual element type. +#[derive(Clone, Copy, Debug)] +struct ArgumentRelation<'db> { + argument_index: usize, + + /// The source argument index, or `None` for a synthetic receiver. + adjusted_argument_index: Option, + + matched_parameter: MatchedParameter<'db>, + declared_type: Type<'db>, + argument_type: Type<'db>, + has_starred_annotation: bool, +} + +impl<'db> ArgumentRelation<'db> { + fn new( + argument_index: usize, + adjusted_argument_index: Option, + parameter: &Parameter<'db>, + matched_parameter: MatchedParameter<'db>, + argument_type: Type<'db>, + ) -> Self { + Self { + argument_index, + adjusted_argument_index, + matched_parameter, + declared_type: matched_parameter + .expected_type + .unwrap_or_else(|| parameter.annotated_type()), + argument_type, + has_starred_annotation: parameter.has_starred_annotation(), + } + } +} + /// Result of checking only the key type of a keyword-unpack argument. enum KeywordUnpackKeyTypeCheck<'db> { /// The argument type is handled by a more specific path, or does not expose mapping keys. @@ -5899,6 +6141,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { call_expression_tcx: TypeContext<'db>, return_ty: Type<'db>, errors: &'a mut Vec>, + is_partial_application: bool, ) -> Self { Self { db, @@ -5913,6 +6156,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { call_expression_tcx, return_ty, errors, + is_partial_application, inferable_typevars: TypeVarSet::None, inference: None, constraint_set_errors: vec![false; arguments.len()], @@ -5921,8 +6165,14 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { fn enumerate_argument_types( &self, - ) -> impl Iterator, Argument<'a>, &CallArgumentTypes<'db>)> + 'a - { + ) -> impl Iterator< + Item = ( + usize, + Option, + Argument<'a>, + &'a CallArgumentTypes<'db>, + ), + > + 'a { let mut iter = self.arguments.iter().enumerate(); let mut num_synthetic_args = 0; std::iter::from_fn(move || { @@ -5947,6 +6197,44 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { }) } + /// Yields the effective formal and actual types for each matched argument-parameter pair. + /// + /// Gradual variadic parameters do not contribute constraints. For unpacked tuple parameters, + /// the matched parameter can provide a more specific formal type for the corresponding element. + fn argument_relations(&self) -> impl Iterator> + 'a { + let parameters: &'a Parameters<'db> = self.signature.parameters(); + let argument_matches: &'a [MatchedArgument<'db>] = self.argument_matches; + + self.enumerate_argument_types().flat_map( + move |(argument_index, adjusted_argument_index, _, argument_types)| { + argument_matches[argument_index] + .iter() + .filter_map(move |matched_parameter| { + let parameter_index = matched_parameter.index; + if Self::is_gradual_variadic_parameter(parameters, parameter_index) { + return None; + } + + let parameter = ¶meters[parameter_index]; + let declared_type = matched_parameter + .expected_type + .unwrap_or_else(|| parameter.annotated_type()); + let argument_type = matched_parameter + .argument_type + .or_else(|| argument_types.try_get_for_declared_type(declared_type))?; + + Some(ArgumentRelation::new( + argument_index, + adjusted_argument_index, + parameter, + matched_parameter, + argument_type, + )) + }) + }, + ) + } + /// Returns argument-index mappings for arguments matched to the `ParamSpec` component. /// /// `prefix_len` is the number of parameters before the `ParamSpec` components in a callable like @@ -6026,7 +6314,10 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { if let Type::Union(union) = declared_type.resolve_type_alias(db) { union.elements(db).iter().find_map(|candidate| { let specialized_candidate = candidate - .apply_optional_specialization(db, self.specialization()); + .apply_optional_specialization( + db, + self.merged_specialization(), + ); argument_type .is_assignable_to(db, env, specialized_candidate) .then_some(*candidate) @@ -6098,7 +6389,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { }) } - fn specialization(&self) -> Option> { + fn merged_specialization(&self) -> Option> { let env = self.env; self.inference .map(|inference| call_specialization(self.db, env, self.signature, inference)) @@ -6113,7 +6404,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { /// has to stay vacuous. fn argument_specialization(&self) -> Option> { self.inference - .map(|inference| inference.specialization(self.db)) + .map(|inference| inference.merged_specialization(self.db)) } fn infer_specialization(&mut self, constraints: &ConstraintSetBuilder<'db>) { @@ -6126,8 +6417,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { let return_with_tcx = Some(self.return_ty).zip(self.call_expression_tcx.annotation()); self.inferable_typevars = generic_context.inferable_typevars(db); - let mut builder = - SpecializationBuilder::new(db, self.env, constraints, self.inferable_typevars); + let mut builder = SpecializationBuilder::new(db, self.env, constraints, generic_context); // Type variables for which we inferred a declared type based on a partially specialized // type from an outer generic context. For these type variables, we may infer types that @@ -6154,19 +6444,26 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // tension between type context preferences and argument constraints. If the combined set // is unsatisfiable, we will fall back to argument constraints alone (which the current // code does via `assignable_to_declared_type`). - let preferred_type_mappings = return_with_tcx + let (preferred_type_mappings, preferred_solutions_incomplete) = return_with_tcx .and_then(|(return_ty, tcx)| { if !tcx - .filter_union(db, |ty| ty.may_prefer_declared_type(db, self.env)) + .filter_union(db, self.env, |ty| ty.may_prefer_declared_type(db, self.env)) .may_prefer_declared_type(db, self.env) { return None; } - let return_ty = - return_ty.filter_disjoint_elements(db, self.env, tcx, self.inferable_typevars); - let tcx = - tcx.filter_disjoint_elements(db, self.env, return_ty, self.inferable_typevars); + let return_ty = return_ty + .discard_disjoint_union_elements(db, self.env, tcx, self.inferable_typevars) + .or_never(); + let tcx = tcx + .discard_disjoint_union_elements( + db, + self.env, + return_ty, + self.inferable_typevars, + ) + .or_never(); let path_bounds = return_ty.assignable_solutions_with_inferable( db, self.env, @@ -6174,7 +6471,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { self.inferable_typevars, ); - // Use `solutions_with` to determine per-typevar variance from the raw + // Use `solve_with` to determine per-typevar variance from the raw // lower/upper bounds on each BDD path. let mut variance_map: FxHashMap, TypeVarVariance> = FxHashMap::default(); @@ -6184,7 +6481,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { .entry(identity) .and_modify(|current| *current = current.join(variance)) .or_insert(variance); - PathBounds::default_solve(db, self.env, constraints, path_bound) + PathBounds::preliminary_solve(db, self.env, constraints, path_bound) }); let Solutions::Constrained(solutions) = solutions else { @@ -6194,7 +6491,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { let mut preferred: FxHashMap, UnionAccumulator<'db>> = FxHashMap::default(); - for solution in &solutions { + for solution in solutions.as_slice() { for binding in solution { let identity = binding.bound_typevar.identity(db); @@ -6210,21 +6507,20 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } // Filter out inferable typevars (cross-typevar references from - // SequentMap transitivity) and unspecialized typevars (from partially - // specialized contexts). + // SequentMap transitivity) and provisional markers. let inferred_ty = builder .remove_inferable_typevar_artifacts_from_solution( binding.bound_typevar, binding.solution, ) - .filter_union(db, |ty| { - if ty.has_unspecialized_type_var(db, self.env) { + .filter_union(db, self.env, |ty| { + if ty.has_provisional_marker(db, self.env) { partially_specialized_declared_type.insert(identity); return false; } true }); - if inferred_ty.has_unspecialized_type_var(db, self.env) { + if inferred_ty.has_provisional_marker(db, self.env) { continue; } @@ -6232,8 +6528,8 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // deeply contains non-inferable typevars. Such types (e.g., // `T@h | list[T@h]` from an outer generic scope) don't provide // useful concrete information and would cause over-expansion. - let concrete_content = - inferred_ty.filter_union(db, |ty| !ty.has_typevar(db, self.env)); + let concrete_content = inferred_ty + .filter_union(db, self.env, |ty| !ty.has_typevar(db, self.env)); if concrete_content.is_never() && inferred_ty.has_typevar(db, self.env) { continue; } @@ -6254,9 +6550,15 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // Add preferred types to the builder so they serve as the base mapping // when argument inference adds more types. - for solution in &solutions { + for solution in solutions.as_slice() { for binding in solution { let identity = binding.bound_typevar.identity(db); + // A `ParamSpec` keeps its first binding, so seeding it here would discard + // the inferred parameter list of the argument. + if binding.bound_typevar.is_paramspec(db) { + continue; + } + if let Some(&ty) = preferred.get(&identity) { builder.add_type_mapping( binding.bound_typevar, @@ -6267,7 +6569,10 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - Some(preferred) + Some(( + preferred, + matches!(solutions, SolutionPaths::BudgetExceeded(_)), + )) }) .unwrap_or_default(); @@ -6285,8 +6590,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // Note that this will still lead to an invalid specialization, but may // produce more precise diagnostics. if !assignable_to_declared_type { - builder = - SpecializationBuilder::new(db, self.env, constraints, self.inferable_typevars); + builder = SpecializationBuilder::new(db, self.env, constraints, generic_context); specialization_errors.clear(); self.constraint_set_errors.fill(false); @@ -6301,7 +6605,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { self.errors.extend(specialization_errors); // Attempt to promote any promotable types assigned to the specialization. - // The hook receives (typevar, bounds) and returns Some(ty) to override the default + // The hook receives (typevar, bounds) and returns Some(solution) to override the default // solution, or None to keep it. let maybe_promote = |typevar: BoundTypeVarInstance<'db>, bounds: &PathBound<'db>| { // Fluid specialization candidates retain literal types until their @@ -6309,7 +6613,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // ParamSpec signature) are unaffected: only solutions containing // literal values are kept unpromoted. if self.call_expression_tcx.preserve_literals - && let Some(lower) = bounds.lower + && let Some(lower) = bounds.evidence_lower() && crate::types::visitor::any_over_type(self.db, self.env, lower, false, |ty| { ty.as_literal_value().is_some() }) @@ -6358,60 +6662,328 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { return None; } - let lower = bounds.lower?; - let promoted = lower.promote(db, self.env); + // Promotion must preserve unsatisfiable outcomes and the completeness of fallbacks. + Some( + PathBounds::default_solve(db, self.env, constraints, bounds).map(|solution| { + let promoted = solution.promote(db, self.env); - // If the TypeVar has an upper bound, only use the promoted type if it - // still satisfies the bound. - if let Some(TypeVarBoundOrConstraints::UpperBound(bound)) = bound_or_constraints { - if !promoted.is_assignable_to(db, self.env, bound) { - return None; - } - } + // If the TypeVar has an upper bound, only use the promoted type if it + // still satisfies the bound. + if let Some(TypeVarBoundOrConstraints::UpperBound(bound)) = bound_or_constraints + && !promoted.is_assignable_to(db, self.env, bound) + { + return solution; + } - Some(promoted) + promoted + }), + ) }; let mut choose = |typevar: BoundTypeVarInstance<'db>, bounds: Option<&PathBound<'db>>| { - let bounds = bounds?; - if let Some(lower) = bounds.lower - && let Some(&preferred_ty) = preferred_type_mappings.get(&typevar.identity(db)) - && lower.is_assignable_to(db, self.env, preferred_ty) - { - return Some(preferred_ty); + let preferred_ty = preferred_type_mappings.get(&typevar.identity(db)).copied(); + + if let Some(bounds) = bounds { + let lower = bounds.evidence_lower()?; + if preferred_ty.is_none_or(|ty| !lower.is_assignable_to(db, self.env, ty)) { + return maybe_promote(typevar, bounds); + } } - maybe_promote(typevar, bounds) + // A contextual fallback remains incomplete when selected for the call. + preferred_ty.map(|ty| { + if preferred_solutions_incomplete { + PathBoundSolution::BudgetExceeded { fallback: Some(ty) } + } else { + PathBoundSolution::Solved(ty) + } + }) }; - let inference = match builder.build_inference_with(generic_context, &mut choose) { + + let inference = match builder.build_inference_with(&mut choose) { Ok(inference) => inference, - Err(()) => { - let parameters = self.signature.parameters(); - let mut argument_relations = Vec::new(); - for (argument_index, _, _, argument_types) in self.enumerate_argument_types() { - for matched_parameter in self.argument_matches[argument_index].iter() { - let parameter_index = matched_parameter.index; - if self.is_gradual_variadic_parameter(parameter_index) { - continue; + Err(()) => builder.build_diagnostic_inference_with( + self.argument_relations() + .map(|relation| (relation.declared_type, relation.argument_type)), + choose, + ), + }; + let specialization = call_specialization(self.db, env, self.signature, inference); + + self.return_ty = self.return_ty.apply_specialization(db, specialization); + self.inference = Some(inference); + } + + /// Infers a variadic type variable tuple from every argument matched to `*args`. + /// + /// Comparing the complete argument tuple with the declared tuple preserves fixed elements, + /// nested type variable tuples, and the unbounded shape of splatted arguments. + /// If requested, checks the preferred type context as well. A `false` result tells the caller + /// to retry inference using only argument constraints. + /// + /// ```py + /// def collect[*Ts](*args: *Ts) -> tuple[*Ts]: ... + /// def nested[*Ts, *Us](*args: *tuple[tuple[*Us], *Ts]) -> tuple[tuple[*Us], tuple[*Ts]]: ... + /// + /// collect(1, "value") + /// nested((1, "value"), True, b"last") + /// ``` + fn infer_typevartuple_argument_constraints<'c>( + &self, + builder: &mut SpecializationBuilder<'db, 'c>, + check_type_context: bool, + specialization_errors: &mut Vec>, + ) -> bool { + let db = self.db; + let Some((parameter_index, parameter)) = self.signature.parameters().variadic() else { + return true; + }; + if !parameter.has_starred_annotation() { + return true; + } + + // An untouched variadic parameter remains available to future calls of a partial. In + // contrast, an ordinary completed call with no variadic arguments infers an empty pack. + if self.is_partial_application + && !self.argument_matches.iter().any(|argument| { + argument + .iter() + .any(|matched| matched.index == parameter_index) + }) + { + return true; + } + + let (formal, typevartuple) = match parameter.annotated_type() { + Type::TypeVar(typevar) if typevar.is_typevartuple(db) => ( + Type::tuple(TupleType::unpacked_typevartuple(db, self.env, typevar)), + typevar, + ), + annotation => { + let Some(typevartuple) = + annotation.exact_tuple_instance_spec(db).and_then(|tuple| { + match tuple.as_ref() { + TupleSpec::Variable(variable) => variable.variable().typevartuple(), + TupleSpec::Fixed(_) => None, } + }) + else { + return true; + }; + (annotation, typevartuple) + } + }; - let formal = matched_parameter - .expected_type - .unwrap_or_else(|| parameters[parameter_index].annotated_type()); - let actual = matched_parameter - .argument_type - .unwrap_or_else(|| argument_types.get_for_declared_type(formal)); - argument_relations.push((formal, actual)); - } + if !self.can_infer_typevartuple_arguments(parameter_index, typevartuple) { + return true; + } + + let Some((actual, argument_indices)) = + self.collect_typevartuple_arguments(parameter_index, parameter, formal) + else { + return true; + }; + + if let Err(error) = builder.infer(formal, actual) { + // Fixed elements may already have produced the same specialization error. + if !specialization_errors.iter().any(|existing| { + matches!( + existing, + BindingError::SpecializationError { + error: existing, + .. + } if existing == &error + ) + }) { + specialization_errors.push(BindingError::SpecializationError { + error, + argument_index: argument_indices + .and_then(|(first, last)| (first == last).then_some(first)), + }); + } + return !check_type_context; + } + + if self.signature.generic_context.is_some() { + let specialization = builder.build_merged_with(|_, _| None); + let expected_ty = formal.apply_specialization(db, specialization); + + // The legacy solver keeps the first pack when another occurrence has a different length. + if let (Some(expected_tuple), Some(actual_tuple)) = ( + expected_ty.exact_tuple_instance_spec(db), + actual.exact_tuple_instance_spec(db), + ) && let (TupleLength::Fixed(expected), TupleLength::Fixed(provided)) = + (expected_tuple.len(), actual_tuple.len()) + && expected != provided + { + specialization_errors.push(BindingError::InvalidArgumentType { + parameter: ParameterContext::new(parameter, parameter_index, false), + argument_index: argument_indices.map(|(first, _)| first), + last_argument_index: argument_indices.map(|(_, last)| last), + expected_ty, + provided_ty: actual, + provenance: InvalidArgumentTypeProvenance::Argument, + parameter_source: None, + }); + } + + // A preferred return context can fix the pack before argument inference reaches it. + // Check the complete arguments against that specialization, including the empty tuple + // from `A()`, so an incompatible `A[*Us, int]` is retried without the context. + return !check_type_context + || self.is_partial_application + || actual + .apply_specialization(db, specialization) + .is_assignable_to(db, self.env, expected_ty); + } + + true + } + + /// Returns whether a type variable tuple can be inferred from its variadic arguments. + /// + /// The old solver stores one specialization per pack, so it can merge covariant occurrences + /// but cannot combine their lower bounds with the upper bounds from a callable parameter. + /// + /// ```py + /// from collections.abc import Callable + /// + /// def repeat[*Ts](expected: tuple[*Ts], *args: *Ts) -> tuple[*Ts]: ... + /// def invoke[*Ts](callback: Callable[[*Ts], None], *args: *Ts) -> None: ... + /// def accepts_str(value: str) -> None: ... + /// + /// repeat((1, "value"), 1, 2) # safe to infer `Ts = (int, str | int)` + /// invoke(accepts_str, 1) # do not widen the callback's `str` parameter + /// ``` + /// + /// TODO: Remove this guard when the new constraint solver can represent and solve both bounds. + fn can_infer_typevartuple_arguments( + &self, + parameter_index: usize, + typevartuple: BoundTypeVarInstance<'db>, + ) -> bool { + let db = self.db; + !self + .enumerate_argument_types() + .any(|(argument_index, _, argument, _)| { + !matches!(argument, Argument::Synthetic) + && self.argument_matches[argument_index].iter().any(|matched| { + matched.index != parameter_index + && !self.signature.parameters()[matched.index] + .annotated_type() + .variance_of(db, self.env, typevartuple.identity(db)) + .evaluate(db) + .is_covariant() + }) + }) + } + + /// Collects arguments matched to a starred parameter into their complete tuple shape. + /// + /// Direct arguments and splatted values are collected in call order. Values already consumed + /// by earlier parameters are removed from splats, and open tuples are resized to expose any + /// required fixed prefix or suffix. The returned indices cover all contributing source + /// arguments and are used for diagnostics. + /// + /// ```py + /// def tail[*Ts](head: int, *args: *Ts) -> tuple[*Ts]: ... + /// def prefixed[*Ts](*args: *tuple[int, *Ts]) -> tuple[*Ts]: ... + /// + /// def example(values: tuple[int, str, bytes], numbers: list[int]) -> None: + /// tail(*values) # collected `*args`: tuple[str, bytes] + /// prefixed(*numbers) # collected `*args`: tuple[int, *tuple[int, ...]] + /// ``` + fn collect_typevartuple_arguments( + &self, + parameter_index: usize, + parameter: &Parameter<'db>, + formal: Type<'db>, + ) -> Option<(Type<'db>, Option<(usize, usize)>)> { + let db = self.db; + let mut actual = TupleSpecBuilder::with_capacity(self.arguments.len()); + // Source indices of the first and last arguments matched to the variadic parameter. + let mut argument_indices: Option<(usize, usize)> = None; + for (argument_index, adjusted_argument_index, argument, argument_types) in + self.enumerate_argument_types() + { + let matches = &self.argument_matches[argument_index]; + if !matches + .iter() + .any(|matched| matched.index == parameter_index) + { + continue; + } + + if let Some(index) = adjusted_argument_index { + argument_indices = + Some((argument_indices.map_or(index, |(first, _)| first), index)); + } + + if matches!(argument, Argument::Variadic) { + let argument_type = argument_types.get_default()?; + let mut argument_tuple = argument_type.iterate(db, self.env); + let consumed_prefix = matches + .parameters + .iter() + .take_while(|matched| matched.index != parameter_index) + .count(); + if consumed_prefix != 0 { + let consumed_prefix = i32::try_from(consumed_prefix).ok()?; + let sliced = argument_tuple + .py_slice_type(db, self.env, Some(consumed_prefix), None, None) + .ok()?; + argument_tuple = sliced.exact_tuple_instance_spec(db)?; } + actual = actual.concat(db, self.env, &argument_tuple); + continue; + } - builder.build_diagnostic_inference_with(generic_context, argument_relations, choose) + for matched in matches + .iter() + .filter(|matched| matched.index == parameter_index) + { + let declared_type = matched + .expected_type + .unwrap_or_else(|| parameter.annotated_type()); + actual.push( + matched + .argument_type + .unwrap_or_else(|| argument_types.get_for_declared_type(declared_type)), + ); } - }; - let specialization = call_specialization(self.db, env, self.signature, inference); + } - self.return_ty = self.return_ty.apply_specialization(db, specialization); - self.inference = Some(inference); + let mut actual = actual.build(); + if let Some(formal_tuple) = formal.exact_tuple_instance_spec(db) + && let (TupleSpec::Variable(formal), TupleSpec::Variable(provided)) = + (formal_tuple.as_ref(), &actual) + && provided + .variable() + .homogeneous_type() + .is_some_and(|element| !element.resolve_type_alias(db).is_never()) + { + // Expose required boundaries without discarding fixed values the splat already has. + let target_length = TupleLength::Variable( + formal + .prefix_elements() + .len() + .max(provided.prefix_elements().len()), + formal + .suffix_elements() + .len() + .max(provided.suffix_elements().len()), + ); + if actual.len() != target_length + && let Ok(resized) = actual.resize(db, self.env, target_length) + { + actual = resized; + } + } + + Some(( + Type::tuple(TupleType::new(db, self.env, &actual)), + argument_indices, + )) } fn infer_argument_constraints<'c>( @@ -6437,15 +7009,12 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { .find_map(|(index, parameter)| Some((index, self.keyword_aggregate_kind(parameter)?))); let keyword_pack_parameter = keyword_aggregate.map(|(index, _)| index); let mut keyword_pack_fields: Vec> = Vec::new(); - - for (argument_index, adjusted_argument_index, argument, argument_types) in - self.enumerate_argument_types() - { - for matched_parameter in self.argument_matches[argument_index].iter() { - let parameter_index = matched_parameter.index; - let parameter = ¶meters[parameter_index]; - let parameter_type = parameter.annotated_type(); - if keyword_pack_parameter == Some(parameter_index) { + if let Some(pack_parameter_index) = keyword_pack_parameter { + for (argument_index, _, argument, argument_types) in self.enumerate_argument_types() { + for matched_parameter in self.argument_matches[argument_index].iter() { + if matched_parameter.index != pack_parameter_index { + continue; + } // a splatted `**other` contributes no statically-known field names, so the // pack cannot be solved from it; leave it to the ordinary arity checks if let Argument::Keyword(name) = argument { @@ -6455,47 +7024,42 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { .unwrap_or_else(Type::unknown); keyword_pack_fields.push( Parameter::keyword_only(Name::new(name)) - .with_annotated_type(field_type.promote(self.db, env)), + .with_annotated_type(field_type.promote(db, env)), ); } - continue; - } - // TODO: Infer a `TypeVarTuple` from all matched positional arguments as a single - // tuple. Fixed elements beside that pack can still infer ordinary type variables. - if parameter.has_starred_annotation() - && matched_parameter.expected_type.is_none() - && (matches!( - parameter_type, - Type::TypeVar(typevar) if typevar.is_typevartuple(db) - ) || matches!( - parameter_type.exact_tuple_instance_spec(db).as_deref(), - Some(TupleSpec::Variable(variable)) - if matches!( - variable.variable(), - VariableSegment::TypeVarTuple(_) - ) - )) - { - continue; - } - if self.is_gradual_variadic_parameter(parameter_index) { - continue; } + } + } - let declared_type = matched_parameter.expected_type.unwrap_or(parameter_type); - let argument_type = argument_types.get_for_declared_type(declared_type); - let specialization_result = builder.infer( - declared_type, - matched_parameter.argument_type.unwrap_or(argument_type), - ); + for relation in self.argument_relations() { + // the aggregate parameter is solved from all of its keyword arguments at once, below + if keyword_pack_parameter == Some(relation.matched_parameter.index) { + continue; + } + // Fixed elements can infer normally; the complete variadic pack is inferred below. + if relation.has_starred_annotation + && relation.matched_parameter.expected_type.is_none() + && (matches!( + relation.declared_type, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || matches!( + relation.declared_type.exact_tuple_instance_spec(db).as_deref(), + Some(TupleSpec::Variable(variable)) + if matches!( + variable.variable(), + VariableSegment::TypeVarTuple(_) + ) + )) + { + continue; + } - if let Err(error) = specialization_result { - self.constraint_set_errors[argument_index] = true; - specialization_errors.push(BindingError::SpecializationError { - error, - argument_index: adjusted_argument_index, - }); - } + if let Err(error) = builder.infer(relation.declared_type, relation.argument_type) { + self.constraint_set_errors[relation.argument_index] = true; + specialization_errors.push(BindingError::SpecializationError { + error, + argument_index: relation.adjusted_argument_index, + }); } } @@ -6531,7 +7095,11 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - preferred_type_mappings + self.infer_typevartuple_argument_constraints( + builder, + !preferred_type_mappings.is_empty(), + specialization_errors, + ) && preferred_type_mappings .iter() .all(|(&identity, &preferred_ty)| { partially_specialized_declared_type.contains(&identity) @@ -6542,17 +7110,22 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { fn check_argument_type( &mut self, constraints: &ConstraintSetBuilder<'db>, - argument_index: usize, - adjusted_argument_index: Option, argument: Argument<'a>, - mut argument_type: Type<'db>, - matched_parameter: MatchedParameter<'db>, + relation: ArgumentRelation<'db>, ) { + let ArgumentRelation { + argument_index, + adjusted_argument_index, + matched_parameter, + declared_type, + mut argument_type, + has_starred_annotation, + } = relation; let db = self.db; let parameter_index = matched_parameter.index; let parameters = self.signature.parameters(); let parameter = ¶meters[parameter_index]; - if self.is_gradual_variadic_parameter(parameter_index) { + if Self::is_gradual_variadic_parameter(parameters, parameter_index) { return; } // `**kwargs: Unpack[T]` with an unsolved `T` is solved *from* these very keyword @@ -6583,8 +7156,8 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // Mapper(callback, values) // ``` // - // The overloads provide alternative, correlated solutions for `T` and `R`. The current - // solver merges each TypeVar's solutions separately, losing that correlation. Applying + // The overloads provide alternative, correlated solutions for `T` and `R`. Argument + // checking merges each TypeVar's solutions separately, losing that correlation. Applying // the merged specialization to `cls` a second time then changes the receiver from // `type[Mapper[frozenset[Never]]]` to // `type[Mapper[frozenset[frozenset[Never]]]]`, incorrectly rejecting the valid call. @@ -6595,7 +7168,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // `U`, independently checking the class against `type[U]` again would reject the call // solely because `U` remains unsolved. // - // TODO: Remove this special case once solution extraction preserves correlations between + // TODO: Remove this special case once argument checking preserves correlations between // TypeVars across alternative inference paths and constructor calls are solved in a single // constraint set, so decorator-scoped receiver variables are not rechecked independently. let constructor_receiver = matches!(argument, Argument::Synthetic) @@ -6605,9 +7178,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { Type::SubclassOf(subclass_of) if subclass_of.into_type_var().is_some() ); - let mut expected_ty = matched_parameter - .expected_type - .unwrap_or_else(|| parameter.annotated_type()); + let mut expected_ty = declared_type; // basedpython: the *argument* reading of the specialization — a type variable the call // left unsolved stays gradual here rather than becoming `Never`, which in a contravariant // parameter position would say the parameter accepts nothing @@ -6651,7 +7222,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // An unresolved `*Ts` still has no per-element expected type. let type_error = if self.constraint_set_errors[argument_index] || constructor_receiver - || (parameter.has_starred_annotation() && matched_parameter.expected_type.is_none()) + || (has_starred_annotation && matched_parameter.expected_type.is_none()) || is_valid_isinstance_target() { false @@ -6696,6 +7267,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { self.errors.push(BindingError::InvalidArgumentType { parameter: ParameterContext::new(parameter, parameter_index, positional), argument_index: adjusted_argument_index, + last_argument_index: None, expected_ty, provided_ty: argument_type, provenance: matched_parameter.provenance, @@ -6758,8 +7330,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - fn is_gradual_variadic_parameter(&self, parameter_index: usize) -> bool { - let parameters = self.signature.parameters(); + fn is_gradual_variadic_parameter(parameters: &Parameters<'db>, parameter_index: usize) -> bool { let parameter = ¶meters[parameter_index]; matches!(parameters.kind(), ParametersKind::Gradual) @@ -6768,7 +7339,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } // TODO: Remove this workaround once call binding can infer a `TypeVarTuple` from `*args` and - // callable inference preserves correlations across overloads. + // callable argument checking preserves correlations across overloads. fn should_defer_typevartuple_callable_check( &self, declared_type: Type<'db>, @@ -6912,18 +7483,18 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { continue; } - let declared_type = - self.signature.parameters()[parameter_index].annotated_type(); - let argument_type = argument_types.get_for_declared_type(declared_type); - - self.check_argument_type( - constraints, + let parameter = &self.signature.parameters()[parameter_index]; + let argument_type = + argument_types.get_for_declared_type(parameter.annotated_type()); + let relation = ArgumentRelation::new( argument_index, adjusted_argument_index, - argument, - argument_type, + parameter, matched_parameter, + argument_type, ); + + self.check_argument_type(constraints, argument, relation); } } } @@ -6954,7 +7525,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { ) -> bool { let db = self.db; let Some(Type::Callable(callable)) = self - .specialization() + .merged_specialization() .and_then(|specialization| specialization.get(db, paramspec)) else { return false; @@ -7068,8 +7639,8 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } (Some(_), Some(_)) => { if !matches!( - callable_binding.overload_call_return_type, - Some(OverloadCallReturnType::ArgumentTypeExpansion(_)) + callable_binding.overload_call_result, + Some(OverloadCallResult::ArgumentTypeExpansion(_)) ) { extend_errors(&callable_binding.overloads()[0]); } @@ -7093,16 +7664,17 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { continue; } - self.check_argument_type( - constraints, + let relation = ArgumentRelation::new( argument_index, adjusted_argument_index, - argument, + &self.signature.parameters()[parameter_index], + matched_parameter, matched_parameter .argument_type .unwrap_or_else(Type::unknown), - matched_parameter, ); + + self.check_argument_type(constraints, argument, relation); } } @@ -7167,14 +7739,15 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { .unwrap_or(Type::unknown()) }; - self.check_argument_type( - constraints, + let relation = ArgumentRelation::new( argument_index, adjusted_argument_index, - Argument::Keywords, - value_type, + &self.signature.parameters()[parameter_index], matched_parameter, + value_type, ); + + self.check_argument_type(constraints, Argument::Keywords, relation); } } @@ -7256,6 +7829,9 @@ pub(crate) enum ArgumentTypeContext<'db> { /// The parameter type to use as context, possibly specialized from the call expression's /// declared type. parameter_type: Type<'db>, + /// basedpython: whether the raw parameter type spells out the class's type argument + /// rather than naming it with a type variable. See [`Type::prescribes_type_arguments`]. + prescribes_type_arguments: bool, }, ParamSpec { @@ -7271,10 +7847,15 @@ impl<'db> ArgumentTypeContext<'db> { /// /// `raw_parameter_type` is the lookup key used by later type checking. `parameter_type` is the /// possibly-specialized context used to infer the argument expression. - fn standard(raw_parameter_type: Type<'db>, parameter_type: Type<'db>) -> Self { + fn standard( + raw_parameter_type: Type<'db>, + parameter_type: Type<'db>, + prescribes_type_arguments: bool, + ) -> Self { Self::Standard { raw_parameter_type, parameter_type, + prescribes_type_arguments, } } @@ -7296,11 +7877,18 @@ impl<'db> ArgumentTypeContext<'db> { Self::Standard { raw_parameter_type, parameter_type, + prescribes_type_arguments, } => { let mut tcx = TypeContext::new(Some(parameter_type)); // a generic parameter's context is specialized from the arguments of // this very call, so it does not observe the argument from outside - tcx.inferred_from_argument = raw_parameter_type != parameter_type; + tcx.argument_origin = if prescribes_type_arguments { + ArgumentContextOrigin::Prescribed + } else if raw_parameter_type != parameter_type { + ArgumentContextOrigin::Solved + } else { + ArgumentContextOrigin::External + }; tcx } Self::ParamSpec { declared_type, .. } => TypeContext::new(Some(declared_type)), @@ -7460,10 +8048,10 @@ fn call_specialization<'db>( .precise_unsolved_typevars }); if !precise_unsolved_typevars { - return inference.specialization(db); + return inference.merged_specialization(db); } - inference.specialization_with(db, |typevar, inferred| { + inference.merged_specialization_with(db, |typevar, inferred| { if inferred.is_some() || typevar.default_type(db).is_some() // `ParamSpec` / `TypeVarTuple` specializations are callable- and tuple-shaped rather @@ -7521,6 +8109,9 @@ pub(crate) struct Binding<'db> { /// The type-variable inference result for this binding, if the callable is generic. inference: Option>, + /// Whether these arguments construct a partial instead of completing a call. + is_partial_application: bool, + /// Information about which parameter(s) each argument was matched with, in argument source /// order. argument_matches: Box<[MatchedArgument<'db>]>, @@ -7612,6 +8203,7 @@ impl<'db> Binding<'db> { constructor_context: None, inferable_typevars: TypeVarSet::None, inference: None, + is_partial_application: false, argument_matches: Box::from([]), variadic_argument_matched_to_variadic_parameter: false, parameter_tys: Box::from([]), @@ -7793,7 +8385,7 @@ impl<'db> Binding<'db> { [specialized_parameter.index] .annotated_type(); let parameter_type = specialized_overload - .specialization(db, env) + .merged_specialization(db, env) .map_or(parameter_type, |specialization| { parameter_type.apply_specialization(db, specialization) }); @@ -7803,6 +8395,69 @@ impl<'db> Binding<'db> { .then_some(parameter_type) } + /// Returns the expected tuple element for an argument matched to a `TypeVarTuple`. + /// + /// For `result: tuple[Payload, list[int]] = collect({"value": 1}, [])`, the arguments + /// receive `Payload` and `list[int]` as context without overriding their inferred types. + fn typevartuple_argument_context( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + binding: &CallableBinding<'db>, + arguments_types: &CallArguments<'_, 'db>, + argument_index: usize, + expected_return_ty: Type<'db>, + ) -> Option> { + let [matched_parameter] = self + .matched_argument_for_call_argument(binding, argument_index)? + .parameters + .as_slice() + else { + return None; + }; + let parameter = &self.signature.parameters()[matched_parameter.index]; + let Type::TypeVar(typevartuple) = parameter.annotated_type() else { + return None; + }; + if !parameter.is_variadic() + || !parameter.has_starred_annotation() + || !typevartuple.is_typevartuple(db) + { + return None; + } + + let return_tuple = self.signature.return_ty.exact_tuple_instance_spec(db)?; + let TupleSpec::Variable(return_tuple) = return_tuple.as_ref() else { + return None; + }; + if return_tuple.variable().typevartuple()?.identity(db) != typevartuple.identity(db) + || arguments_types + .iter() + .take(argument_index) + .any(|(argument, types)| { + matches!(argument, Argument::Variadic) + && types + .get_default() + .is_none_or(|ty| ty.iterate(db, env).len().maximum().is_none()) + }) + { + return None; + } + + let tuple_index = return_tuple.prefix_elements().len().checked_add( + (0..argument_index) + .filter_map(|index| self.matched_argument_for_call_argument(binding, index)) + .flat_map(MatchedArgument::iter) + .filter(|matched| matched.index == matched_parameter.index) + .count(), + )?; + expected_return_ty + .exact_tuple_instance_spec(db)? + .as_ref() + .py_index(db, env, i32::try_from(tuple_index).ok()?) + .ok() + } + /// Returns the type context to use for bidirectional inference of a source call argument, /// using the provided argument specialization. /// @@ -7838,7 +8493,7 @@ impl<'db> Binding<'db> { .unwrap_or(original_parameter_type); let paramspec_callable = |paramspec| { let Type::Callable(callable) = self - .specialization(db, env) + .merged_specialization(db, env) .and_then(|specialization| specialization.get(db, paramspec)) .or_else(|| { specialization.and_then(|specialization| specialization.get(db, paramspec)) @@ -7862,6 +8517,7 @@ impl<'db> Binding<'db> { return Some(ArgumentTypeContext::standard( original_parameter_type, bound, + false, )); } @@ -7899,11 +8555,24 @@ impl<'db> Binding<'db> { } parameter_type = parameter_type.apply_optional_specialization(db, specialization); + if let Some(expected_return_ty) = call_expression_tcx.annotation() + && let Some(expected) = self.typevartuple_argument_context( + db, + env, + binding, + arguments_types, + argument_index, + expected_return_ty, + ) + { + parameter_type = expected; + } } Some(ArgumentTypeContext::standard( original_parameter_type, parameter_type, + original_parameter_type.prescribes_type_arguments(db, env), )) } @@ -7936,8 +8605,11 @@ impl<'db> Binding<'db> { generic_context.inferable_typevars(db), ); - if let Solutions::Constrained(solutions) = path_bounds.solve(db, env, constraints) { - for solution in solutions { + let solutions = path_bounds.solve_with(|_variance, path_bound| { + PathBounds::preliminary_solve(db, env, constraints, path_bound) + }); + if let Solutions::Constrained(solutions) = solutions { + for solution in solutions.into_vec() { for binding in solution { let identity = binding.bound_typevar.identity(db); return_type_solutions @@ -7956,6 +8628,15 @@ impl<'db> Binding<'db> { } } + // The marker distinguishes unsolved type variables without defaults from gradual types + // inferred from arguments. Only the former are ignored during fixpoint iteration. + let argument_specialization = self.inference.map(|inference| { + inference.merged_specialization_with(db, |typevar, inferred| { + (inferred.is_none() && typevar.default_type(db).is_none()) + .then_some(Type::Dynamic(DynamicType::UnspecializedTypeVar)) + }) + }); + // TODO: Note that specializing parameter types for type context using this specialization is // not strictly correct, as it requires eagerly choosing a solution for a given type variable, // which may conflate upper and lower bounds when applied transitively to parameter types @@ -7969,10 +8650,9 @@ impl<'db> Binding<'db> { let identity = typevar.identity(db); let call_expression_constraints = return_type_solutions.get(&identity).copied(); - let argument_constraints = self - .specialization(db, env) + let argument_constraints = argument_specialization .and_then(|specialization| specialization.get(db, typevar)) - .filter(|ty| !ty.has_dynamic(db, env)) + .filter(|ty| !ty.has_provisional_marker(db, env)) // the *argument's* file: this widens a solution inferred from // one, and a caller whose numeric model is strict must not get // `int | float` back as the context its argument is read against @@ -8108,6 +8788,7 @@ impl<'db> Binding<'db> { call_expression_tcx, self.return_ty, &mut self.errors, + self.is_partial_application, ); // If this overload is generic, first see if we can infer a specialization of the function @@ -8172,7 +8853,7 @@ impl<'db> Binding<'db> { /// /// Such a `Never` says that the call's result cannot be described, not that the call does not /// return, so it must not make the rest of the scope unreachable. - pub(crate) fn returns_unsolved_typevar(&self, db: &'db dyn Db) -> bool { + fn returns_unsolved_typevar(&self, db: &'db dyn Db) -> bool { self.return_ty.is_never() && self .inference @@ -8231,7 +8912,8 @@ impl<'db> Binding<'db> { } /// `functools.partial(...)` is allowed to leave required parameters unbound. - fn clear_missing_argument_errors_for_partial_application(&mut self) { + fn prepare_for_partial_application(&mut self) { + self.is_partial_application = true; self.errors .retain(|error| !matches!(error, BindingError::MissingArguments { .. })); } @@ -8305,6 +8987,7 @@ impl<'db> Binding<'db> { /// Downstream constructor validation is deferred until after partial signatures are merged. fn clear_deferred_constructor_errors_for_partial_application(&mut self) { + self.is_partial_application = true; self.errors.retain(|error| { !matches!( error, @@ -8391,6 +9074,7 @@ impl<'db> Binding<'db> { /// Returns an error if the parameter name is not found. fn parameter_type_by_name( &self, + db: &'db dyn Db, parameter_name: &str, fallback_to_default: bool, ) -> Result>, UnknownParameterNameError> { @@ -8406,7 +9090,7 @@ impl<'db> Binding<'db> { if parameter_ty.is_some() { Ok(parameter_ty) } else if fallback_to_default { - Ok(parameters[index].default_type()) + Ok(parameters[index].default_type(db)) } else { Ok(None) } @@ -8503,7 +9187,7 @@ impl<'db> Binding<'db> { &self.argument_matches } - pub(crate) fn specialization( + pub(crate) fn merged_specialization( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -8539,7 +9223,7 @@ struct BindingSnapshot<'db> { #[derive(Clone, Debug)] struct CallableBindingSnapshot<'db> { - overload_return_type: Option>, + overload_result: Option>, /// Represents the snapshot of the matched overload bindings. /// @@ -8610,7 +9294,7 @@ impl CallableBindingSnapshotter { /// Panics if the indexes of the matched overloads are not valid for the given binding. fn take<'db>(&self, binding: &CallableBinding<'db>) -> CallableBindingSnapshot<'db> { CallableBindingSnapshot { - overload_return_type: binding.overload_call_return_type, + overload_result: binding.overload_call_result.clone(), matching_overloads: self .0 .iter() @@ -8626,7 +9310,7 @@ impl CallableBindingSnapshotter { snapshot: CallableBindingSnapshot<'db>, ) { debug_assert_eq!(self.0.len(), snapshot.matching_overloads.len()); - binding.overload_call_return_type = snapshot.overload_return_type; + binding.overload_call_result = snapshot.overload_result; for (index, snapshot) in snapshot.matching_overloads { binding.overloads[index].restore(snapshot); } @@ -8634,30 +9318,95 @@ impl CallableBindingSnapshotter { } /// Describes a callable for the purposes of diagnostics. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub(crate) struct CallableDescription<'a> { name: Cow<'a, str>, kind: Option<&'static str>, } +#[salsa::tracked] impl<'db> CallableDescription<'db> { + /// Describe a function definition without inferring its signature, qualifying methods by + /// their defining class. Cache the syntax lookup to avoid direct AST dependencies in callers. + #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] + pub(crate) fn from_overload( + db: &'db dyn Db, + function: OverloadLiteral<'db>, + ) -> CallableDescription<'static> { + let body_scope = function.body_scope(db); + let index = semantic_index(db, body_scope.program_file(db)); + let class = index.class_definition_of_method(body_scope.file_scope_id(db)); + let mut name = class.and_then(|class| class.name(db)).map_or_else( + || function.name(db).as_str().to_owned(), + |class_name| format!("{class_name}.{}", function.name(db)), + ); + name.shrink_to_fit(); + CallableDescription { + name: Cow::Owned(name), + kind: Some(if class.is_some() { + "method" + } else { + "function" + }), + } + } + + fn defining_class(db: &'db dyn Db, callable_type: Type<'db>) -> Option> { + let function = match callable_type { + Type::FunctionLiteral(function) => function, + Type::BoundMethod(method) => method.function(db), + Type::ClassLiteral(class) => return Some(class), + _ => return None, + }; + + let semantic_index = semantic_index(db, function.program_file(db)); + let enclosing_scope = semantic_index.scope(function.definition(db).file_scope(db)); + let class_node = enclosing_scope.node().as_class()?; + + original_class_type(db, semantic_index.expect_single_definition(class_node)) + } + pub(crate) fn new( db: &'db dyn Db, callable_type: Type<'db>, + ) -> Option> { + Self::new_with_settings(db, callable_type, None) + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) fn kind(&self) -> Option<&'static str> { + self.kind + } + + /// Rendering a qualified name needs an environment as well as the settings, so the two + /// travel together: without settings there is no qualified form to render. + fn new_with_settings( + db: &'db dyn Db, + callable_type: Type<'db>, + settings: Option<(&ProgramEnvironment<'db>, &DisplaySettings<'db>)>, ) -> Option> { fn qualified_function_name<'db>( db: &'db dyn Db, function: FunctionType<'db>, + settings: Option<(&ProgramEnvironment<'db>, &DisplaySettings<'db>)>, ) -> Cow<'db, str> { - let semantic_index = semantic_index(db, function.program_file(db)); - let enclosing_scope = semantic_index.scope(function.definition(db).file_scope(db)); - if let Some(class_node) = enclosing_scope.node().as_class() + if let Some((env, settings)) = settings && let Some(class) = - original_class_type(db, semantic_index.expect_single_definition(class_node)) + CallableDescription::defining_class(db, Type::FunctionLiteral(function)) { - Cow::Owned(format!("{}.{}", class.name(db), function.name(db))) + Cow::Owned(format!( + "{}.{}", + class.display_with(db, env, settings.clone()), + function.name(db) + )) } else { - Cow::Borrowed(function.name(db)) + Cow::Borrowed( + CallableDescription::from_overload(db, function.literal(db).last_definition) + .name(), + ) } } @@ -8668,11 +9417,19 @@ impl<'db> CallableDescription<'db> { } else { "function" }), - name: qualified_function_name(db, function), + name: qualified_function_name(db, function, settings), }), Type::ClassLiteral(class_type) => Some(CallableDescription { kind: Some("class"), - name: Cow::Borrowed(class_type.name(db)), + name: settings + .map(|(env, settings)| { + Cow::Owned( + class_type + .display_with(db, env, settings.clone()) + .to_string(), + ) + }) + .unwrap_or_else(|| Cow::Borrowed(class_type.name(db).as_str())), }), Type::SubclassOf(subclass) if let Some(typevar) = subclass.into_type_var() => { Some(CallableDescription { @@ -8689,7 +9446,7 @@ impl<'db> CallableDescription<'db> { }; CallableDescription { kind, - name: qualified_function_name(db, function), + name: qualified_function_name(db, function, settings), } }), Type::KnownBoundMethod(KnownBoundMethodType::FunctionTypeDunderGet(function)) => { @@ -8903,6 +9660,8 @@ pub(crate) enum BindingError<'db> { InvalidArgumentType { parameter: ParameterContext, argument_index: Option, + /// Last argument when this error describes all arguments matched to a variadic parameter. + last_argument_index: Option, expected_ty: Type<'db>, provided_ty: Type<'db>, provenance: InvalidArgumentTypeProvenance, @@ -9071,8 +9830,16 @@ impl BindingError<'_> { *argument_index = map(*argument_index); }; match self { - BindingError::InvalidArgumentType { argument_index, .. } - | BindingError::InvalidKeyType { argument_index, .. } + BindingError::InvalidArgumentType { + argument_index, + last_argument_index, + .. + } => { + remap(argument_index); + remap(last_argument_index); + } + + BindingError::InvalidKeyType { argument_index, .. } | BindingError::UnknownArgument { argument_index, .. } | BindingError::UnknownKeywordVariadicArgument { argument_index } | BindingError::PositionalOnlyParameterAsKwarg { argument_index, .. } @@ -9205,6 +9972,7 @@ impl<'db> BindingError<'db> { Self::InvalidArgumentType { parameter, argument_index, + last_argument_index, expected_ty, provided_ty, provenance, @@ -9215,7 +9983,10 @@ impl<'db> BindingError<'db> { // silenced diagnostics during overload evaluation, and rely on the assignability // diagnostic being emitted here. - let range = context.get_range(node, *argument_index); + let mut range = context.get_range(node, *argument_index); + if let Some(last) = last_argument_index { + range = range.cover(context.get_range(node, Some(*last))); + } // basedpython: a call argument is a conversion site — an in-scope // a conformance, a `__from__` / `__of__` on the parameter @@ -9248,10 +10019,17 @@ impl<'db> BindingError<'db> { return; }; - let display_settings = DisplaySettings::from_possibly_ambiguous_types( + let defining_class = + CallableDescription::defining_class(db, callable_ty).map(Type::ClassLiteral); + let types = [*provided_ty, *expected_ty] + .into_iter() + .chain(defining_class); + let display_settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, types); + let qualified_callable_description = CallableDescription::new_with_settings( db, - env, - [provided_ty, expected_ty], + callable_ty, + Some((env, &display_settings)), ); let provided_ty_display = provided_ty.display_with(db, env, display_settings.clone()); @@ -9259,7 +10037,9 @@ impl<'db> BindingError<'db> { let mut diag = builder.into_diagnostic(format_args!( "Argument{} is incorrect", - callable_description + qualified_callable_description + .as_ref() + .or(callable_description) .map(|description| format!(" to {description}")) .unwrap_or_default() )); @@ -10211,3 +10991,131 @@ fn overload_parameter_types<'db>( }) .collect() } + +// TODO: Replace these tests with mdtests once correlated alternatives affect call inference's +// return types or diagnostics, making retained correlations and completeness observable. +#[cfg(test)] +mod tests { + use super::*; + + use ruff_db::files::system_path_to_file; + use ruff_db::system::DbWithWritableSystem; + + use crate::db::tests::{TestDb, setup_db}; + use crate::place::global_symbol; + use crate::types::generics::TypeVarInferenceSolutions; + + fn call_inference<'db>( + db: &'db TestDb, + callable: Type<'db>, + arguments: impl IntoIterator>, + type_context: TypeContext<'db>, + ) -> anyhow::Result> { + let env = db.program_environment(); + let arguments = CallArguments::positional(arguments); + let constraints = ConstraintSetBuilder::new(); + let bindings = callable + .bindings(db, &env) + .match_parameters(db, &env, &arguments) + .check_types(db, &env, &constraints, &arguments, type_context, &[]) + .map_err(|error| anyhow::anyhow!("call binding failed: {error:?}"))?; + let (_, binding) = bindings + .iter_flat() + .flat_map(CallableBinding::matching_overloads) + .exactly_one() + .map_err(|_| anyhow::anyhow!("expected one matching call binding"))?; + binding + .inference + .ok_or_else(|| anyhow::anyhow!("expected generic call inference")) + } + + #[test] + fn overloaded_argument_retains_correlated_inference() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +from typing import Callable, overload + +# Restrict T to the overload inputs, excluding the vacuous T = Never solution. +def infer_pair[T: (int, str), U](converter: Callable[[T], U]) -> tuple[T, U]: + raise NotImplementedError + +@overload +def swap(value: int) -> str: ... +@overload +def swap(value: str) -> int: ... +def swap(value: int | str) -> int | str: + raise NotImplementedError +"#, + )?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let callable = global_symbol(db, file, "infer_pair").place.expect_type(); + let argument = global_symbol(db, file, "swap").place.expect_type(); + let inference = call_inference(db, callable, [argument], TypeContext::default())?; + let TypeVarInferenceSolutions::Alternatives(paths) = inference.solutions(db) else { + anyhow::bail!( + "expected correlated alternatives, got {:?}", + inference.solutions(db) + ); + }; + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + FxHashSet::from_iter([ + [Some(int), Some(str)].as_slice(), + [Some(str), Some(int)].as_slice(), + ]) + ); + Ok(()) + } + + #[test] + fn contextual_preference_preserves_incomplete_inference() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... + +def make[T, U](value: T) -> tuple[list[T], U, U]: + raise NotImplementedError + +expected: tuple[list[object], A | B, C | D | E] +"#, + )?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let callable = global_symbol(db, file, "make").place.expect_type(); + let expected = global_symbol(db, file, "expected").place.expect_type(); + let int = KnownClass::Int.to_instance(db, &env); + + // The contextual upper bound for U is (A | B) & (C | D | E): six non-disjoint + // terms exceed the four-term construction budget. T still has the preferred type object. + // Selecting that preference must preserve the contextual family's incompleteness, + // even though only one argument-inference alternative remains. + let inference = call_inference(db, callable, [int], expected.into())?; + let TypeVarInferenceSolutions::Incomplete(paths) = inference.solutions(db) else { + anyhow::bail!( + "expected incomplete inference, got {:?}", + inference.solutions(db) + ); + }; + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + [[Some(Type::object()), None].as_slice()] + ); + Ok(()) + } +} diff --git a/crates/ty_python_semantic/src/types/call/bind/constructor.rs b/crates/ty_python_semantic/src/types/call/bind/constructor.rs index c9fa416c96..a106db1860 100644 --- a/crates/ty_python_semantic/src/types/call/bind/constructor.rs +++ b/crates/ty_python_semantic/src/types/call/bind/constructor.rs @@ -384,7 +384,12 @@ impl<'db> ConstructorBinding<'db> { } let class = self.constructed_class_literal(db, env)?.as_static()?; let overload = self.first_matching_overload()?; - let keyword = |name| overload.parameter_type_by_name(name, false).ok().flatten(); + let keyword = |name: &str| { + overload + .parameter_type_by_name(db, name, false) + .ok() + .flatten() + }; django::field_constructor_instance_type( db, env, @@ -455,7 +460,7 @@ impl<'db> ConstructorBinding<'db> { let self_parameter_specialization = static_class_literal.and_then(|lit| { let self_param_ty = overload.signature.parameters().get(0)?.annotated_type(); let resolved_self_param_ty = overload - .specialization(db, env) + .merged_specialization(db, env) .map_or(self_param_ty, |specialization| { self_param_ty.apply_specialization(db, specialization) }); @@ -469,7 +474,7 @@ impl<'db> ConstructorBinding<'db> { .copied() .map(|mapped_ty| { let without_unknown = - mapped_ty.filter_union(db, |element| !element.is_unknown()); + mapped_ty.filter_union(db, env, |element| !element.is_unknown()); let mapped_ty = if without_unknown.is_never() { mapped_ty } else { @@ -494,7 +499,7 @@ impl<'db> ConstructorBinding<'db> { .or(return_specialization) .or_else(|| { overload - .specialization(db, env)? + .merged_specialization(db, env)? .restrict(db, class_context) }) }; @@ -643,9 +648,11 @@ impl<'db> ConstructorBinding<'db> { .unspecialized_return_type(db) .apply_optional_specialization( db, - overload.specialization(db, env).map(|specialization| { - self.unspecialize_class_type_variables(db, env, specialization) - }), + overload + .merged_specialization(db, env) + .map(|specialization| { + self.unspecialize_class_type_variables(db, env, specialization) + }), ); if self .constructed_class_literal(db, env) diff --git a/crates/ty_python_semantic/src/types/call/bind/enum_property.rs b/crates/ty_python_semantic/src/types/call/bind/enum_property.rs deleted file mode 100644 index b897cb7205..0000000000 --- a/crates/ty_python_semantic/src/types/call/bind/enum_property.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::Bindings; -use crate::db::Db; -use crate::types::call::CallArguments; -use crate::types::{KnownClass, PropertyInstanceType, Type}; -use itertools::Itertools; - -impl<'db> Bindings<'db> { - /// Replaces constructed `enum.property` instances with the property type derived from their - /// accessor arguments. - pub(super) fn evaluate_enum_property_calls( - &mut self, - db: &'db dyn Db, - call_arguments: &CallArguments<'_, 'db>, - ) { - let property_instance = - |getter: Option>, setter: Option>, deleter: Option>| { - Type::PropertyInstance(PropertyInstanceType::new_enum_property( - db, - getter.filter(|ty| !ty.is_none(db)), - setter.filter(|ty| !ty.is_none(db)), - deleter.filter(|ty| !ty.is_none(db)), - )) - }; - - // TODO: Preserve subclasses of `enum.property`. `PropertyInstanceType` currently records - // only a known property class, so this rewrite collapses subclass instances to - // `enum.property`. - for constructor in self.iter_constructor_items_mut() { - if !constructor - .constructed_instance_type() - .is_instance_of(db, KnownClass::EnumProperty) - { - continue; - } - - let property = { - let Ok((_, overload)) = constructor.callable().matching_overloads().exactly_one() - else { - continue; - }; - let accessor = |parameter_index| { - call_arguments - .iter() - .zip(overload.argument_matches()) - .find_map(|((_, argument_types), argument_matches)| { - let parameter = argument_matches - .parameters - .iter() - .find(|parameter| parameter.index == parameter_index)?; - parameter - .argument_type - .or_else(|| argument_types.get_default()) - }) - }; - property_instance(accessor(0), accessor(1), accessor(2)) - }; - constructor.set_constructed_instance_type(property); - } - } -} diff --git a/crates/ty_python_semantic/src/types/call/bind/property.rs b/crates/ty_python_semantic/src/types/call/bind/property.rs new file mode 100644 index 0000000000..c0ad4ac5c2 --- /dev/null +++ b/crates/ty_python_semantic/src/types/call/bind/property.rs @@ -0,0 +1,103 @@ +use super::{Bindings, ConstructorCallableKind}; +use crate::db::Db; +use crate::types::call::CallArguments; +use crate::types::{ + ClassBase, KnownClass, MemberLookupPolicy, ProgramEnvironment, PropertyInstanceType, Type, + is_property_method, +}; +use itertools::Itertools; + +impl<'db> Bindings<'db> { + /// Retains the accessors and nominal class when the property initializer is inherited. + pub(super) fn evaluate_property_calls( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + call_arguments: &CallArguments<'_, 'db>, + ) { + for constructor in self.iter_constructor_items_mut() { + if constructor.context().kind() != ConstructorCallableKind::Init { + continue; + } + let function = match constructor.callable().callable_type { + Type::BoundMethod(method) => method.function(db), + Type::FunctionLiteral(function) => function, + _ => continue, + }; + if function.name(db) != "__init__" || !is_property_method(db, env, function) { + continue; + } + let Some(instance) = constructor + .constructed_instance_type() + .as_nominal_instance() + else { + continue; + }; + let class = instance.class(db, env); + // The first class that defines the accessor storage must be a known property class. + let inherits_accessors = class + .iter_mro(db) + .filter_map(ClassBase::into_class) + .find_map(|base| { + if matches!( + base.known(db), + Some(KnownClass::Property | KnownClass::EnumProperty) + ) { + Some(true) + } else if ["fget", "fset", "fdel"] + .into_iter() + .any(|name| !base.own_class_member(db, env, None, name).is_undefined()) + { + Some(false) + } else { + None + } + }); + if inherits_accessors != Some(true) { + continue; + } + // Property-specific protocol and override checks use the stored accessors directly. + // A subclass that changes descriptor behavior must instead use ordinary descriptors. + if ["__get__", "__set__", "__delete__"] + .into_iter() + .any(|name| { + class + .class_member(db, env, name, MemberLookupPolicy::default()) + .place + .ignore_possibly_undefined() + .and_then(Type::as_function_literal) + .is_none_or(|function| !is_property_method(db, env, function)) + }) + { + continue; + } + let Ok((_, overload)) = constructor.callable().matching_overloads().exactly_one() + else { + continue; + }; + let accessor = |parameter_index| { + call_arguments + .iter() + .zip(overload.argument_matches()) + .find_map(|((_, argument_types), argument_matches)| { + let parameter = argument_matches + .parameters + .iter() + .find(|parameter| parameter.index == parameter_index)?; + parameter + .argument_type + .or_else(|| argument_types.get_default()) + }) + .filter(|ty| !ty.is_none(db)) + }; + let property = Type::PropertyInstance(PropertyInstanceType::new_with_class( + db, + class, + accessor(0), + accessor(1), + accessor(2), + )); + constructor.set_constructed_instance_type(property); + } + } +} diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index 7acc229d95..005f14e17d 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -12,10 +12,11 @@ use crate::{ LiteralValueTypeKind, MemberLookupPolicy, Parameter, Parameters, Signature, SubclassOfInner, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, UnionType, constraints::{ConstraintSet, IteratorConstraintsExtension}, + function::OverloadLiteral, known_instance::FunctoolsPartialInstance, relation::{TypeRelation, TypeRelationChecker}, signatures::{CallableSignature, PartialSignatureApplication}, - visitor, walk_signature, + visitor, walk_signature, walk_signature_without_return_type, }, }; use ty_python_core::definition::Definition; @@ -230,11 +231,9 @@ impl<'db> Type<'db> { .signatures(db) .into_iter() .map(|sig| sig.clone().with_return_type(Type::TypeVar(tvar))); - CallableType::new( + callable.with_signatures( db, CallableSignature::from_overloads(signatures), - callable.kind(db), - callable.provenance(db), ) })) } @@ -251,11 +250,9 @@ impl<'db> Type<'db> { callable.signatures(db).into_iter().map(|sig| { sig.clone().with_return_type(Type::TypeVar(tvar)) }); - callables.push(CallableType::new( + callables.push(callable.with_signatures( db, CallableSignature::from_overloads(signatures), - callable.kind(db), - callable.provenance(db), )); } } @@ -300,7 +297,6 @@ impl<'db> Type<'db> { db, CallableSignature::from_overloads(method.signatures(db, env)), CallableTypeKind::Regular, - CallableFunctionProvenance::None, ))), Type::WrapperDescriptor(wrapper_descriptor) => { @@ -308,7 +304,6 @@ impl<'db> Type<'db> { db, CallableSignature::from_overloads(wrapper_descriptor.signatures(db, env)), CallableTypeKind::Regular, - CallableFunctionProvenance::None, ))) } @@ -367,6 +362,7 @@ impl<'db> Type<'db> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::TypeVar(_) | Type::BoundSuper(_) => None, } @@ -385,61 +381,280 @@ impl<'db> CallableUpcastContext<'db> { } } +/// The behavior we assume for a [`CallableType`] beyond its call signatures. +/// +/// A callable's signature alone does not determine its runtime class, attributes, truthiness, +/// or whether it can act as a descriptor. The `CallableTypeKind` records which of these +/// properties we know or assume. Calls use the stored signature without reference to the +/// `CallableTypeKind`. Accessing `__call__`, however, returns the original callable type, +/// preserving both its signatures and its kind. +/// +/// For [`Self::FunctionLike`], [`Self::StaticMethodLike`], and [`Self::ClassMethodLike`], the +/// LSP server emits method semantic tokens on attribute access, allowing editors to highlight +/// these attributes as methods. We also preserve these kinds when a decorator returns a +/// `Callable`, assuming that the decorator preserves the decorated function's descriptor behavior. +/// +/// For example, `decorate` below returns a new function whose signature matches the original +/// method. We give the result the [`Self::FunctionLike`] kind, so `Example().method` still +/// binds `self`, even though the `Callable` return annotation does not guarantee that behavior: +/// +/// ```python +/// from collections.abc import Callable +/// +/// def decorate[**P, R](function: Callable[P, R]) -> Callable[P, R]: +/// def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: +/// return function(*args, **kwargs) +/// return wrapper +/// +/// class Example: +/// @decorate +/// def method(self, value: int) -> str: +/// return str(value) +/// +/// Example().method(1) # Returns "1"; no explicit self argument is needed. +/// ``` +/// +/// [`Self::ParamSpecValue`] is different to the other variants in that it does not describe +/// a runtime callable object. Instead, it uses the callable representation to store parameter +/// lists for type inference. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum CallableTypeKind { - /// Represents regular callable objects. + /// Arbitrary callable objects, as described by a `typing.Callable` annotation. + /// + /// These can be functions, bound methods, classes, or instances with a `__call__` method. + /// We know their call signatures but do not assume a particular runtime class: truthiness + /// is ambiguous, the metatype is `type`, and member lookup exposes only `object` attributes + /// and `__call__`. In particular, function attributes such as `__name__` are not guaranteed. + /// + /// We do not bind a receiver when these callables are accessed as attributes. In this + /// example, `Calculator().add` still requires both integer arguments: accessing `add` + /// through the instance does not supply that instance as its first argument. + /// + /// ```python + /// from collections.abc import Callable + /// + /// class Add: + /// def __call__(self, left: int, right: int) -> int: + /// return left + right + /// + /// class Calculator: + /// add: Callable[[int, int], int] = Add() + /// + /// Calculator.add(1, 2) # Returns 3. + /// Calculator().add(1, 2) # Returns 3; both arguments are still required. + /// ``` + /// + /// As a heuristic, class-member lookup converts dunder attributes of this kind to + /// [`Self::FunctionLike`] if their signatures can accept parameters. See + /// [`Self::DunderParamSpec`] for the exception for `Callable[P, R]` attributes. + /// + /// For example, `len(Sized())` implicitly calls `__len__` on the `Sized` instance. We + /// treat the `Callable`-typed `__len__` as a function, so accessing `Sized().__len__` + /// binds the `instance` parameter and gives it the signature `() -> int`: + /// + /// ```python + /// from collections.abc import Callable + /// + /// def length(instance: "Sized") -> int: + /// return 1 + /// + /// class Sized: + /// __len__: Callable[["Sized"], int] = length + /// + /// len(Sized()) # Returns 1. + /// ``` Regular, - /// Represents function-like objects, like the synthesized methods of dataclasses or - /// `NamedTuples`. These callables act like real functions when accessed as attributes on - /// instances, i.e. they bind `self`. + /// Callable objects modeled as instances of Python's `types.FunctionType`. + /// + /// A [`Type::FunctionLiteral`] identifies a particular function definition. This kind + /// represents functions with the given signatures without requiring that identity. It is + /// also used for lambdas and synthesized methods of dataclasses and named tuples. + /// + /// We model these callables as follows: + /// + /// - These callables are always truthy. + /// - Member lookup uses `types.FunctionType`, exposing attributes such as `__name__`, + /// `__qualname__`, `__module__`, `__code__`, `__defaults__`, and `__annotations__`. + /// `__call__` retains the callable's precise signatures. + /// - Their metatype is the `types.FunctionType` class literal. + /// - They are subtypes of `types.FunctionType`. They can also satisfy a + /// [`Self::Regular`] callable type with a compatible signature, but a regular callable + /// cannot satisfy a function-like callable type merely by having a compatible signature. + /// - They use `types.FunctionType` as their owner type when constructing `super()`. + /// - They act as [non-data descriptors][descriptor-protocol]: access through a class leaves + /// the signature unchanged, while access through an instance binds the first parameter + /// to that instance. The resulting callable remains function-like. + /// TODO: Model the result as a bound method. Its runtime type is `types.MethodType`, + /// so retaining the `types.FunctionType` behavior listed above is inaccurate. + /// - Like function literals, they defer binding `typing.Self` until the receiver is known + /// from the call's arguments, as illustrated below. + /// + /// In this example, `Base.identity` is function-like because of the decorator. Retrieving + /// it from `Base` does not fix `Self` to `Base`: the subsequent call passes a `Child` + /// instance, so both the receiver's type and the return type are `Child`. + /// + /// ```python + /// from collections.abc import Callable + /// from typing import Self, reveal_type + /// + /// def preserve[**P, R](function: Callable[P, R]) -> Callable[P, R]: + /// return function + /// + /// class Base: + /// @preserve + /// def identity(self) -> Self: + /// return self + /// + /// class Child(Base): + /// pass + /// + /// identity = Base.identity + /// reveal_type(identity(Child())) # Child + /// ``` + /// + /// When inferring a mutable collection's element type, we generalize function literals to + /// function-like callables. This allows the list below to contain other functions with + /// compatible signatures, rather than restricting it to the single function `first`: + /// + /// ```python + /// def first(value: int) -> str: + /// return str(value) + /// + /// def second(value: int) -> str: + /// return f"{value}!" + /// + /// callbacks = [first] # Inferred as list[(value: int) -> str]. + /// callbacks.append(second) + /// ``` + /// + /// [descriptor-protocol]: https://docs.python.org/3/howto/descriptor.html#descriptor-protocol FunctionLike, - /// Represents a `Callable[P, R]`-typed dunder attribute. + /// A `Callable[P, R]`-typed dunder attribute whose parameters come from a `ParamSpec`. + /// + /// This has the runtime assumptions of [`Self::Regular`]: truthiness is ambiguous, + /// member lookup exposes `object` attributes, and we do not treat these callables as + /// descriptors. The separate kind prevents the dunder descriptor heuristic from turning + /// it into [`Self::FunctionLike`] after `P` is specialized: the specialized parameters + /// already describe the callable's arguments. + /// Calling [`CallableType::bind_self`] removes this marker without removing a parameter. + /// + /// In the example below, specializing `P` to `[str]` gives `callback.__call__` the signature + /// `(str, /) -> int`. Binding a receiver would incorrectly remove its `str` parameter: + /// + /// ```python + /// from collections.abc import Callable + /// + /// class Callback[**P]: + /// __call__: Callable[P, int] + /// + /// class Length(Callback[[str]]): + /// def __call__(self, text: str) -> int: + /// return len(text) + /// + /// def invoke(callback: Callback[[str]]) -> int: + /// return callback("hello") + /// + /// invoke(Length()) # Returns 5. + /// ``` /// - /// This is distinct from [`Self::Regular`] so that the dunder descriptor heuristic does not - /// turn the callable into a function-like object after `P` is specialized. + /// This variant is used to represent the callable object itself; [`Self::ParamSpecValue`] + /// represents the parameter list substituted for `P`. DunderParamSpec, - /// A callable type that represents a staticmethod. These callables do not bind `self` - /// when accessed as attributes on instances - they return the underlying function as-is. + /// A callable with the descriptor behavior of `staticmethod`. + /// + /// These are [non-data descriptors][descriptor-protocol] that return the callable unchanged + /// on both class and instance access, without binding a receiver. + /// + /// TODO: Distinguish the `staticmethod` descriptor from the wrapped function returned by + /// descriptor access. Currently, this kind is retained after access, and member lookup + /// and type relations lack both nominal types: truthiness is ambiguous, the metatype is + /// `type`, and only `object` attributes plus `__call__` are exposed. + /// + /// In the example below, `Example.method` is an always-truthy `types.FunctionType` instance + /// at runtime. After the `Callable`-returning decorator is applied, ty incorrectly rejects + /// its `__name__` attribute and the assignment, and loses precision for `type` and `bool`: + /// + /// ```python + /// from collections.abc import Callable + /// from types import FunctionType + /// from typing import reveal_type + /// + /// def preserve[**P, R](function: Callable[P, R]) -> Callable[P, R]: + /// return function + /// + /// class Example: + /// @preserve + /// @staticmethod + /// def method(value: int) -> str: + /// return str(value) + /// + /// Example.method.__name__ # ty reports unresolved-attribute; Python returns "method". + /// function: FunctionType = Example.method # ty reports invalid-assignment. + /// reveal_type(type(Example.method)) # ty reveals type; Python returns FunctionType. + /// reveal_type(bool(Example.method)) # ty reveals bool; the result is always True. + /// ``` + /// + /// [descriptor-protocol]: https://docs.python.org/3/howto/descriptor.html#descriptor-protocol StaticMethodLike, - /// A callable type that we believe represents a classmethod (i.e. it will unconditionally bind - /// the first argument on `__get__`). + /// A callable with the descriptor behavior of `classmethod`. + /// + /// These are [non-data descriptors][descriptor-protocol] that bind the first parameter on + /// both class and instance access, using the owner when no instance is supplied. + /// + /// TODO: Distinguish the `classmethod` descriptor from the bound method returned by + /// descriptor access. Currently, this kind is retained after binding. Neither the + /// descriptor's `classmethod` type nor the bound method's `types.MethodType` is reflected + /// in member lookup or type relations: truthiness is ambiguous, the metatype is `type`, + /// and only `object` attributes plus `__call__` are exposed. + /// + /// In the example below, `Example.method` is an always-truthy `types.MethodType` instance + /// at runtime. After the `Callable`-returning decorator is applied, ty incorrectly rejects + /// its `__name__` attribute and the assignment, and loses precision for `type` and `bool`: + /// + /// ```python + /// from collections.abc import Callable + /// from types import MethodType + /// from typing import reveal_type + /// + /// def preserve[**P, R](function: Callable[P, R]) -> Callable[P, R]: + /// return function + /// + /// class Example: + /// @preserve + /// @classmethod + /// def method(cls, value: int) -> str: + /// return str(value) + /// + /// Example.method.__name__ # ty reports unresolved-attribute; Python returns "method". + /// method: MethodType = Example.method # ty reports invalid-assignment. + /// reveal_type(type(Example.method)) # ty reveals type; Python returns MethodType. + /// reveal_type(bool(Example.method)) # ty reveals bool; the result is always True. + /// ``` + /// + /// [descriptor-protocol]: https://docs.python.org/3/howto/descriptor.html#descriptor-protocol ClassMethodLike, - /// Represents the value bound to a `typing.ParamSpec` type variable. + /// An internal representation of the value bound to a `typing.ParamSpec` type variable. + /// + /// Unlike the other variants, this does not represent a callable object in its entirety: + /// it represents only the parameter lists substituted for a `ParamSpec`. + /// + /// We reuse callable signatures to store the parameter lists, including overloads, with + /// `Unknown` return types as placeholders. Specialization extracts these parameters into + /// `Callable[P, R]`, `Concatenate`, or paired `P.args`/`P.kwargs` annotations while preserving + /// the enclosing callable's return type. A single signature is displayed as a parameter + /// list, without a return type. + /// + /// This kind also distinguishes gradual `...` parameter lists and their top and bottom + /// materializations from ordinary callable types in type-relation checks. It does not + /// carry the runtime `typing.ParamSpec` instance behavior of a `ParamSpec` declaration. ParamSpecValue, } -/// Source-function provenance retained by a callable signature. -/// -/// A [`CallableType`] can describe a bare callable shape, such as one from `Callable[...]`. For -/// function-like sources, such as a [`FunctionType`] upcast to a [`CallableType`] or a lambda, this -/// records whether the source function has an explicit return annotation. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] -pub enum CallableFunctionProvenance { - /// The callable does not retain source-function provenance. - None, - - /// The callable came from a function without an explicit return annotation. - ImplicitReturn, - - /// The callable came from a function with an explicit return annotation. - ExplicitReturn, -} - -impl CallableFunctionProvenance { - pub(crate) fn from_function_return_annotation(has_explicit_return_annotation: bool) -> Self { - if has_explicit_return_annotation { - Self::ExplicitReturn - } else { - Self::ImplicitReturn - } - } -} - /// A "policy" enum that describes how `type[]` types should be upcast /// to `Callable` types. /// @@ -488,7 +703,7 @@ impl From for UpcastPolicy { /// It can be written in type expressions using `typing.Callable`. `lambda` expressions are /// inferred directly as `CallableType`s; all function-literal types are subtypes of a /// `CallableType`. -#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +#[salsa::interned(debug, constructor=new_internal, heap_size=ruff_memory_usage::heap_size)] pub struct CallableType<'db> { #[returns(ref)] pub(crate) signatures: CallableSignature<'db>, @@ -496,20 +711,10 @@ pub struct CallableType<'db> { #[returns(copy)] pub(super) kind: CallableTypeKind, - /// Source-function return-annotation provenance retained by this callable. - /// - /// Function-like values can retain their source-function provenance when converted to a - /// callable signature: - /// ```python - /// def decorator(cls) -> object: ... - /// ``` - /// - /// Callables that are only known from a callable shape do not retain that provenance: - /// ```python - /// def decorator_factory() -> Callable[[type[object]], object]: ... - /// ``` + /// The declaration on which `@deprecated` wrapped this callable. Retain the declaration + /// for diagnostic names, source annotations, and deduplication, independently of binding kind. #[returns(copy)] - pub(crate) provenance: CallableFunctionProvenance, + pub(crate) deprecated: Option>, } pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( @@ -517,8 +722,16 @@ pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( ty: CallableType<'db>, visitor: &V, ) { - for signature in &ty.signatures(db).overloads { - walk_signature(db, signature, visitor); + if ty.is_paramspec_value(db) { + // We normalize the callables that represent the value assigned to a ParamSpec by removing + // their return values. A missing return value is usually treated as `Unknown` + for signature in &ty.signatures(db).overloads { + walk_signature_without_return_type(db, signature, visitor); + } + } else { + for signature in &ty.signatures(db).overloads { + walk_signature(db, signature, visitor); + } } } @@ -526,12 +739,36 @@ pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( impl get_size2::GetSize for CallableType<'_> {} impl<'db> CallableType<'db> { + pub(crate) fn new(db: &'db dyn Db, signatures: S, kind: CallableTypeKind) -> Self + where + S: salsa::Lookup> + std::hash::Hash, + CallableSignature<'db>: salsa::HashEqLike, + { + Self::new_internal(db, signatures, kind, None) + } + + pub(crate) fn with_deprecated(self, db: &'db dyn Db, deprecated: OverloadLiteral<'db>) -> Self { + Self::new_internal(db, self.signatures(db), self.kind(db), Some(deprecated)) + } + + /// Replace the signatures without losing binding behavior or deprecation metadata. + pub(crate) fn with_signatures(self, db: &'db dyn Db, signatures: S) -> Self + where + S: salsa::Lookup> + std::hash::Hash, + CallableSignature<'db>: salsa::HashEqLike, + { + Self::new_internal(db, signatures, self.kind(db), self.deprecated(db)) + } + + pub(crate) fn with_kind(self, db: &'db dyn Db, kind: CallableTypeKind) -> Self { + Self::new_internal(db, self.signatures(db), kind, self.deprecated(db)) + } + pub(crate) fn single(db: &'db dyn Db, signature: Signature<'db>) -> CallableType<'db> { CallableType::new( db, CallableSignature::single(signature), CallableTypeKind::Regular, - CallableFunctionProvenance::None, ) } @@ -540,7 +777,6 @@ impl<'db> CallableType<'db> { db, CallableSignature::single(signature), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, ) } @@ -549,10 +785,13 @@ impl<'db> CallableType<'db> { db, CallableSignature::single(Signature::new(parameters, Type::unknown())), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, ) } + fn is_paramspec_value(self, db: &'db dyn Db) -> bool { + self.kind(db) == CallableTypeKind::ParamSpecValue + } + /// Create a callable type which accepts any parameters and returns an `Unknown` type. pub(crate) fn unknown(db: &'db dyn Db) -> CallableType<'db> { Self::single(db, Signature::unknown()) @@ -589,11 +828,21 @@ impl<'db> CallableType<'db> { } pub(crate) fn into_regular(self, db: &'db dyn Db) -> CallableType<'db> { + self.with_kind(db, CallableTypeKind::Regular) + } + + /// Retain every parameter signature and its generic context, but erase return types + /// that do not participate in a `ParamSpec` specialization. + pub(crate) fn into_paramspec_value(self, db: &'db dyn Db) -> CallableType<'db> { CallableType::new( db, - self.signatures(db), - CallableTypeKind::Regular, - self.provenance(db), + CallableSignature::from_overloads( + self.signatures(db) + .iter() + .cloned() + .map(|signature| signature.with_return_type(Type::unknown())), + ), + CallableTypeKind::ParamSpecValue, ) } @@ -617,7 +866,6 @@ impl<'db> CallableType<'db> { .map(|signature| signature.clone().with_return_type(return_ty)), ), self.kind(db), - self.provenance(db), ) } @@ -642,7 +890,6 @@ impl<'db> CallableType<'db> { db, CallableSignature::partially_apply(db, env, overloads)?, CallableTypeKind::Regular, - CallableFunctionProvenance::None, )) } @@ -677,30 +924,15 @@ impl<'db> CallableType<'db> { return self.into_regular(db); } - CallableType::new( - db, - self.signatures(db).bind_self(db, env, self_type), - self.kind(db), - self.provenance(db), - ) + self.with_signatures(db, self.signatures(db).bind_self(db, env, self_type)) } pub(crate) fn into_function_like(self, db: &'db dyn Db) -> CallableType<'db> { - CallableType::new( - db, - self.signatures(db), - CallableTypeKind::FunctionLike, - self.provenance(db), - ) + self.with_kind(db, CallableTypeKind::FunctionLike) } pub(crate) fn into_dunder_paramspec(self, db: &'db dyn Db) -> CallableType<'db> { - CallableType::new( - db, - self.signatures(db), - CallableTypeKind::DunderParamSpec, - self.provenance(db), - ) + self.with_kind(db, CallableTypeKind::DunderParamSpec) } pub(crate) fn apply_self( @@ -719,12 +951,10 @@ impl<'db> CallableType<'db> { receiver_type: Type<'db>, self_type: Type<'db>, ) -> CallableType<'db> { - CallableType::new( + self.with_signatures( db, self.signatures(db) .apply_self_with_receiver(db, env, receiver_type, self_type), - self.kind(db), - self.provenance(db), ) } @@ -733,12 +963,7 @@ impl<'db> CallableType<'db> { /// Specifically, this represents a callable type with a single signature: /// `(*args: object, **kwargs: object) -> Never`. pub(crate) fn bottom(db: &'db dyn Db) -> CallableType<'db> { - Self::new( - db, - CallableSignature::bottom(), - CallableTypeKind::Regular, - CallableFunctionProvenance::None, - ) + Self::new(db, CallableSignature::bottom(), CallableTypeKind::Regular) } pub(super) fn recursive_type_normalized_impl( @@ -748,13 +973,13 @@ impl<'db> CallableType<'db> { div: Type<'db>, nested: bool, ) -> Option { - Some(CallableType::new( - db, - self.signatures(db) - .recursive_type_normalized_impl(db, env, div, nested)?, - self.kind(db), - self.provenance(db), - )) + Some( + self.with_signatures( + db, + self.signatures(db) + .recursive_type_normalized_impl(db, env, div, nested)?, + ), + ) } pub(super) fn apply_type_mapping_impl<'a>( @@ -768,12 +993,10 @@ impl<'db> CallableType<'db> { return replacements.get(&self).copied().unwrap_or(self); } - CallableType::new( + self.with_signatures( db, self.signatures(db) .apply_type_mapping_impl(db, type_mapping, tcx, visitor), - self.kind(db), - self.provenance(db), ) } @@ -872,7 +1095,6 @@ impl<'db> CallableTypes<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::Regular, - CallableFunctionProvenance::None, ) .into_precise_functools_partial_instance(db, wrapped) } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index e2d16948b6..0de48e08cd 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -5,11 +5,13 @@ pub(crate) use self::dynamic_literal::{ DynamicClassAnchor, DynamicClassLiteral, DynamicMetaclassConflict, dynamic_class_bases_argument, }; pub(super) use self::enum_literal::{DynamicEnumAnchor, DynamicEnumLiteral, EnumSpec}; +use self::implicit_attributes::{AugmentedBindings, ImplicitAttribute}; pub use self::known::KnownClass; use self::named_tuple::synthesize_namedtuple_class_member; pub(super) use self::named_tuple::{ DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, NamedTupleField, NamedTupleSpec, }; +pub use self::slots::SlotDescriptorType; pub(crate) use self::static_literal::{ ClassLiteralFlags, ExpandedClassBaseEntry, FrozenDataclassDispatch, StaticClassLiteral, based_enum_has_payload_variants, based_enum_is_idiomatic, based_enum_of_variant, @@ -20,19 +22,20 @@ pub(super) use self::typed_dict::{ DynamicTypedDictAnchor, DynamicTypedDictLiteral, synthesized_typed_dict_class_member, }; use super::dedicated::{django, pydantic, sqlalchemy}; +use super::display; use super::{ BoundTypeVarIdentity, BoundTypeVarInstance, MemberLookupPolicy, MroIterator, SpecialFormType, SubclassOfType, Type, TypeQualifiers, class_base::ClassBase, function::FunctionType, }; -use super::{TypeVarVariance, display}; use crate::place::{DefinedPlace, Provenance, TypeOrigin}; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; +use crate::types::callable::CallableTypeKind; use crate::types::constraints::{ ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, }; use crate::types::enums::enum_metadata; use crate::types::function::{AbstractMethodKind, DataclassTransformerParams}; use crate::types::generics::{GenericContext, Specialization, walk_specialization}; +use crate::types::infer::infer_definition_types; use crate::types::known_instance::DeprecatedInstance; use crate::types::member::Member; use crate::types::relation::{ @@ -43,10 +46,11 @@ use crate::types::signatures::{ }; use crate::types::tuple::TupleSpec; use crate::types::typevar::TypeVarSet; +use crate::types::variance::VarianceOrigin; use crate::types::{ - ApplyTypeMappingVisitor, CallableType, CallableTypes, DataclassParams, - FindLegacyTypeVarsVisitor, IntersectionType, TypeContext, TypeMapping, TypedDictModule, - UnionBuilder, VarianceInferable, + ApplyTypeMappingVisitor, CallableType, CallableTypes, DataclassParams, ErrorContext, + ErrorContextTree, FindLegacyTypeVarsVisitor, IntersectionType, TypeContext, TypeMapping, + TypeVarVariance, TypingModule, UnionBuilder, VarianceInferable, VarianceTerm, }; use crate::{ Db, FxIndexMap, FxOrderSet, @@ -68,15 +72,30 @@ use ty_python_core::{ProgramFile, place_table, use_def_map}; mod dynamic_literal; mod enum_literal; +mod implicit_attributes; mod known; mod named_tuple; +mod slots; mod static_literal; mod typed_dict; #[derive(Clone, Copy)] enum DynamicClassHeaderAnchor<'db> { Definition(Definition<'db>), - ScopeOffset(u32), + ScopeOffset(DynamicClassScopeOffset), +} + +/// Identifies a dangling dynamic-class call relative to its enclosing scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub enum DynamicClassScopeOffset { + /// A call in the module AST, identified by its scope-relative node index. + Node(u32), + + /// A call in a parsed string annotation, whose nodes are not in the module AST. + /// + /// `offset` identifies the outermost string expression in the module AST, relative to the + /// scope's node index. `range` identifies the call within that string expression. + StringAnnotation { offset: u32, range: TextRange }, } /// Returns the source range of a call that creates a dynamic class. @@ -98,11 +117,24 @@ fn dynamic_class_header_range<'db>( .expect("dynamic class definitions should only be used for assignments") .range(), DynamicClassHeaderAnchor::ScopeOffset(offset) => { + let (offset, relative_range) = match offset { + DynamicClassScopeOffset::Node(offset) => (offset, None), + DynamicClassScopeOffset::StringAnnotation { offset, range } => { + (offset, Some(range)) + } + }; let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor .as_u32() .expect("anchor should not be NodeIndex::NONE"); let absolute_index = NodeIndex::from(anchor_u32 + offset); + if let Some(relative_range) = relative_range { + let string: &ast::ExprStringLiteral = module + .get_by_index(absolute_index) + .try_into() + .expect("string annotation offset should point to ExprStringLiteral"); + return relative_range + string.start(); + } let node: &ast::ExprCall = module .get_by_index(absolute_index) .try_into() @@ -340,7 +372,7 @@ impl<'db> CodeGeneratorKind<'db> { matches!(self, Self::Pydantic(_)) } - pub(super) const fn is_sqlalchemy(self) -> bool { + const fn is_sqlalchemy(self) -> bool { matches!(self, Self::SqlalchemyDeclarative) } @@ -513,23 +545,26 @@ impl<'db> VarianceInferable<'db> for GenericAlias<'db> { db: &'db dyn Db, _: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - self.variance_of_owner(db, typevar) + ) -> VarianceTerm<'db> { + VarianceTerm::variable(db, VarianceOrigin::GenericAlias(self), typevar) } } #[salsa::tracked] impl<'db> GenericAlias<'db> { + /// Compose each type argument's variance with its formal parameter's variance. Inferred + /// parameters refer to the unspecialized class equation, keeping references such as + /// `P[list[T]]` finite without expanding specialized class bodies. #[salsa::tracked( returns(copy), - cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, + cycle_initial=|_, _, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size )] - fn variance_of_owner( + pub(in crate::types) fn variance_equation( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { let origin = self.origin(db); let env = ProgramEnvironment::from_file(origin.program_file(db)); @@ -537,7 +572,7 @@ impl<'db> GenericAlias<'db> { // Note that we only care about the variance of the specialized generic alias with respect // to the given type variable, not the unspecialized class literal origin. - specialization + let variances = specialization .generic_context(db) .variables(db) .zip(specialization.types(db)) @@ -555,28 +590,26 @@ impl<'db> GenericAlias<'db> { .is_reified_class_typevar(db) .then_some(TypeVarVariance::Invariant) }); - if let Some(explicit_variance) = declared_variance { - ty.with_polarity(explicit_variance) - .variance_of(db, &env, typevar) - } else { - // `with_polarity` composes the passed variance with the - // inferred one. The inference is done lazily, as we can - // sometimes determine the result just from the passed - // variance. This operation is commutative, so we could - // infer either first. We choose to make the `StaticClassLiteral` - // variance lazy, as it is known to be expensive, requiring - // that we traverse all members. - // - // If salsa let us look at the cache, we could check first - // to see if the class literal query was already run. - - let typevar_variance_in_substituted_type = ty.variance_of(db, &env, typevar); - origin - .with_polarity(typevar_variance_in_substituted_type) - .variance_of(db, &env, generic_typevar.identity(db)) - } - }) - .collect() + // Composition is commutative. Keep the argument on the left so evaluation can + // skip the class's potentially expensive equation when the argument is bivariant. + ty.variance_of(db, &env, typevar) + .compose_thunk(db, || match declared_variance { + Some(explicit_variance) + if generic_typevar.is_paramspec(db) + || generic_typevar.is_typevartuple(db) + || origin.into_protocol_class(db).is_none() => + { + explicit_variance.into() + } + Some(explicit_variance) => VarianceTerm::variable( + db, + VarianceOrigin::ProtocolParameter(origin, explicit_variance), + generic_typevar.identity(db), + ), + None => origin.variance_of(db, &env, generic_typevar.identity(db)), + }) + }); + VarianceTerm::join(db, variances) } } @@ -703,6 +736,16 @@ impl<'db> ClassLiteral<'db> { } } + pub(super) fn inferred_metaclass(self, db: &'db dyn Db) -> ClassMetaclass<'db> { + match self { + Self::Static(class) => class.inferred_metaclass(db), + Self::Dynamic(class) => class.inferred_metaclass(db), + Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) | Self::DynamicEnum(_) => { + ClassMetaclass::Selected(self.metaclass(db)) + } + } + } + /// Look up a class-level member by iterating through the MRO. pub(crate) fn class_member( self, @@ -716,7 +759,7 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => class.class_member(db, env, name, policy), Self::DynamicNamedTuple(namedtuple) => namedtuple.class_member(db, env, name, policy), Self::DynamicTypedDict(typeddict) => typeddict.class_member(db, env, name, policy), - Self::DynamicEnum(enum_lit) => enum_lit.class_member(db, env, name), + Self::DynamicEnum(enum_lit) => enum_lit.class_member(db, env, name, policy), } } @@ -885,7 +928,7 @@ impl<'db> ClassLiteral<'db> { } /// basedpython: returns whether this class is declared `sealed`. - pub(crate) fn is_sealed(self, db: &'db dyn Db) -> bool { + fn is_sealed(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_sealed(db), Self::Dynamic(_) @@ -1572,12 +1615,17 @@ impl<'db> ClassType<'db> { /// Return the metaclass of this class, or `type[Unknown]` if the metaclass cannot be inferred. pub(super) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { - match self { - Self::NonGeneric(class) => class.metaclass(db), - Self::Generic(generic) => generic - .origin(db) - .metaclass(db) - .apply_optional_specialization(db, Some(generic.specialization(db))), + let env = ProgramEnvironment::from_file(self.class_literal(db).program_file(db)); + self.inferred_metaclass(db).to_type(db, &env) + } + + pub(super) fn inferred_metaclass(self, db: &'db dyn Db) -> ClassMetaclass<'db> { + let (class, specialization) = self.class_literal_and_specialization(db); + match class.inferred_metaclass(db) { + ClassMetaclass::Selected(metaclass) => ClassMetaclass::Selected( + metaclass.apply_optional_specialization(db, specialization), + ), + ClassMetaclass::ProtocolFallback => ClassMetaclass::ProtocolFallback, } } @@ -1692,6 +1740,7 @@ impl<'db> ClassType<'db> { db, env, other, + None, |this, other| this.could_exist_in_mro_of(db, env, other, constraints), |this, other| { this.is_disjoint_from(db, env, other, constraints, TypeVarSet::None) @@ -1717,6 +1766,7 @@ impl<'db> ClassType<'db> { db, env, other, + checker.report_context(), |this, other| { this.could_exist_in_mro_of_with_disjointness_checker(db, env, other, checker) }, @@ -1733,11 +1783,13 @@ impl<'db> ClassType<'db> { ) } + #[expect(clippy::too_many_arguments)] fn could_coexist_in_mro_with_impl( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, other: Self, + context: Option<&ErrorContextTree<'db>>, could_exist_in_mro_of: impl Fn(Self, Self) -> bool, specializations_are_disjoint: impl Fn(Specialization<'db>, Specialization<'db>) -> bool, types_are_disjoint: impl Fn(Type<'db>, Type<'db>) -> bool, @@ -1747,11 +1799,25 @@ impl<'db> ClassType<'db> { } if self.is_final(db) { - return could_exist_in_mro_of(other, self); + let compatible = could_exist_in_mro_of(other, self); + if !compatible && let Some(context) = context { + context.push(ErrorContext::FinalClassDisjoint { + final_type: Type::instance(db, env, self), + other: Type::instance(db, env, other), + }); + } + return compatible; } if other.is_final(db) { - return could_exist_in_mro_of(self, other); + let compatible = could_exist_in_mro_of(self, other); + if !compatible && let Some(context) = context { + context.push(ErrorContext::FinalClassDisjoint { + final_type: Type::instance(db, env, other), + other: Type::instance(db, env, self), + }); + } + return compatible; } // A class cannot implement two incompatible specializations of an invariant base. @@ -1770,6 +1836,12 @@ impl<'db> ClassType<'db> { }) }) { + if let Some(context) = context { + context.push(ErrorContext::IncompatibleClassLayouts { + left: Type::instance(db, env, self), + right: Type::instance(db, env, other), + }); + } return false; } @@ -1779,11 +1851,11 @@ impl<'db> ClassType<'db> { // that `type` is its own metaclass (and we know that `type` can coexist in an MRO // with any other arbitrary class, anyway). let type_class = KnownClass::Type.to_class_literal(db, env); - let self_metaclass = self.metaclass(db); + let self_metaclass = self.inferred_metaclass(db).for_inheritance(db, env); if self_metaclass == type_class { return true; } - let other_metaclass = other.metaclass(db); + let other_metaclass = other.inferred_metaclass(db).for_inheritance(db, env); if other_metaclass == type_class { return true; } @@ -1891,7 +1963,13 @@ impl<'db> ClassType<'db> { .map(|specialization| specialization.tuple_runtime_element_specialization(db)); class_literal .own_class_member(db, env, inherited_generic_context, specialization, name) - .map_type(|ty| ty.apply_projected_optional_specialization(db, env, specialization)) + .map_type(|ty| { + ty.apply_projected_optional_owner_specialization_to_member( + db, + env, + specialization, + ) + }) }; match name { @@ -2089,7 +2167,6 @@ impl<'db> ClassType<'db> { db, getitem_signature, CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )); Member::definitely_declared(getitem_type) }) @@ -2207,7 +2284,9 @@ impl<'db> ClassType<'db> { class_literal .instance_member(db, env, Some(specialization), name) - .map_type(|ty| ty.apply_projected_specialization(db, env, specialization)) + .map_type(|ty| { + ty.apply_projected_owner_specialization_to_member(db, env, specialization) + }) } } } @@ -2262,11 +2341,49 @@ impl<'db> ClassType<'db> { generic .origin(db) .own_instance_member(db, env, name) - .map_type(|ty| ty.apply_optional_specialization(db, Some(specialization))) + .map_type(|ty| { + ty.apply_optional_owner_specialization_to_member(db, Some(specialization)) + }) } } } + /// Pair an ordinary member lookup with augmented assignments that first read their target. + /// + /// ```python + /// class Counter: + /// value = 0 + /// + /// def increment(self): + /// self.value += 1 + /// + /// @classmethod + /// def increment_class(cls): + /// cls.value += 1 + /// ``` + /// + /// MRO lookup can infer either assignment only after locating an existing `value`. If ordinary + /// lookup suppressed an implicit attribute, such as a generated `NamedTuple` field, its writes + /// must remain suppressed too. + fn member_with_augmented_bindings( + self, + db: &'db dyn Db, + member: Member<'db>, + name: &str, + target_method_decorator: MethodDecorator, + ) -> ImplicitAttribute<'db> { + let augmented_bindings = self + .static_class_literal(db) + .map(|(class, _)| class.implicit_attribute_bindings(db, name, target_method_decorator)) + .filter(|implicit| member.is_undefined() == implicit.member.is_undefined()) + .and_then(|implicit| implicit.augmented_bindings); + + ImplicitAttribute { + member, + augmented_bindings, + } + } + /// Return a callable type (or union of callable types) that represents the callable /// constructor signature of this class. pub(super) fn into_callable(self, db: &'db dyn Db) -> CallableTypes<'db> { @@ -2365,12 +2482,8 @@ impl<'db> ClassType<'db> { .iter() .any(|signature| !signature.return_ty.is_assignable_to(db, env, instance_type)); - let dunder_new_bound_method = CallableType::new( - db, - bound_signature, - CallableTypeKind::Regular, - CallableFunctionProvenance::None, - ); + let dunder_new_bound_method = + CallableType::new(db, bound_signature, CallableTypeKind::Regular); if returns_non_subclass { return CallableTypes::one(dunder_new_bound_method); @@ -2444,7 +2557,6 @@ impl<'db> ClassType<'db> { db, synthesized_dunder_init_signature, CallableTypeKind::Regular, - CallableFunctionProvenance::None, )) } else { None @@ -2557,7 +2669,7 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => class.variance_of(db, env, typevar), Self::NonGeneric( @@ -2565,7 +2677,7 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { | ClassLiteral::DynamicNamedTuple(_) | ClassLiteral::DynamicTypedDict(_) | ClassLiteral::DynamicEnum(_), - ) => TypeVarVariance::Bivariant, + ) => VarianceTerm::BIVARIANT, Self::Generic(generic) => generic.variance_of(db, env, typevar), } } @@ -2799,7 +2911,7 @@ impl Field<'_> { /// Whether this field is immutable after construction because of a per-field /// marker (`pydantic.Field(frozen=True)`). Model-wide freezing (a frozen /// dataclass or a frozen model config) is handled separately. - pub(crate) const fn is_frozen(&self) -> bool { + const fn is_frozen(&self) -> bool { match &self.kind { FieldKind::Pydantic { frozen, .. } => *frozen, _ => false, @@ -2821,13 +2933,13 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { match self { Self::Static(class) => class.variance_of(db, env, typevar), Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicTypedDict(_) - | Self::DynamicEnum(_) => TypeVarVariance::Bivariant, + | Self::DynamicEnum(_) => VarianceTerm::BIVARIANT, } } } @@ -2853,6 +2965,60 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { } } + /// Infer augmented-assignment results after finding the existing attribute they read. + /// + /// ```python + /// class Counter: + /// def __init__(self): + /// self.value = (1,) + /// + /// def update(self): + /// self.value += (self.value,) + /// ``` + /// + /// Inferring these bindings earlier would recursively look up the same attribute and + /// allow an augmented assignment to incorrectly establish an otherwise missing attribute. + /// Once recursive inference produces a concrete result, top-level cycle placeholders do not + /// represent additional runtime values. Other inferred alternatives remain intact, as do + /// nested placeholders in genuinely expanding recursive types such as the tuple above. + fn infer_augmented_bindings( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + bindings: &[(ClassType<'db>, AugmentedBindings<'db>)], + ) -> (Type<'db>, Provenance<'db>) { + let mut union = UnionBuilder::new(db, env); + let mut provenance = Provenance::Unknown; + + for (class, bindings) in bindings { + let (_, specialization) = class.class_literal_and_specialization(db); + + for definition in bindings.definitions(db) { + let inferred_ty = infer_definition_types(db, *definition) + .binding_type(*definition) + .apply_optional_specialization(db, specialization); + union = union.add(inferred_ty); + provenance = provenance.or(Provenance::SingleDefinition(*definition)); + } + } + + let inferred_ty = union.build().promote(db, env).promote_singletons(db, env); + let inferred_ty = if let Some(elements) = + inferred_ty.as_union().map(|union| union.elements(db)) + && elements.iter().any(Type::is_divergent) + && elements.iter().any(|ty| !ty.is_divergent()) + { + UnionType::from_elements( + db, + env, + elements.iter().copied().filter(|ty| !ty.is_divergent()), + ) + } else { + inferred_ty + }; + + (inferred_ty, provenance) + } + /// Look up a class member by iterating through the MRO. /// /// Parameters: @@ -2879,6 +3045,7 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { let mut dynamic_type: Option> = None; let mut lookup_result: LookupResult<'db> = Err(LookupError::Undefined(TypeQualifiers::empty())); + let mut pending_augmented_bindings = Vec::new(); for superclass in self.mro_iter { match superclass { @@ -2916,14 +3083,40 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { continue; } + let implicit = class.member_with_augmented_bindings( + db, + class.own_class_member(db, &self.env, inherited_generic_context, name), + name, + MethodDecorator::ClassMethod, + ); + if let Some(bindings) = implicit.augmented_bindings { + pending_augmented_bindings.push((class, bindings)); + } + + let mut member = implicit.member.inner; + if let Place::Defined(defined) = &mut member.place + && !pending_augmented_bindings.is_empty() + { + if !defined.origin.is_declared() { + let (inferred_ty, inferred_provenance) = Self::infer_augmented_bindings( + db, + &self.env, + &pending_augmented_bindings, + ); + defined.ty = UnionType::from_two_elements( + db, + &self.env, + defined.ty, + inferred_ty, + ); + defined.provenance = defined.provenance.or(inferred_provenance); + } + + pending_augmented_bindings.clear(); + } + lookup_result = lookup_result.or_else(|lookup_error| { - lookup_error.or_fall_back_to( - db, - &self.env, - class - .own_class_member(db, &self.env, inherited_generic_context, name) - .inner, - ) + lookup_error.or_fall_back_to(db, &self.env, member) }); } ClassBase::TypedDict(module) => { @@ -2954,8 +3147,9 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { let db = self.db; let mut union = UnionBuilder::new(db, &self.env); let mut union_qualifiers = TypeQualifiers::empty(); - let mut is_definitely_bound = false; + let mut definitely_bound_member: Option> = None; let mut provenance = Provenance::Unknown; + let mut pending_augmented_bindings = Vec::new(); for superclass in self.mro_iter { match superclass { @@ -2969,6 +3163,16 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { return InstanceMemberResult::Done(PlaceAndQualifiers::unbound()); } ClassBase::Class(class) => { + let implicit = class.member_with_augmented_bindings( + db, + class.own_instance_member(db, &self.env, name), + name, + MethodDecorator::None, + ); + if let Some(bindings) = implicit.augmented_bindings { + pending_augmented_bindings.push((class, bindings)); + } + if let member @ PlaceAndQualifiers { place: Place::Defined(DefinedPlace { @@ -2979,16 +3183,28 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { .. }), qualifiers, - } = class.own_instance_member(db, &self.env, name).inner + } = implicit.member.inner { if boundness == Definedness::AlwaysDefined { if origin.is_declared() { + if definitely_bound_member.is_some_and(|member| { + !member + .qualifiers + .contains(TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE) + }) && !qualifiers + .contains(TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE) + { + // An overriding class default shadows inherited declarations, + // but inherited instance assignments must still be collected. + continue; + } + // We found a definitely-declared attribute. Discard possibly collected // inferred types from subclasses and return the declared type. return InstanceMemberResult::Done(member); } - is_definitely_bound = true; + definitely_bound_member = Some(member); } // If the attribute is not definitely declared on this class, keep looking @@ -3000,6 +3216,64 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { // TODO: We could raise a diagnostic here if there are conflicting type // qualifiers union_qualifiers |= qualifiers; + + if !pending_augmented_bindings.is_empty() { + let (inferred_ty, inferred_provenance) = Self::infer_augmented_bindings( + db, + &self.env, + &pending_augmented_bindings, + ); + union = union.add(inferred_ty); + provenance = provenance.or(inferred_provenance); + union_qualifiers |= TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE; + pending_augmented_bindings.clear(); + } + } + + if !pending_augmented_bindings.is_empty() + && let class_member @ Member { + inner: + PlaceAndQualifiers { + place: + Place::Defined(DefinedPlace { + ty: class_member_ty, + origin, + definedness: class_member_definedness, + provenance: class_member_provenance, + .. + }), + .. + }, + } = class.own_class_member(db, &self.env, None, name) + { + if !class_member_ty.is_definitely_non_data_descriptor(db, &self.env) { + pending_augmented_bindings.clear(); + continue; + } + + if origin.is_declared() { + if union.is_empty() { + return InstanceMemberResult::Done(class_member.inner); + } + + union = union.add(class_member_ty); + provenance = provenance.or(class_member_provenance); + union_qualifiers |= class_member.inner.qualifiers; + } else { + let (inferred_ty, inferred_provenance) = Self::infer_augmented_bindings( + db, + &self.env, + &pending_augmented_bindings, + ); + union = union.add(inferred_ty); + provenance = provenance.or(inferred_provenance); + union_qualifiers |= TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE; + } + + pending_augmented_bindings.clear(); + if class_member_definedness == Definedness::AlwaysDefined { + definitely_bound_member = Some(class_member.inner); + } } } ClassBase::TypedDict(_) => { @@ -3011,7 +3285,7 @@ impl<'db, I: Iterator>> MroLookup<'db, I> { let result = if union.is_empty() { Place::Undefined.with_qualifiers(TypeQualifiers::empty()) } else { - let boundness = if is_definitely_bound { + let boundness = if definitely_bound_member.is_some() { Definedness::AlwaysDefined } else { Definedness::PossiblyUndefined @@ -3036,7 +3310,7 @@ pub(super) enum ClassMemberResult<'db> { /// Found the member or exhausted the MRO. Done(CompletedMemberLookup<'db>), /// Encountered a `TypedDict` base. - TypedDict(TypedDictModule), + TypedDict(TypingModule), } pub(super) struct CompletedMemberLookup<'db> { @@ -3216,6 +3490,59 @@ pub(super) enum DisjointBaseKind { DefinesSlots, } +/// A selected metaclass, or the `ABCMeta` fallback inferred from a typeshed stdlib protocol base. +/// +/// Typeshed lists `Protocol` as a base for some classes, such as collection ABCs, that do not +/// inherit from it at runtime. Inferring a metaclass constraint from those bases would therefore +/// produce false conflicts. The fallback exposes ABC methods such as `register`, but does not +/// participate in metaclass selection. +/// +/// Outside those stubs, a `Protocol` base selects its actual `_ProtocolMeta` metaclass, even in a +/// stub file. It participates in metaclass selection and constrains subclasses in the usual way. +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +pub(super) enum ClassMetaclass<'db> { + Selected(Type<'db>), + /// A lookup-only fallback originating in typeshed. Inheritance preserves this provenance. + ProtocolFallback, +} + +impl<'db> ClassMetaclass<'db> { + fn with_protocol_fallback( + db: &'db dyn Db, + selected: Type<'db>, + has_protocol_fallback: bool, + ) -> Self { + if has_protocol_fallback + && selected + .to_class_type(db) + .is_some_and(|class| class.is_known(db, KnownClass::Type)) + { + Self::ProtocolFallback + } else { + Self::Selected(selected) + } + } + + fn to_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + match self { + Self::Selected(metaclass) => metaclass, + Self::ProtocolFallback => KnownClass::ABCMeta.to_class_literal(db, env), + } + } + + /// Return the metaclass guaranteed by class declarations, without the typeshed fallback. + pub(super) fn for_inheritance( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Type<'db> { + match self { + Self::Selected(metaclass) => metaclass, + Self::ProtocolFallback => KnownClass::Type.to_class_literal(db, env), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct MetaclassError<'db> { kind: MetaclassErrorKind<'db>, @@ -3235,16 +3562,11 @@ pub(super) enum MetaclassErrorKind<'db> { /// The metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all /// its bases. Conflict { - /// `candidate1` will either be the explicit `metaclass=` keyword in the class definition, - /// or the inferred metaclass of a base class - candidate1: MetaclassCandidate<'db>, - - /// `candidate2` will always be the inferred metaclass of a base class - candidate2: MetaclassCandidate<'db>, - - /// Flag to indicate whether `candidate1` is the explicit `metaclass=` keyword or the - /// inferred metaclass of a base class. This helps us give better error messages in diagnostics. - candidate1_is_base_class: bool, + /// The explicit `metaclass=` keyword or a previously visited base's metaclass. + candidate: MetaclassCandidate<'db>, + /// The incompatible metaclass of `base`. + base_metaclass: ClassType<'db>, + base: ClassBase<'db>, }, /// The metaclass is a parameterized generic class, which is not supported. GenericMetaclass, @@ -3255,61 +3577,3 @@ pub(super) enum MetaclassErrorKind<'db> { /// A cycle was encountered attempting to determine the metaclass Cycle, } - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -enum SlotsKind { - /// `__slots__` is not found in the class. - NotSpecified, - /// `__slots__` is defined but empty: `__slots__ = ()`. - Empty, - /// `__slots__` is defined and is not empty: `__slots__ = ("a", "b")`. - NotEmpty, - /// `__slots__` is defined but its value is dynamic: - /// * `__slots__ = tuple(a for a in b)` - /// * `__slots__ = ["a", "b"]` - Dynamic, -} - -impl SlotsKind { - fn from(db: &dyn Db, base: StaticClassLiteral) -> Self { - let env = ProgramEnvironment::from_scope(base.body_scope(db)); - let Place::Defined(DefinedPlace { - ty: slots_ty, - definedness: bound, - .. - }) = base - .own_class_member( - db, - &env, - base.inherited_generic_context(db), - None, - "__slots__", - ) - .inner - .place - else { - return Self::NotSpecified; - }; - - if matches!(bound, Definedness::PossiblyUndefined) { - return Self::Dynamic; - } - - match slots_ty { - // __slots__ = ("a", "b") - Type::NominalInstance(nominal) => match nominal - .tuple_spec(db, &env) - .and_then(|spec| spec.len().into_fixed_length()) - { - Some(0) => Self::Empty, - Some(_) => Self::NotEmpty, - None => Self::Dynamic, - }, - - // __slots__ = "abc" # Same as `("abc",)` - Type::LiteralValue(literal) if literal.is_string() => Self::NotEmpty, - - _ => Self::Dynamic, - } - } -} diff --git a/crates/ty_python_semantic/src/types/class/dynamic_literal.rs b/crates/ty_python_semantic/src/types/class/dynamic_literal.rs index 5757f67c3d..210d8b2abc 100644 --- a/crates/ty_python_semantic/src/types/class/dynamic_literal.rs +++ b/crates/ty_python_semantic/src/types/class/dynamic_literal.rs @@ -10,9 +10,9 @@ use crate::{ ClassBase, ClassLiteral, ClassType, DataclassParams, KnownClass, MemberLookupPolicy, SubclassOfType, Type, class::{ - ClassMemberResult, CodeGeneratorKind, DisjointBase, DynamicClassHeaderAnchor, - InstanceMemberResult, MroLookup, dynamic_class_header_range, - typed_dict::typed_dict_fallback_class_member, + ClassMemberResult, ClassMetaclass, CodeGeneratorKind, DisjointBase, + DynamicClassHeaderAnchor, DynamicClassScopeOffset, InstanceMemberResult, MroLookup, + dynamic_class_header_range, typed_dict::typed_dict_fallback_class_member, }, definition_expression_type, extract_fixed_length_iterable_element_types, member::Member, @@ -49,7 +49,7 @@ use ty_python_core::{definition::Definition, scope::ScopeId}; /// /// The `anchor` field provides stable identity: /// - For assigned calls, the `Definition` uniquely identifies the class. -/// - For dangling calls, a relative node offset anchored to the enclosing scope +/// - For dangling calls, a call location anchored to the enclosing scope /// provides stable identity that only changes when the scope itself changes. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct DynamicClassLiteral<'db> { @@ -61,8 +61,8 @@ pub struct DynamicClassLiteral<'db> { /// /// - `Definition`: The call is assigned to a variable. The definition /// uniquely identifies this class and can be used to find the call expression. - /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset - /// is relative to the enclosing scope's anchor node index. + /// - `ScopeOffset`: The call is "dangling" (not assigned). Its location + /// is relative to the enclosing scope. #[returns(ref)] pub anchor: DynamicClassAnchor<'db>, @@ -98,14 +98,14 @@ pub enum DynamicClassAnchor<'db> { /// The call is "dangling" (not assigned to a variable). /// - /// The offset is relative to the enclosing scope's anchor node index. - /// For module scope, this is equivalent to an absolute index (anchor is 0). + /// The [`DynamicClassScopeOffset`] locates the call relative to the enclosing scope, + /// including when the call is inside a string annotation. /// /// The `explicit_bases` are computed eagerly at creation time since dangling /// calls cannot recursively reference the class being defined. ScopeOffset { scope: ScopeId<'db>, - offset: u32, + offset: DynamicClassScopeOffset, explicit_bases: Box<[Type<'db>]>, }, } @@ -261,8 +261,13 @@ impl<'db> DynamicClassLiteral<'db> { /// /// See pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { + let env = ProgramEnvironment::from_scope(self.scope(db)); + self.inferred_metaclass(db).to_type(db, &env) + } + + pub(super) fn inferred_metaclass(self, db: &'db dyn Db) -> ClassMetaclass<'db> { self.try_metaclass(db) - .unwrap_or_else(|_| SubclassOfType::subclass_of_unknown()) + .unwrap_or_else(|_| ClassMetaclass::Selected(SubclassOfType::subclass_of_unknown())) } /// Try to get the metaclass of this dynamic class. @@ -271,10 +276,10 @@ impl<'db> DynamicClassLiteral<'db> { /// (i.e., two base classes have metaclasses that are not in a subclass relationship). /// /// See - pub(crate) fn try_metaclass( + pub(in crate::types) fn try_metaclass( self, db: &'db dyn Db, - ) -> Result, DynamicMetaclassConflict<'db>> { + ) -> Result, DynamicMetaclassConflict<'db>> { let original_bases = self.explicit_bases(db); let env = ProgramEnvironment::from_scope(self.scope(db)); @@ -282,37 +287,46 @@ impl<'db> DynamicClassLiteral<'db> { // To dynamically create a class with no bases that has a custom metaclass, // you have to invoke that metaclass rather than `type()`. if original_bases.is_empty() { - return Ok(KnownClass::Type.to_class_literal(db, &env)); + return Ok(ClassMetaclass::Selected( + KnownClass::Type.to_class_literal(db, &env), + )); } // If there's an MRO error, return unknown to avoid cascading errors. if self.try_mro(db).is_err() { - return Ok(SubclassOfType::subclass_of_unknown()); + return Ok(ClassMetaclass::Selected( + SubclassOfType::subclass_of_unknown(), + )); } // Convert Types to ClassBases for metaclass computation. // All bases should convert successfully here: `try_mro()` above would have // returned `Err(InvalidBases)` if any failed, causing us to return early. - let bases: Vec> = original_bases + let mut has_protocol_fallback = false; + let mut bases = original_bases .iter() .filter_map(|base_type| ClassBase::try_from_type(db, &env, *base_type, None)) - .collect(); - - // If all bases failed to convert, return type as the metaclass. - if bases.is_empty() { - return Ok(KnownClass::Type.to_class_literal(db, &env)); - } - - // Start with the first base's metaclass as the candidate. - let mut candidate = bases[0].metaclass(db, &env); + .filter_map(|base| { + match base.inferred_metaclass(db, &env, ClassLiteral::Dynamic(self)) { + ClassMetaclass::Selected(metaclass) => Some((base, metaclass)), + ClassMetaclass::ProtocolFallback => { + has_protocol_fallback = true; + None + } + } + }); - // Track which base the candidate metaclass came from. - let (mut candidate_base, rest) = bases.split_first().unwrap(); + // Start with the first selected metaclass, ignoring protocol fallbacks. + let Some((mut candidate_base, mut candidate)) = bases.next() else { + return Ok(ClassMetaclass::with_protocol_fallback( + db, + KnownClass::Type.to_class_literal(db, &env), + has_protocol_fallback, + )); + }; // Reconcile with other bases' metaclasses. - for base in rest { - let base_metaclass = base.metaclass(db, &env); - + for (base, base_metaclass) in bases { // Get the ClassType for comparison. let Some(candidate_class) = candidate.to_class_type(db) else { // If candidate isn't a class type, keep it as is. @@ -322,6 +336,11 @@ impl<'db> DynamicClassLiteral<'db> { continue; }; + // Keep the incumbent when both metaclasses are equal. + if candidate_class.is_subclass_of(db, &env, base_metaclass_class) { + continue; + } + // If base's metaclass is more derived, use it. if base_metaclass_class.is_subclass_of(db, &env, candidate_class) { candidate = base_metaclass; @@ -329,22 +348,21 @@ impl<'db> DynamicClassLiteral<'db> { continue; } - // If candidate is already more derived, keep it. - if candidate_class.is_subclass_of(db, &env, base_metaclass_class) { - continue; - } - // Conflict: neither metaclass is a subclass of the other. // Python raises `TypeError: metaclass conflict` at runtime. return Err(DynamicMetaclassConflict { metaclass1: candidate_class, - base1: *candidate_base, + base1: candidate_base, metaclass2: base_metaclass_class, - base2: *base, + base2: base, }); } - Ok(candidate) + Ok(ClassMetaclass::with_protocol_fallback( + db, + candidate, + has_protocol_fallback, + )) } /// Iterate over the MRO of this class using C3 linearization. diff --git a/crates/ty_python_semantic/src/types/class/enum_literal.rs b/crates/ty_python_semantic/src/types/class/enum_literal.rs index 36535f410c..ccb37c973a 100644 --- a/crates/ty_python_semantic/src/types/class/enum_literal.rs +++ b/crates/ty_python_semantic/src/types/class/enum_literal.rs @@ -8,7 +8,7 @@ use crate::place::{Place, PlaceAndQualifiers}; use crate::types::Type; use crate::types::class::known::KnownClass; use crate::types::class::{ - ClassLiteral, ClassType, DynamicClassHeaderAnchor, MemberLookupPolicy, + ClassLiteral, ClassType, DynamicClassHeaderAnchor, DynamicClassScopeOffset, MemberLookupPolicy, dynamic_class_header_range, }; use crate::types::class_base::ClassBase; @@ -63,7 +63,7 @@ pub enum DynamicEnumAnchor<'db> { }, ScopeOffset { scope: ScopeId<'db>, - offset: u32, + offset: DynamicClassScopeOffset, spec: EnumSpec<'db>, }, } @@ -251,18 +251,28 @@ impl<'db> DynamicEnumLiteral<'db> { /// /// If members are unknown and nothing was found in the MRO, returns `Unknown` /// as a last resort to avoid false `unresolved-attribute` errors. + /// + /// `policy` is forwarded to the mixin and enum-base lookups so that those lookups resolve the + /// same way they would on an equivalent class-syntax enum. For example, a caller asking for + /// `__eq__` with `MRO_NO_OBJECT_FALLBACK` must not be given `object.__eq__`: that would + /// describe the enum as defining its own equality and hide its real comparison semantics. + /// + /// The unknown-member fallback at the end does not consult `policy`. It exists to avoid false + /// `unresolved-attribute` errors when the member names are not statically known, which is a + /// property of the enum rather than of the lookup being performed. pub(crate) fn class_member( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, name: &str, + policy: MemberLookupPolicy, ) -> PlaceAndQualifiers<'db> { let own = self.own_class_member(db, name); if !own.is_undefined() { return own.inner; } if let Some(mixin_class) = self.mixin_class(db, env) { - let result = mixin_class.class_member(db, env, name, MemberLookupPolicy::default()); + let result = mixin_class.class_member(db, env, name, policy); if !result.place.is_undefined() { return result; } @@ -271,7 +281,7 @@ impl<'db> DynamicEnumLiteral<'db> { .base_class(db) .to_class_literal(db, env) .as_class_literal() - .map(|cls| cls.class_member(db, env, name, MemberLookupPolicy::default())) + .map(|cls| cls.class_member(db, env, name, policy)) .unwrap_or_else(|| Place::Undefined.into()); // When members are unknown (e.g. `Enum("E", some_var)`), any name could diff --git a/crates/ty_python_semantic/src/types/class/implicit_attributes.rs b/crates/ty_python_semantic/src/types/class/implicit_attributes.rs new file mode 100644 index 0000000000..d863a36e4e --- /dev/null +++ b/crates/ty_python_semantic/src/types/class/implicit_attributes.rs @@ -0,0 +1,500 @@ +//! Implicit instance and class attributes inferred from method assignments. + +use super::{MethodDecorator, static_literal::StaticClassLiteral}; +use crate::{ + Db, ProgramEnvironment, TypeQualifiers, attribute_assignments, attribute_declarations, + place::{Place, Provenance}, + reachability::binding_reachability, + types::{ + KnownClass, Truthiness, Type, TypeContext, UnionBuilder, definition_expression_type, + function::{is_implicit_classmethod, is_implicit_staticmethod}, + infer::infer_unpack_types, + infer_expression_type, inferred_declaration, + member::Member, + }, +}; +use ruff_db::parsed::parsed_module; +use ruff_python_ast::name::Name; +use ty_python_core::{ + attribute_scopes, + definition::{Definition, DefinitionKind, DefinitionState, TargetKind}, + place_table, + scope::{Scope, ScopeId}, + semantic_index, use_def_map, +}; + +#[salsa::tracked] +impl<'db> StaticClassLiteral<'db> { + /// Tries to find declarations/bindings of an attribute named `name` that are only + /// "implicitly" defined (`self.x = …`, `cls.x = …`) in a method of this class. + /// The `target_method_decorator` parameter is used to skip methods that do not have the + /// expected decorator. + pub(super) fn implicit_attribute( + self, + db: &'db dyn Db, + name: &str, + target_method_decorator: MethodDecorator, + ) -> Member<'db> { + self.implicit_attribute_bindings(db, name, target_method_decorator) + .member + } + + /// Separate assignments that establish an attribute from assignments that must first read it. + /// + /// ```python + /// class Counter: + /// def increment(self): + /// self.value += 1 + /// ``` + /// + /// Here, `value` remains undefined until MRO lookup finds an independent class or instance + /// attribute. The same rule applies to `cls.value` in a classmethod. + pub(super) fn implicit_attribute_bindings( + self, + db: &'db dyn Db, + name: &str, + target_method_decorator: MethodDecorator, + ) -> ImplicitAttribute<'db> { + let class_body_scope = self.body_scope(db); + // Collect names in a tracked query so unrelated edits can preserve dependent member + // lookups, and avoid retaining query entries for names that no method can define. + let names = implicit_attribute_names(db, class_body_scope); + let Ok(name_index) = names.binary_search_by(|candidate| candidate.as_str().cmp(name)) + else { + return ImplicitAttribute { + member: Member::unbound(), + augmented_bindings: None, + }; + }; + + Self::implicit_attribute_inner( + db, + ImplicitAttributeName::new( + db, + class_body_scope, + &names[name_index], + target_method_decorator, + ), + ) + } + + #[salsa::tracked( + returns(copy), + cycle_fn=implicit_attribute_cycle_recover, + cycle_initial=|_, id, _| ImplicitAttribute { + member: Member { + inner: Place::bound(Type::divergent(id)).into(), + }, + augmented_bindings: None, + }, + heap_size=ruff_memory_usage::heap_size, + )] + fn implicit_attribute_inner( + db: &'db dyn Db, + attribute: ImplicitAttributeName<'db>, + ) -> ImplicitAttribute<'db> { + Self::implicit_attribute_impl(db, attribute) + } + + fn implicit_attribute_impl( + db: &'db dyn Db, + attribute: ImplicitAttributeName<'db>, + ) -> ImplicitAttribute<'db> { + let class_body_scope = attribute.class_body_scope(db); + let name = attribute.name(db).as_str(); + let target_method_decorator = attribute.target_method_decorator(db); + let program_file = class_body_scope.program_file(db); + let python_file = program_file.python_file(db); + let env = &ProgramEnvironment::from_file(program_file); + + // If we do not see any declarations of an attribute, neither in the class body nor in + // any method, we build a union of the raw types inferred from all bindings of that + // attribute, then apply public-type promotion to the final union. + let mut union_of_inferred_types = UnionBuilder::new(db, env); + let mut qualifiers = TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE; + + let mut is_attribute_bound = false; + let mut augmented_bindings = Vec::new(); + let mut provenance = Provenance::Unknown; + + let module = parsed_module(db, python_file).load(db); + let index = semantic_index(db, program_file); + let class_map = use_def_map(db, class_body_scope); + let class_table = place_table(db, class_body_scope); + let is_valid_scope = |method_scope: &Scope| { + let Some(method_def) = method_scope.node().as_function() else { + return true; + }; + + // Check the decorators directly on the AST node to determine if this method + // is a classmethod or staticmethod. This is more reliable than checking the + // final evaluated type, which may be wrapped by other decorators like @cache. + let function_node = method_def.node(&module); + let definition = index.expect_single_definition(method_def); + + let mut is_classmethod = false; + let mut is_staticmethod = false; + + for decorator in &function_node.decorator_list { + let decorator_ty = + definition_expression_type(db, definition, &decorator.expression); + if let Type::ClassLiteral(class) = decorator_ty { + match class.known(db) { + Some(KnownClass::Classmethod) => is_classmethod = true, + Some(KnownClass::Staticmethod) => is_staticmethod = true, + _ => {} + } + } + } + + // Also check for implicit classmethods/staticmethods based on method name + let method_name = function_node.name.as_str(); + if is_implicit_classmethod(method_name) { + is_classmethod = true; + } + if is_implicit_staticmethod(method_name) { + is_staticmethod = true; + } + + match target_method_decorator { + MethodDecorator::None => !is_classmethod && !is_staticmethod, + MethodDecorator::ClassMethod => is_classmethod, + MethodDecorator::StaticMethod => is_staticmethod, + } + }; + + // First check declarations + for (attribute_declarations, method_scope_id) in + attribute_declarations(db, class_body_scope, name) + { + let method_scope = index.scope(method_scope_id); + if !is_valid_scope(method_scope) { + continue; + } + + for attribute_declaration in attribute_declarations { + let DefinitionState::Defined(declaration) = attribute_declaration.declaration + else { + continue; + }; + + let DefinitionKind::AnnotatedAssignment(assignment) = declaration.kind(db) else { + continue; + }; + + // We found an annotated assignment of one of the following forms (using 'self' in these + // examples, but we support arbitrary names for the first parameters of methods): + // + // self.name: + // self.name: = … + + let Some(annotation) = inferred_declaration(db, declaration).declared() else { + continue; + }; + let annotation = Place::declared(annotation.inner) + .with_definition(declaration) + .with_qualifiers( + annotation.qualifiers | TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE, + ); + + if let Some(all_qualifiers) = annotation.is_bare_final() { + if let Some(value) = assignment.value(&module) { + // If we see an annotated assignment with a bare `Final` as in + // `self.SOME_CONSTANT: Final = 1`, infer the type from the value + // on the right-hand side. + + let inferred_ty = infer_expression_type( + db, + index.expression(value), + TypeContext::default(), + ); + return ImplicitAttribute { + member: Member { + inner: Place::bound(inferred_ty) + .with_definition(declaration) + .with_qualifiers(all_qualifiers), + }, + augmented_bindings: None, + }; + } + + // If there is no right-hand side, just record that we saw a `Final` qualifier + qualifiers |= all_qualifiers; + continue; + } + + return ImplicitAttribute { + member: Member { inner: annotation }, + augmented_bindings: None, + }; + } + } + + for (attribute_assignments, attribute_binding_scope_id) in + attribute_assignments(db, class_body_scope, name) + { + let binding_scope = index.scope(attribute_binding_scope_id); + if !is_valid_scope(binding_scope) { + continue; + } + + let scope_for_reachability_analysis = { + if binding_scope.node().as_function().is_some() { + binding_scope + } else if binding_scope.is_eager() { + let mut eager_scope_parent = binding_scope; + while eager_scope_parent.is_eager() + && let Some(parent) = eager_scope_parent.parent() + { + eager_scope_parent = index.scope(parent); + } + eager_scope_parent + } else { + binding_scope + } + }; + + // The attribute assignment inherits the reachability of the method which contains it + let is_method_reachable = + if let Some(method_def) = scope_for_reachability_analysis.node().as_function() { + let method = index.expect_single_definition(method_def); + let method_place = class_table + .symbol_id(&method_def.node(&module).name) + .unwrap(); + class_map + .reachable_symbol_bindings(method_place) + .find_map(|bind| { + (bind.binding.is_defined_and(|def| def == method)) + .then(|| binding_reachability(db, class_map, &bind)) + }) + .unwrap_or(Truthiness::AlwaysFalse) + } else { + Truthiness::AlwaysFalse + }; + if is_method_reachable.is_always_false() { + continue; + } + + for attribute_assignment in attribute_assignments { + if let DefinitionState::Undefined = attribute_assignment.binding { + continue; + } + + let DefinitionState::Defined(binding) = attribute_assignment.binding else { + continue; + }; + + if matches!(binding.kind(db), DefinitionKind::AugmentedAssignment(_)) { + augmented_bindings.push(binding); + continue; + } + + if !is_method_reachable.is_always_false() { + is_attribute_bound = true; + } + + let inferred_ty = implicit_attribute_binding_type(db, binding); + + if let Some(inferred_ty) = inferred_ty { + provenance = provenance.or(Provenance::SingleDefinition(binding)); + union_of_inferred_types = union_of_inferred_types.add(inferred_ty); + } + } + } + + let member = if is_attribute_bound { + Member { + inner: Place::bound( + union_of_inferred_types + .build() + .promote_in(db, env, class_body_scope.file(db)) + .promote_singletons(db, env), + ) + .with_provenance(provenance) + .with_qualifiers(qualifiers), + } + } else { + Member::unbound() + }; + + ImplicitAttribute { + member, + augmented_bindings: (!augmented_bindings.is_empty()) + .then(|| AugmentedBindings::new(db, augmented_bindings.into_boxed_slice())), + } + } +} + +/// Attributes assigned by instance methods or classmethods on a single class. +/// +/// Ordinary assignments such as `self.value = 1` or `cls.value = 1` establish an attribute +/// directly. Augmented assignments first require an existing instance or class attribute to supply +/// the value they read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +pub(super) struct ImplicitAttribute<'db> { + /// The attribute established by assignments that do not depend on an existing value. + pub(super) member: Member<'db>, + /// Augmented assignments that require an existing instance or class attribute. + pub(super) augmented_bindings: Option>, +} + +/// Augmented assignments deferred until MRO lookup finds the attribute they read. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub(super) struct AugmentedBindings<'db> { + #[returns(deref)] + pub(super) definitions: Box<[Definition<'db>]>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for AugmentedBindings<'_> {} + +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct ImplicitAttributeName<'db> { + #[returns(copy)] + class_body_scope: ScopeId<'db>, + #[returns(ref)] + name: Name, + #[returns(copy)] + target_method_decorator: MethodDecorator, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for ImplicitAttributeName<'_> {} + +/// Infer the value written by an attribute definition, including unpacked and iteration targets. +fn implicit_attribute_binding_type<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Option> { + let program_file = definition.program_file(db); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + let env = ProgramEnvironment::from_file(program_file); + + match definition.kind(db) { + DefinitionKind::AnnotatedAssignment(_) => { + // Annotated assignments are handled before inferring ordinary attribute bindings. + None + } + DefinitionKind::Assignment(assignment) => match assignment.unpack() { + Some(unpack) => { + // (..., self.name, ...) = + let unpacked = infer_unpack_types(db, unpack); + Some(unpacked.expression_type(assignment.target(&module))) + } + None => { + // self.name = + Some(infer_expression_type( + db, + index.expression(assignment.value(&module)), + TypeContext::default(), + )) + } + }, + DefinitionKind::For(for_stmt) => match for_stmt.target_kind() { + TargetKind::Sequence(_, unpack) => { + // for ..., self.name, ... in : + let unpacked = infer_unpack_types(db, unpack); + Some(unpacked.expression_type(for_stmt.target(&module))) + } + TargetKind::Single => { + // for self.name in : + let iterable_ty = infer_expression_type( + db, + index.expression(for_stmt.iterable(&module)), + TypeContext::default(), + ); + // TODO: Potential diagnostics resulting from the iterable are not reported. + Some( + iterable_ty + .iterate(db, &env) + .homogeneous_element_type(db, &env), + ) + } + }, + DefinitionKind::WithItem(with_item) => match with_item.target_kind() { + TargetKind::Sequence(_, unpack) => { + // with as ..., self.name, ...: + let unpacked = infer_unpack_types(db, unpack); + Some(unpacked.expression_type(with_item.target(&module))) + } + TargetKind::Single => { + // with as self.name: + let context_ty = infer_expression_type( + db, + index.expression(with_item.context_expr(&module)), + TypeContext::default(), + ); + Some(if with_item.is_async() { + context_ty.aenter(db, &env) + } else { + context_ty.enter(db, &env) + }) + } + }, + DefinitionKind::Comprehension(comprehension) => match comprehension.target_kind() { + TargetKind::Sequence(_, unpack) => { + // [... for ..., self.name, ... in ] + let unpacked = infer_unpack_types(db, unpack); + Some(unpacked.expression_type(comprehension.target(&module))) + } + TargetKind::Single => { + // [... for self.name in ] + let iterable_ty = infer_expression_type( + db, + index.expression(comprehension.iterable(&module)), + TypeContext::default(), + ); + // TODO: Potential diagnostics resulting from the iterable are not reported. + Some( + iterable_ty + .iterate(db, &env) + .homogeneous_element_type(db, &env), + ) + } + }, + // Named expressions cannot target attributes, and other definitions do not write one. + _ => None, + } +} + +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +pub(super) fn implicit_attribute_names<'db>( + db: &'db dyn Db, + class_body_scope: ScopeId<'db>, +) -> Box<[Name]> { + let index = semantic_index(db, class_body_scope.program_file(db)); + let mut names = Vec::new(); + + for function_scope_id in attribute_scopes(db, class_body_scope) { + names.extend( + index + .place_table(function_scope_id) + .members() + .filter_map(|member| member.as_instance_attribute().map(Name::new)), + ); + } + + names.sort_unstable(); + names.dedup(); + names.into_boxed_slice() +} + +fn implicit_attribute_cycle_recover<'db>( + db: &'db dyn Db, + cycle: &salsa::Cycle, + previous: &ImplicitAttribute<'db>, + attribute_member: ImplicitAttribute<'db>, + attribute: ImplicitAttributeName<'db>, +) -> ImplicitAttribute<'db> { + let env = ProgramEnvironment::from_scope(attribute.class_body_scope(db)); + let inner = + attribute_member + .member + .inner + .cycle_normalized(db, &env, previous.member.inner, cycle); + ImplicitAttribute { + member: Member { inner }, + ..attribute_member + } +} diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 8401e498e7..d4ce587fa9 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -85,6 +85,8 @@ pub enum KnownClass { MethodType, MethodWrapperType, WrapperDescriptorType, + MemberDescriptorType, + GetSetDescriptorType, UnionType, GeneratorType, AsyncGeneratorType, @@ -114,6 +116,7 @@ pub enum KnownClass { TypeVarTuple, ExtensionsTypeVarTuple, // must be distinct from typing.TypeVarTuple, backports new features TypeAliasType, + ExtensionsTypeAliasType, // may be distinct from typing.TypeAliasType NoDefaultType, NewType, Hashable, @@ -152,6 +155,9 @@ pub enum KnownClass { // re ReMatch, RePattern, + // unittest + /// The standard-library `unittest.case.TestCase` class. + UnittestTestCase, // ty_extensions Character, ConstraintSet, @@ -182,9 +188,151 @@ pub enum KnownClass { SqlalchemyDeclarativeBase, SqlalchemyMappedAsDataclass, SqlalchemyMapped, + // Pytest + PytestParametrizeMarkDecorator, } impl KnownClass { + /// Return whether this class is known to have `type` as its runtime metaclass. + /// + /// Built-in and extension types can inherit from collection ABCs only in their stubs. Those + /// bases must not give the runtime class an inferred protocol metaclass. + pub(super) fn has_known_type_metaclass(self, python_version: PythonVersion) -> bool { + match self { + Self::Bool + | Self::Object + | Self::Bytes + | Self::Bytearray + | Self::Memoryview + | Self::Type + | Self::Int + | Self::Float + | Self::Complex + | Self::Str + | Self::List + | Self::Tuple + | Self::Range + | Self::Set + | Self::FrozenSet + | Self::Dict + | Self::Slice + | Self::Property + | Self::BaseException + | Self::Exception + | Self::Warning + | Self::BaseExceptionGroup + | Self::ExceptionGroup + | Self::Staticmethod + | Self::Classmethod + | Self::Super + | Self::NotImplementedError + | Self::GenericAlias + | Self::ModuleType + | Self::FunctionType + | Self::MethodType + | Self::MethodWrapperType + | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType + | Self::UnionType + | Self::GeneratorType + | Self::AsyncGeneratorType + | Self::CoroutineType + | Self::NotImplementedType + | Self::BuiltinFunctionType + | Self::EllipsisType + | Self::Deque => true, + + Self::Sentinel => python_version >= PythonVersion::PY315, + + Self::Enum + | Self::EnumProperty + | Self::EnumType + | Self::Auto + | Self::Member + | Self::Nonmember + | Self::StrEnum + | Self::IntEnum + | Self::Flag + | Self::IntFlag + | Self::ABCMeta + | Self::NoneType + | Self::SupportsKeysAndGetItem + | Self::Awaitable + | Self::Generator + | Self::AsyncGenerator + | Self::Deprecated + | Self::StdlibAlias + | Self::SpecialForm + | Self::TypeVar + | Self::ParamSpec + | Self::ExtensionsParamSpec + | Self::ParamSpecArgs + | Self::ParamSpecKwargs + | Self::ProtocolMeta + | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple + | Self::TypeAliasType + | Self::ExtensionsTypeAliasType + | Self::NoDefaultType + | Self::NewType + | Self::Hashable + | Self::SupportsIndex + | Self::Iterable + | Self::Iterator + | Self::AsyncIterator + | Self::Sequence + | Self::Mapping + | Self::MutableMapping + | Self::ExtensionsTypeVar + | Self::ExtensionTypedDictFallback + | Self::ChainMap + | Self::Counter + | Self::DefaultDict + | Self::OrderedDict + | Self::VersionInfo + | Self::Field + | Self::KwOnly + | Self::NamedTupleFallback + | Self::NamedTupleLike + | Self::TypedDictFallback + | Self::Template + | Self::Path + | Self::FunctoolsPartial + | Self::ConstraintSet + | Self::ConstraintSetSolution + | Self::GenericContext + | Self::Specialization + | Self::TyExtensionsAsyncIterable + | Self::TyExtensionsAsyncIterator + | Self::TyExtensionsIterable + | Self::TyExtensionsIterator + | Self::UnittestTestCase + | Self::PydanticBaseModel + | Self::PydanticBaseSettings + | Self::PydanticConfigDict + | Self::PydanticRootModel + | Self::PydanticStrict + | Self::PytestParametrizeMarkDecorator + | Self::AssertionError + | Self::RuntimeError + | Self::ReMatch + | Self::RePattern + | Self::ByStaticProperty + | Self::DjangoModel + | Self::DjangoField + | Self::DjangoForeignKey + | Self::DjangoOneToOneField + | Self::DjangoManyToManyField + | Self::DjangoManager + | Self::DjangoQuerySet + | Self::SqlalchemyDeclarativeBase + | Self::SqlalchemyMappedAsDataclass + | Self::SqlalchemyMapped + | Self::Character => false, + } + } + pub(crate) const fn is_bool(self) -> bool { matches!(self, Self::Bool) } @@ -213,6 +361,7 @@ impl KnownClass { | Self::FunctionType | Self::VersionInfo | Self::TypeAliasType + | Self::ExtensionsTypeAliasType | Self::TypeVar | Self::ExtensionsTypeVar | Self::ParamSpec @@ -223,6 +372,8 @@ impl KnownClass { | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType | Self::UnionType | Self::GeneratorType | Self::AsyncGeneratorType @@ -314,6 +465,7 @@ impl KnownClass { | Self::Path | Self::ExtensionTypedDictFallback | Self::TypedDictFallback + | Self::UnittestTestCase | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict @@ -329,7 +481,8 @@ impl KnownClass { | Self::SqlalchemyDeclarativeBase | Self::SqlalchemyMappedAsDataclass | Self::SqlalchemyMapped - | Self::ByStaticProperty => Some(Truthiness::Ambiguous), + | Self::ByStaticProperty + | Self::PytestParametrizeMarkDecorator => Some(Truthiness::Ambiguous), // Evaluating `NotImplementedType` in a boolean context was deprecated in Python 3.9 // and raises a `TypeError` in Python >=3.14 @@ -394,6 +547,8 @@ impl KnownClass { | KnownClass::MethodType | KnownClass::MethodWrapperType | KnownClass::WrapperDescriptorType + | KnownClass::MemberDescriptorType + | KnownClass::GetSetDescriptorType | KnownClass::UnionType | KnownClass::GeneratorType | KnownClass::AsyncGeneratorType @@ -411,6 +566,7 @@ impl KnownClass { | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType | KnownClass::Hashable @@ -452,6 +608,7 @@ impl KnownClass { | KnownClass::FunctoolsPartial | KnownClass::ReMatch | KnownClass::RePattern + | KnownClass::UnittestTestCase | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict @@ -467,7 +624,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | KnownClass::PytestParametrizeMarkDecorator => false, } } @@ -524,6 +682,8 @@ impl KnownClass { | KnownClass::MethodType | KnownClass::MethodWrapperType | KnownClass::WrapperDescriptorType + | KnownClass::MemberDescriptorType + | KnownClass::GetSetDescriptorType | KnownClass::UnionType | KnownClass::GeneratorType | KnownClass::AsyncGeneratorType @@ -541,6 +701,7 @@ impl KnownClass { | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType | KnownClass::Hashable @@ -582,6 +743,7 @@ impl KnownClass { | KnownClass::FunctoolsPartial | KnownClass::ReMatch | KnownClass::RePattern + | KnownClass::UnittestTestCase | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticRootModel @@ -596,7 +758,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | KnownClass::PytestParametrizeMarkDecorator => false, KnownClass::PydanticConfigDict => true, } @@ -655,6 +818,8 @@ impl KnownClass { | KnownClass::MethodType | KnownClass::MethodWrapperType | KnownClass::WrapperDescriptorType + | KnownClass::MemberDescriptorType + | KnownClass::GetSetDescriptorType | KnownClass::UnionType | KnownClass::GeneratorType | KnownClass::AsyncGeneratorType @@ -672,6 +837,7 @@ impl KnownClass { | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType | KnownClass::Hashable @@ -712,6 +878,7 @@ impl KnownClass { | KnownClass::FunctoolsPartial | KnownClass::ReMatch | KnownClass::RePattern + | KnownClass::UnittestTestCase | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict @@ -727,7 +894,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | KnownClass::PytestParametrizeMarkDecorator => false, } } @@ -798,6 +966,8 @@ impl KnownClass { | Self::MethodType | Self::MethodWrapperType | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType | Self::NoneType | Self::SpecialForm | Self::TypeVar @@ -810,6 +980,7 @@ impl KnownClass { | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::TypeAliasType + | Self::ExtensionsTypeAliasType | Self::NoDefaultType | Self::NewType | Self::ChainMap @@ -854,6 +1025,7 @@ impl KnownClass { | Self::Mapping | Self::MutableMapping | Self::Sequence + | Self::UnittestTestCase | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict @@ -869,7 +1041,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | Self::PytestParametrizeMarkDecorator => false, } } @@ -926,6 +1099,8 @@ impl KnownClass { | KnownClass::MethodType | KnownClass::MethodWrapperType | KnownClass::WrapperDescriptorType + | KnownClass::MemberDescriptorType + | KnownClass::GetSetDescriptorType | KnownClass::UnionType | KnownClass::GeneratorType | KnownClass::AsyncGeneratorType @@ -951,6 +1126,7 @@ impl KnownClass { | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType | KnownClass::Hashable @@ -985,6 +1161,7 @@ impl KnownClass { | KnownClass::ConstraintSetSolution | KnownClass::GenericContext | KnownClass::Specialization + | KnownClass::UnittestTestCase | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict @@ -1000,7 +1177,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | KnownClass::PytestParametrizeMarkDecorator => false, KnownClass::NamedTupleFallback | KnownClass::TypedDictFallback | KnownClass::ExtensionTypedDictFallback => true, @@ -1048,6 +1226,8 @@ impl KnownClass { Self::UnionType => "UnionType", Self::MethodWrapperType => "MethodWrapperType", Self::WrapperDescriptorType => "WrapperDescriptorType", + Self::MemberDescriptorType => "MemberDescriptorType", + Self::GetSetDescriptorType => "GetSetDescriptorType", Self::BuiltinFunctionType => "BuiltinFunctionType", Self::GeneratorType => "GeneratorType", Self::AsyncGeneratorType => "AsyncGeneratorType", @@ -1064,7 +1244,7 @@ impl KnownClass { Self::TypeVarTuple => "TypeVarTuple", Self::ExtensionsTypeVarTuple => "TypeVarTuple", Self::Sentinel => "sentinel", - Self::TypeAliasType => "TypeAliasType", + Self::TypeAliasType | Self::ExtensionsTypeAliasType => "TypeAliasType", Self::NoDefaultType => "_NoDefaultType", Self::NewType => "NewType", Self::Hashable => "Hashable", @@ -1130,6 +1310,7 @@ impl KnownClass { Self::ReMatch => "Match", Self::RePattern => "Pattern", Self::ProtocolMeta => "_ProtocolMeta", + Self::UnittestTestCase => "TestCase", Self::PydanticBaseModel => "BaseModel", Self::PydanticBaseSettings => "BaseSettings", Self::PydanticConfigDict => "ConfigDict", @@ -1145,34 +1326,19 @@ impl KnownClass { Self::SqlalchemyDeclarativeBase => "DeclarativeBase", Self::SqlalchemyMappedAsDataclass => "MappedAsDataclass", Self::SqlalchemyMapped => "Mapped", + Self::PytestParametrizeMarkDecorator => "_ParametrizeMarkDecorator", } } pub(crate) fn display(self, python_version: PythonVersion) -> impl std::fmt::Display { - struct KnownClassDisplay { - class: KnownClass, - python_version: PythonVersion, - } - - impl std::fmt::Display for KnownClassDisplay { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let KnownClassDisplay { - class: known_class, - python_version, - } = *self; - write!( - f, - "{module}.{class}", - module = known_class.canonical_module(python_version), - class = known_class.name(python_version) - ) - } - } - - KnownClassDisplay { - class: self, - python_version, - } + std::fmt::from_fn(move |f| { + write!( + f, + "{module}.{class}", + module = self.canonical_module(python_version), + class = self.name(python_version) + ) + }) } /// Look up a [`KnownClass`] in its canonical module and return a [`Type`] representing all @@ -1197,7 +1363,11 @@ impl KnownClass { // or salsa aborts the whole run #[salsa::tracked( returns(copy), - cycle_initial=|_, _, _| Type::unknown(), + cycle_initial=|_, id, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, argument: KnownClassArgument<'db>| { + let env = ProgramEnvironment::from_program(argument.program(db)); + result.cycle_normalized(db, &env, *previous, cycle) + }, heap_size=ruff_memory_usage::heap_size, )] fn known_class_to_instance<'db>( @@ -1517,7 +1687,9 @@ impl KnownClass { | Self::BuiltinFunctionType | Self::EllipsisType | Self::NotImplementedType - | Self::WrapperDescriptorType => KnownModule::Types, + | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType => KnownModule::Types, Self::NoneType | Self::SupportsKeysAndGetItem => KnownModule::Typeshed, Self::SpecialForm | Self::TypeVar @@ -1537,7 +1709,7 @@ impl KnownClass { | Self::Mapping | Self::MutableMapping | Self::Hashable => KnownModule::CollectionsAbcInternal, - Self::TypeAliasType + Self::ExtensionsTypeAliasType | Self::ExtensionsTypeVar | Self::ExtensionsTypeVarTuple | Self::ExtensionsParamSpec @@ -1546,6 +1718,13 @@ impl KnownClass { | Self::Deprecated | Self::ExtensionTypedDictFallback | Self::NewType => KnownModule::TypingExtensions, + Self::TypeAliasType => { + if python_version >= PythonVersion::PY312 { + KnownModule::Typing + } else { + KnownModule::TypingExtensions + } + } Self::TypeVarTuple => { if python_version >= PythonVersion::PY311 { KnownModule::Typing @@ -1591,6 +1770,7 @@ impl KnownClass { Self::Path => KnownModule::Pathlib, Self::FunctoolsPartial => KnownModule::Functools, Self::ReMatch | Self::RePattern => KnownModule::Re, + Self::UnittestTestCase => KnownModule::UnittestCase, Self::PydanticBaseModel => KnownModule::PydanticMain, Self::PydanticBaseSettings => KnownModule::PydanticSettingsMain, Self::PydanticConfigDict => KnownModule::PydanticConfig, @@ -1607,6 +1787,7 @@ impl KnownClass { KnownModule::SqlalchemyOrmDeclApi } Self::SqlalchemyMapped => KnownModule::SqlalchemyOrmBase, + Self::PytestParametrizeMarkDecorator => KnownModule::PytestMarkStructures, } } @@ -1642,6 +1823,8 @@ impl KnownClass { | Self::MethodType | Self::MethodWrapperType | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType | Self::GeneratorType | Self::AsyncGeneratorType | Self::CoroutineType @@ -1669,6 +1852,7 @@ impl KnownClass { | Self::AsyncGenerator | Self::Deprecated | Self::TypeAliasType + | Self::ExtensionsTypeAliasType | Self::TypeVar | Self::ExtensionsTypeVar | Self::ParamSpec @@ -1721,6 +1905,7 @@ impl KnownClass { | Self::FunctoolsPartial | Self::ReMatch | Self::RePattern + | Self::UnittestTestCase | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict @@ -1736,7 +1921,8 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped - | KnownClass::ByStaticProperty => false, + | KnownClass::ByStaticProperty + | Self::PytestParametrizeMarkDecorator => false, } } @@ -1792,9 +1978,11 @@ impl KnownClass { "UnionType" => &[Self::UnionType], "MethodWrapperType" => &[Self::MethodWrapperType], "WrapperDescriptorType" => &[Self::WrapperDescriptorType], + "MemberDescriptorType" => &[Self::MemberDescriptorType], + "GetSetDescriptorType" => &[Self::GetSetDescriptorType], "BuiltinFunctionType" => &[Self::BuiltinFunctionType], "NewType" => &[Self::NewType], - "TypeAliasType" => &[Self::TypeAliasType], + "TypeAliasType" => &[Self::TypeAliasType, Self::ExtensionsTypeAliasType], "TypeVar" => &[Self::TypeVar, Self::ExtensionsTypeVar], "Iterable" => &[Self::Iterable, Self::TyExtensionsIterable], "Iterator" => &[Self::Iterator, Self::TyExtensionsIterator], @@ -1853,6 +2041,7 @@ impl KnownClass { "Pattern" => &[Self::RePattern], "_ProtocolMeta" => &[Self::ProtocolMeta], "_TypedDict" => &[Self::ExtensionTypedDictFallback], + "TestCase" => &[Self::UnittestTestCase], "BaseModel" => &[Self::PydanticBaseModel], "BaseSettings" => &[Self::PydanticBaseSettings], "ConfigDict" => &[Self::PydanticConfigDict], @@ -1867,6 +2056,7 @@ impl KnownClass { "DeclarativeBase" => &[Self::SqlalchemyDeclarativeBase], "MappedAsDataclass" => &[Self::SqlalchemyMappedAsDataclass], "Mapped" => &[Self::SqlalchemyMapped], + "_ParametrizeMarkDecorator" => &[Self::PytestParametrizeMarkDecorator], _ => return None, }; @@ -1939,6 +2129,8 @@ impl KnownClass { | Self::AsyncGeneratorType | Self::CoroutineType | Self::WrapperDescriptorType + | Self::MemberDescriptorType + | Self::GetSetDescriptorType | Self::BuiltinFunctionType | Self::Field | Self::KwOnly @@ -1952,6 +2144,8 @@ impl KnownClass { | Self::ExtensionsParamSpec | Self::TypeVarTuple | Self::ExtensionsTypeVarTuple + | Self::TypeAliasType + | Self::ExtensionsTypeAliasType | Self::Sentinel | Self::NamedTupleLike | Self::Character @@ -1966,6 +2160,8 @@ impl KnownClass { | Self::Awaitable | Self::Generator | Self::AsyncGenerator + | Self::UnittestTestCase + | Self::PytestParametrizeMarkDecorator | Self::Hashable | Self::Iterable | Self::Iterator @@ -2000,7 +2196,6 @@ impl KnownClass { Self::NoneType => matches!(module, KnownModule::Typeshed | KnownModule::Types), Self::SpecialForm - | Self::TypeAliasType | Self::NoDefaultType | Self::SupportsIndex | Self::ParamSpecArgs @@ -2210,62 +2405,42 @@ impl<'db> KnownClassLookupError<'db> { } fn display<'env>( - &self, + self, db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, class: KnownClass, ) -> impl std::fmt::Display + 'env { - struct ErrorDisplay<'env, 'db> { - db: &'db dyn Db, - env: &'env ProgramEnvironment<'db>, - class: KnownClass, - error: KnownClassLookupError<'db>, - } - - impl std::fmt::Display for ErrorDisplay<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - let ErrorDisplay { - db: _, - env, - class, - error, - } = self; - - let python_version = env.python_version(db); - let class = class.display(python_version); - let location = if error.is_third_party() { - "" - } else { - " in typeshed" - }; + std::fmt::from_fn(move |f| { + let python_version = env.python_version(db); + let class = class.display(python_version); + let location = if self.is_third_party() { + "" + } else { + " in typeshed" + }; - match error { - KnownClassLookupError::ClassNotFound { .. } => write!( - f, - "Could not find class `{class}`{location} on Python {python_version}", - ), - KnownClassLookupError::SymbolNotAClass { found_type, .. } => write!( - f, - "Error looking up `{class}`{location}: expected to find a class definition \ - on Python {python_version}, but found a symbol of type `{found_type}` instead", - found_type = found_type.display(db, env), - ), - KnownClassLookupError::ClassPossiblyUnbound { .. } => write!( - f, - "Error looking up `{class}`{location} on Python {python_version}: expected \ - to find a fully bound symbol, but found one that is possibly unbound", - ), - } + match self { + KnownClassLookupError::ClassNotFound { .. } => write!( + f, + "Could not find class `{class}`{location} on Python {python_version}", + ), + KnownClassLookupError::SymbolNotAClass { found_type, .. } => write!( + f, + "Error looking up `{class}`{location}: \ + expected to find a class definition \ + on Python {python_version}, \ + but found a symbol of type `{found_type}` instead", + found_type = found_type.display(db, env), + ), + KnownClassLookupError::ClassPossiblyUnbound { .. } => write!( + f, + "Error looking up `{class}`{location} \ + on Python {python_version}: expected \ + to find a fully bound symbol, \ + but found one that is possibly unbound", + ), } - } - - ErrorDisplay { - db, - env, - class, - error: *self, - } + }) } } @@ -2387,7 +2562,7 @@ mod tests { python_platform: python_platform.clone(), search_paths: search_paths.clone(), }; - program = Program::from_settings(&db, settings); + program = Program::from_settings(&db, &settings); current_version = version_added; } diff --git a/crates/ty_python_semantic/src/types/class/named_tuple.rs b/crates/ty_python_semantic/src/types/class/named_tuple.rs index 199470acf6..6045953c08 100644 --- a/crates/ty_python_semantic/src/types/class/named_tuple.rs +++ b/crates/ty_python_semantic/src/types/class/named_tuple.rs @@ -10,7 +10,7 @@ use crate::{ BindingContext, BoundTypeVarInstance, ClassBase, ClassLiteral, ClassType, GenericContext, KnownClass, KnownInstanceType, MemberLookupPolicy, Parameter, Parameters, PropertyInstanceType, Signature, SubclassOfType, Type, TypeContext, TypeMapping, - class::{DynamicClassHeaderAnchor, dynamic_class_header_range}, + class::{DynamicClassHeaderAnchor, DynamicClassScopeOffset, dynamic_class_header_range}, definition_expression_type, member::Member, mro::Mro, @@ -161,8 +161,8 @@ pub struct DynamicNamedTupleLiteral<'db> { /// /// - `Definition`: The call is assigned to a variable. The definition /// uniquely identifies this namedtuple and can be used to find the call. - /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset - /// is relative to the enclosing scope's anchor node index. + /// - `ScopeOffset`: The call is "dangling" (not assigned). Its location + /// is relative to the enclosing scope. #[returns(ref)] pub anchor: DynamicNamedTupleAnchor<'db>, } @@ -286,15 +286,7 @@ impl<'db> DynamicNamedTupleLiteral<'db> { } let field_types = self.fields(db).iter().map(|field| field.ty); - TupleType::heterogeneous(db, env, field_types) - .map(|tuple| tuple.to_class_type(db)) - .unwrap_or_else(|| { - KnownClass::Tuple - .to_class_literal(db, env) - .as_class_literal() - .expect("tuple should be a class literal") - .default_specialization(db) - }) + TupleType::heterogeneous(db, env, field_types).to_class_type(db) } /// Look up an instance member defined directly on this class (not inherited). @@ -525,8 +517,8 @@ pub enum DynamicNamedTupleAnchor<'db> { /// We're dealing with a `namedtuple()` or `NamedTuple` call that is /// "dangling" (not assigned to a variable). /// - /// The offset is relative to the enclosing scope's anchor node index. - /// For module scope, this is equivalent to an absolute index (anchor is 0). + /// The [`DynamicClassScopeOffset`] locates the call relative to the enclosing scope, + /// including when the call is inside a string annotation. /// /// Dangling calls can always store the spec. They *can* contain /// forward references if they appear in class bases: @@ -542,7 +534,7 @@ pub enum DynamicNamedTupleAnchor<'db> { /// entirety during type inference. ScopeOffset { scope: ScopeId<'db>, - offset: u32, + offset: DynamicClassScopeOffset, spec: NamedTupleSpec<'db>, }, } diff --git a/crates/ty_python_semantic/src/types/class/slots.rs b/crates/ty_python_semantic/src/types/class/slots.rs new file mode 100644 index 0000000000..a3f813f2e8 --- /dev/null +++ b/crates/ty_python_semantic/src/types/class/slots.rs @@ -0,0 +1,573 @@ +use itertools::Itertools; +use ruff_db::parsed::parsed_module; +use ruff_python_ast::{self as ast, PythonVersion, name::Name}; +use ty_python_core::{place_table, use_def_map}; + +use crate::place::{DefinedPlace, Definedness, Place, place_from_bindings}; +use crate::types::class::{CodeGeneratorKind, StaticClassLiteral}; +use crate::types::generics::Specialization; +use crate::types::{ + ClassBase, ClassLiteral, DataclassFlags, KnownClass, SpecialFormType, Type, + definition_expression_type, tuple::Tuple, +}; +use crate::{Db, FxIndexSet, ProgramEnvironment}; + +/// The information that can be recovered from a class's own `__slots__` assignment. +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +enum SlotDefinition { + /// Every declared slot name is statically known. + Names(Box<[Name]>), + /// The declaration is definitely nonempty, but at least one name is unknown. + NonEmpty, + /// The class has no slot declaration, or its declaration cannot be resolved statically. + DynamicOrNone, +} + +/// An interpreter-created `types.MemberDescriptorType` for an instance slot. +/// +/// Its `__get__` and `__set__` methods access the memory reserved for the slot in each instance, +/// without invoking the Python-level getter, setter, or deleter callbacks used by a `property`. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub struct SlotDescriptorType<'db> { + #[returns(copy)] + pub(crate) value_type: Type<'db>, +} + +impl get_size2::GetSize for SlotDescriptorType<'_> {} + +/// Whether instances can store attributes in an ordinary instance dictionary. +/// +/// Ordinary Python classes provide this storage, while classes that use slots throughout their +/// inheritance chain can omit it. A slotted class can inherit an instance dictionary from a base +/// class or request one explicitly: +/// +/// ```python +/// class Slotted: +/// __slots__ = ("value",) +/// +/// class WithDictionary(Slotted): +/// __slots__ = ("__dict__",) +/// ``` +/// +/// This describes `instance.__dict__`, not `Class.__dict__`: the class's own namespace remains +/// available regardless of its instance layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +enum InstanceDictionary { + /// Instances definitely have dictionary-backed attribute storage. + Present, + /// Instances definitely lack dictionary-backed attribute storage. + Absent, + /// A base class or dynamic slot declaration prevents determining the instance layout. + /// + /// Unknown storage remains permissive when checking attribute access and assignment. + Unknown, +} + +impl InstanceDictionary { + /// Classify interpreter-managed storage that cannot be recovered from stub declarations. + fn for_known_class(class: KnownClass) -> Option { + match class { + KnownClass::Object + | KnownClass::Bool + | KnownClass::Bytes + | KnownClass::Bytearray + | KnownClass::Memoryview + | KnownClass::Int + | KnownClass::Float + | KnownClass::Complex + | KnownClass::Str + | KnownClass::List + | KnownClass::Tuple + | KnownClass::Range + | KnownClass::Set + | KnownClass::FrozenSet + | KnownClass::Dict + | KnownClass::Slice + | KnownClass::Property + | KnownClass::Super + | KnownClass::GenericAlias + | KnownClass::MethodType + | KnownClass::MethodWrapperType + | KnownClass::WrapperDescriptorType + | KnownClass::MemberDescriptorType + | KnownClass::GetSetDescriptorType + | KnownClass::UnionType + | KnownClass::GeneratorType + | KnownClass::AsyncGeneratorType + | KnownClass::CoroutineType + | KnownClass::NotImplementedType + | KnownClass::BuiltinFunctionType + | KnownClass::EllipsisType + | KnownClass::NoneType => Some(Self::Absent), + // Typeshed adds these abstract bases to builtin sequences and mappings even though + // they do not occur in their runtime inheritance chains or provide instance storage. + KnownClass::Sequence | KnownClass::Mapping | KnownClass::MutableMapping => { + Some(Self::Absent) + } + // This synthetic base supplies named-tuple members without changing instance layouts. + KnownClass::NamedTupleFallback => Some(Self::Absent), + KnownClass::Type + | KnownClass::BaseException + | KnownClass::Exception + | KnownClass::Warning + | KnownClass::NotImplementedError + | KnownClass::BaseExceptionGroup + | KnownClass::ExceptionGroup + | KnownClass::Staticmethod + | KnownClass::Classmethod + | KnownClass::ModuleType + | KnownClass::FunctionType => Some(Self::Present), + KnownClass::Enum + | KnownClass::EnumProperty + | KnownClass::EnumType + | KnownClass::Auto + | KnownClass::Member + | KnownClass::Nonmember + | KnownClass::StrEnum + | KnownClass::IntEnum + | KnownClass::Flag + | KnownClass::IntFlag + | KnownClass::ABCMeta + | KnownClass::SupportsKeysAndGetItem + | KnownClass::Awaitable + | KnownClass::Generator + | KnownClass::AsyncGenerator + | KnownClass::Deprecated + | KnownClass::StdlibAlias + | KnownClass::SpecialForm + | KnownClass::TypeVar + | KnownClass::ParamSpec + | KnownClass::ExtensionsParamSpec + | KnownClass::ParamSpecArgs + | KnownClass::ParamSpecKwargs + | KnownClass::ProtocolMeta + | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple + | KnownClass::TypeAliasType + | KnownClass::ExtensionsTypeAliasType + | KnownClass::NoDefaultType + | KnownClass::NewType + | KnownClass::Hashable + | KnownClass::SupportsIndex + | KnownClass::Iterable + | KnownClass::Iterator + | KnownClass::AsyncIterator + | KnownClass::ExtensionsTypeVar + | KnownClass::ExtensionTypedDictFallback + | KnownClass::Sentinel + | KnownClass::ChainMap + | KnownClass::Counter + | KnownClass::DefaultDict + | KnownClass::Deque + | KnownClass::OrderedDict + | KnownClass::VersionInfo + | KnownClass::Field + | KnownClass::KwOnly + | KnownClass::NamedTupleLike + | KnownClass::TypedDictFallback + | KnownClass::Template + | KnownClass::Path + | KnownClass::FunctoolsPartial + | KnownClass::ConstraintSet + | KnownClass::ConstraintSetSolution + | KnownClass::GenericContext + | KnownClass::Specialization + | KnownClass::TyExtensionsAsyncIterable + | KnownClass::TyExtensionsAsyncIterator + | KnownClass::TyExtensionsIterable + | KnownClass::TyExtensionsIterator + | KnownClass::UnittestTestCase + | KnownClass::PydanticBaseModel + | KnownClass::PydanticBaseSettings + | KnownClass::PydanticConfigDict + | KnownClass::PydanticRootModel + | KnownClass::PydanticStrict + | KnownClass::PytestParametrizeMarkDecorator + | KnownClass::AssertionError + | KnownClass::RuntimeError + | KnownClass::ReMatch + | KnownClass::RePattern + | KnownClass::ByStaticProperty + | KnownClass::DjangoModel + | KnownClass::DjangoField + | KnownClass::DjangoForeignKey + | KnownClass::DjangoOneToOneField + | KnownClass::DjangoManyToManyField + | KnownClass::DjangoManager + | KnownClass::DjangoQuerySet + | KnownClass::SqlalchemyDeclarativeBase + | KnownClass::SqlalchemyMappedAsDataclass + | KnownClass::SqlalchemyMapped + | KnownClass::Character => None, + } + } + + /// Combine two base layouts while preserving any definitely present dictionary. + fn inherited_with(self, other: Self) -> Self { + match (self, other) { + (Self::Present, _) | (_, Self::Present) => Self::Present, + (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown, + (Self::Absent, Self::Absent) => Self::Absent, + } + } +} + +/// The slots and dictionary storage inherited by instances of a class. +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] +struct InstanceLayout { + slots: Box<[Name]>, + dictionary: InstanceDictionary, +} + +impl InstanceLayout { + fn unknown() -> Self { + Self { + slots: Box::default(), + dictionary: InstanceDictionary::Unknown, + } + } +} + +#[salsa::tracked] +impl<'db> StaticClassLiteral<'db> { + /// Returns whether this class body explicitly defines `__slots__`. + pub(crate) fn has_explicit_slots(self, db: &'db dyn Db) -> bool { + self.has_own_class_binding(db, "__slots__") + } + + /// Returns whether a binding for this name reaches the end of the class body. + pub(super) fn has_own_class_binding(self, db: &'db dyn Db, name: &str) -> bool { + let scope = self.body_scope(db); + place_table(db, scope) + .symbol_id(name) + .is_some_and(|symbol| { + use_def_map(db, scope) + .end_of_scope_symbol_bindings(symbol) + .any(|binding| binding.binding.definition().is_some()) + }) + } + + /// Returns this class's explicit or generated slot names when they are statically known. + /// + /// Inherited slots are excluded; callers that need the complete layout should use + /// [`Self::has_instance_slot`]. A dynamic declaration returns `None` rather than guessing. + pub(crate) fn slot_names(self, db: &'db dyn Db) -> Option<&'db [Name]> { + if !self.has_explicit_slots(db) && !self.has_generated_slots(db) { + return None; + } + + match self.slot_definition(db) { + SlotDefinition::Names(names) => Some(names), + SlotDefinition::NonEmpty | SlotDefinition::DynamicOrNone => None, + } + } + + /// Returns whether this class definitely introduces at least one instance slot. + pub(super) fn has_nonempty_slots(self, db: &'db dyn Db) -> bool { + (self.has_explicit_slots(db) || self.has_generated_slots(db)) + && match self.slot_definition(db) { + SlotDefinition::Names(names) => !names.is_empty(), + SlotDefinition::NonEmpty => true, + SlotDefinition::DynamicOrNone => false, + } + } + + /// Returns whether this class synthesizes slots through a dataclass or named tuple. + pub(super) fn has_generated_slots(self, db: &'db dyn Db) -> bool { + self.dataclass_params(db).is_some_and(|parameters| { + parameters.flags(db).contains(DataclassFlags::SLOTS) + && ProgramEnvironment::from_scope(self.body_scope(db)).python_version(db) + >= PythonVersion::PY310 + }) || self.has_named_tuple_slots(db) + } + + /// Returns whether this class directly inherits the synthesized named-tuple layout. + fn has_named_tuple_slots(self, db: &'db dyn Db) -> bool { + self.has_explicit_bases(db) + && self + .explicit_bases(db) + .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)) + } + + /// Resolves explicit slots, empty named-tuple layouts, and slotted dataclass fields. + /// + /// Tuple and string values retain their inferred literal types; mutable list, set, and + /// dictionary literals are resolved from the indexed reaching assignment. + #[salsa::tracked( + returns(ref), + cycle_initial=|_, _, _| SlotDefinition::DynamicOrNone, + heap_size=ruff_memory_usage::heap_size, + )] + fn slot_definition(self, db: &'db dyn Db) -> SlotDefinition { + let body_scope = self.body_scope(db); + // A bare annotation does not bind `__slots__`, but an annotated assignment does: + // + // __slots__: tuple[str, ...] + // __slots__: tuple[str, ...] = ("value",) + let Some(symbol) = place_table(db, body_scope) + .symbol_id("__slots__") + .filter(|_| self.has_explicit_slots(db)) + else { + if self.has_named_tuple_slots(db) { + return SlotDefinition::Names(Box::default()); + } + + if !self.has_generated_slots(db) { + return SlotDefinition::DynamicOrNone; + } + + // Dataclasses generate slots for their fields, excluding inherited storage: + // + // class Base: + // __slots__ = ("inherited",) + // + // @dataclass(slots=True, weakref_slot=True) + // class Child(Base): + // inherited: int + // value: int + // + // Here, `Child.__slots__` contains only `value` and `__weakref__`. + let field_policy = CodeGeneratorKind::DataclassLike(None); + let inherited_slots: FxIndexSet<_> = self + .iter_mro(db, None) + .skip(1) + .filter_map(ClassBase::into_class) + .filter_map(|class| class.static_class_literal(db).map(|(class, _)| class)) + .filter_map(|class| class.slot_names(db)) + .flatten() + .cloned() + .collect(); + let weakref_name = Name::new_static("__weakref__"); + let mut names: Vec<_> = self + .fields(db, None, field_policy) + .keys() + .filter(|name| !inherited_slots.contains(*name)) + .cloned() + .collect(); + if self.has_dataclass_param(db, field_policy, DataclassFlags::WEAKREF_SLOT) + && !inherited_slots.contains(&weakref_name) + { + names.push(weakref_name); + } + return SlotDefinition::Names(names.into_boxed_slice()); + }; + + // A conditional assignment does not establish one definite layout: + // + // if condition: + // __slots__ = ("value",) + let env = ProgramEnvironment::from_scope(body_scope); + let use_def = use_def_map(db, body_scope); + let bindings = use_def.end_of_scope_symbol_bindings(symbol); + let Place::Defined(DefinedPlace { + ty: slots_ty, + definedness: Definedness::AlwaysDefined, + .. + }) = place_from_bindings(db, &env, bindings).place + else { + return SlotDefinition::DynamicOrNone; + }; + + // A single string is itself a slot name: `__slots__ = "value"`. + if let Some(name) = slots_ty.as_string_literal() { + return SlotDefinition::Names(Box::new([Name::new(name.value(db))])); + } + + // Tuple inference preserves individual names, including names supplied indirectly: + // + // names = ("first", "second") + // __slots__ = names + // + // An unknown element prevents recovering every name. A variable-length tuple still + // proves the declaration is nonempty when its minimum length is greater than zero. + if let Some(tuple) = slots_ty.tuple_instance_spec(db, &env) { + match &*tuple { + Tuple::Fixed(tuple) => { + return tuple + .iter_all_elements() + .map(|element| { + element + .as_string_literal() + .map(|literal| Name::new(literal.value(db))) + }) + .collect::>>() + .map_or(SlotDefinition::NonEmpty, SlotDefinition::Names); + } + Tuple::Variable(_) if tuple.len().minimum() > 0 => { + return SlotDefinition::NonEmpty; + } + Tuple::Variable(_) => {} + } + } + + // Mutable container types do not retain their individual literal elements: + // + // __slots__ = ["value"] + // __slots__ = {"value"} + // __slots__ = {"value": "Documentation"} + // + // Recover each element's inferred string-literal type from the single reaching class-body + // assignment instead, so names supplied through other variables are also recognized. + let Ok(definition) = use_def + .end_of_scope_symbol_bindings(symbol) + .filter_map(|binding| binding.binding.definition()) + .exactly_one() + else { + return SlotDefinition::DynamicOrNone; + }; + + let parsed = parsed_module(db, self.python_file(db)).load(db); + let Some(value) = definition.kind(db).value(&parsed) else { + return SlotDefinition::DynamicOrNone; + }; + + let literal_slot_name = |expression: &ast::Expr| { + definition_expression_type(db, definition, expression) + .as_string_literal() + .map(|literal| Name::new(literal.value(db))) + }; + + let names = match value { + ast::Expr::List(list) => list.elts.iter().map(literal_slot_name).collect(), + ast::Expr::Set(set) => set.elts.iter().map(literal_slot_name).collect(), + ast::Expr::Dict(dictionary) => dictionary + .items + .iter() + .map(|item| item.key.as_ref().and_then(literal_slot_name)) + .collect(), + _ => None, + }; + + names.map_or(SlotDefinition::DynamicOrNone, SlotDefinition::Names) + } + + /// Collects slot storage and instance-dictionary availability across the complete MRO. + /// + /// ```python + /// class Base: + /// __slots__ = ("value",) + /// + /// class Child(Base): + /// __slots__ = ("other", "__dict__") + /// ``` + /// + /// Here, `Child` has both slots and can also store additional dictionary-backed attributes. + #[salsa::tracked( + returns(ref), + cycle_initial=|_, _, _| InstanceLayout::unknown(), + heap_size=ruff_memory_usage::heap_size, + )] + fn instance_layout(self, db: &'db dyn Db) -> InstanceLayout { + if self.is_protocol(db) { + return InstanceLayout::unknown(); + } + + let mut slots = FxIndexSet::default(); + let mut dictionary = InstanceDictionary::Absent; + + for base in self.iter_mro(db, None) { + let base = match base { + ClassBase::Class(base) => base, + ClassBase::Any | ClassBase::Divergent(_) | ClassBase::Dynamic(_) => { + dictionary = dictionary.inherited_with(InstanceDictionary::Unknown); + continue; + } + ClassBase::TypedDict(_) | ClassBase::Generic | ClassBase::Protocol => continue, + }; + + let base = match base.class_literal(db) { + ClassLiteral::Static(base) => base, + // Functional named tuples synthesize empty slots, while TypedDict instances use + // dictionary item storage rather than an instance-attribute dictionary. + ClassLiteral::DynamicNamedTuple(_) | ClassLiteral::DynamicTypedDict(_) => continue, + // Enum instances retain an instance dictionary even when the enum is created + // through the functional API. + ClassLiteral::DynamicEnum(_) => { + dictionary = InstanceDictionary::Present; + continue; + } + ClassLiteral::Dynamic(_) => { + dictionary = dictionary.inherited_with(InstanceDictionary::Unknown); + continue; + } + }; + + if let Some(names) = base.slot_names(db) { + if names.iter().any(|name| name == "__dict__") { + dictionary = InstanceDictionary::Present; + } + slots.extend(names.iter().cloned()); + } else if base.has_explicit_slots(db) { + dictionary = dictionary.inherited_with(InstanceDictionary::Unknown); + } else if let Some(known_dictionary) = + base.known(db).and_then(InstanceDictionary::for_known_class) + { + dictionary = dictionary.inherited_with(known_dictionary); + } else if !base.is_protocol(db) { + dictionary = InstanceDictionary::Present; + } + } + + InstanceLayout { + slots: slots.into_iter().collect(), + dictionary, + } + } + + /// Returns whether instance dictionary storage exists or cannot be ruled out. + fn has_instance_dictionary(self, db: &'db dyn Db) -> bool { + if !self.has_explicit_slots(db) && !self.has_generated_slots(db) && self.known(db).is_none() + { + return true; + } + + self.instance_layout(db).dictionary != InstanceDictionary::Absent + } + + /// Returns whether this class or any base defines a slot with the given name. + pub(crate) fn has_instance_slot(self, db: &'db dyn Db, name: &str) -> bool { + self.instance_layout(db) + .slots + .iter() + .any(|slot| slot == name) + } + + /// Whether a known slotted layout has no instance storage available for `name`. + /// + /// An unknown layout remains permissive, as do builtins whose C-level storage is not fully + /// described by their stubs. + pub(crate) fn lacks_instance_storage(self, db: &'db dyn Db, name: &str) -> bool { + self.slot_names(db).is_some() + && !self.has_instance_slot(db, name) + && !self.has_instance_dictionary(db) + } + + /// Synthesizes the class descriptor created for an instance slot. + /// + /// ```python + /// class Example: + /// __slots__ = ("value", "__weakref__") + /// ``` + /// + /// Ordinary slots use `MemberDescriptorType` descriptors. The weak-reference slot uses the + /// `GetSetDescriptorType` descriptor declared in typeshed. + pub(super) fn own_slot_descriptor( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + specialization: Option>, + name: &str, + ) -> Type<'db> { + if name == "__weakref__" { + return KnownClass::GetSetDescriptorType.to_instance(db, env); + } + + let value_ty = self + .own_instance_member(db, env, name) + .ignore_possibly_undefined() + .map(|ty| ty.apply_optional_specialization(db, specialization)) + .unwrap_or_else(Type::unknown); + + Type::SlotDescriptor(SlotDescriptorType::new(db, value_ty)) + } +} diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 4e3e0a0b47..317db9c390 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -12,30 +12,30 @@ use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::cell::RefCell; +use super::implicit_attributes::implicit_attribute_names; use crate::{ Db, FxIndexMap, FxIndexSet, TypeQualifiers, place::{ - DefinedPlace, Definedness, Place, PlaceAndQualifiers, Provenance, PublicTypePolicy, - TypeOrigin, place_from_bindings, place_from_declarations, - }, - reachability::{ - DeclarationsIteratorExtension, ReachabilityConstraintsExtension, binding_reachability, + ConsideredDefinitions, DefinedPlace, Definedness, Place, PlaceAndQualifiers, + PublicTypePolicy, RequiresExplicitReExport, TypeOrigin, place_by_id, place_from_bindings, + place_from_declarations, }, + reachability::{DeclarationsIteratorExtension, ReachabilityConstraintsExtension}, types::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, CallArguments, CallableType, ClassBase, ClassLiteral, ClassType, DATACLASS_FLAGS, DataclassFlags, DataclassParams, EnumLiteralType, GenericAlias, GenericContext, KnownClass, KnownInstanceType, MaterializationKind, MemberLookupPolicy, MetaclassCandidate, MetaclassTransformInfo, Parameter, Parameters, PropertyInstanceType, Signature, - SpecialFormType, StaticMroError, SubclassOfType, Truthiness, Type, TypeContext, - TypeMapping, TypeVarVariance, TypedDictModule, UnionBuilder, UnionType, binding_type, + SpecialFormType, StaticMroError, SubclassOfType, Type, TypeContext, TypeMapping, + TypeVarVariance, TypingModule, UnionBuilder, UnionType, binding_type, bound_super::BoundSuperType, call::{CallError, CallErrorKind}, - callable::{CallableFunctionProvenance, CallableTypeKind}, + callable::CallableTypeKind, class::{ - ClassInstanceFlags, ClassMemberResult, CodeGeneratorKind, DisjointBase, + ClassInstanceFlags, ClassMemberResult, ClassMetaclass, CodeGeneratorKind, DisjointBase, DynamicTypedDictLiteral, Field, FieldKind, InstanceMemberResult, MetaclassError, - MetaclassErrorKind, MethodDecorator, MroLookup, NamedTupleField, SlotsKind, + MetaclassErrorKind, MethodDecorator, MroLookup, NamedTupleField, synthesize_namedtuple_class_member, typed_dict::{TypedDictFields, synthesize_typed_dict_method, typed_dict_class_member}, }, @@ -44,32 +44,26 @@ use crate::{ definition_expression_type, determine_upper_bound, diagnostic::INVALID_DATACLASS_OVERRIDE, enums::{enum_metadata, is_enum_class_by_inheritance, try_unwrap_nonmember_value}, - function::{ - DataclassTransformerParams, KnownFunction, is_implicit_classmethod, - is_implicit_staticmethod, - }, + function::{DataclassTransformerParams, KnownFunction}, generics::Specialization, - infer::{infer_definition_types, infer_unpack_types, original_class_type}, - infer_expression_type, inferred_declaration, + infer::original_class_type, + inferred_declaration, known_instance::{DeprecatedInstance, FieldInstance}, member::{Member, class_member}, mro::{Mro, MroIterator}, signatures::CallableSignature, tuple::{FixedLengthTuple, Tuple}, typed_dict::{TypedDictParams, TypedDictType, typed_dict_params_from_class_def}, - variance::VarianceInferable, + variance::{VarianceInferable, VarianceOrigin, VarianceTerm}, visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}, }, }; -use crate::{attribute_assignments, attribute_declarations}; use ty_python_core::{ ProgramFile, attribute_scopes, - definition::{Definition, DefinitionKind, DefinitionState, TargetKind}, + definition::{Definition, DefinitionKind, DefinitionState}, place_table, - scope::{Scope, ScopeId}, - semantic_index, - symbol::Symbol, - use_def_map, + scope::ScopeId, + semantic_index, use_def_map, }; /// Representation of a class definition statement in the AST: either a non-generic class, or a @@ -151,11 +145,11 @@ impl<'db> StaticClassLiteral<'db> { self.flags(db).contains(ClassLiteralFlags::SEALED) } - pub(crate) fn has_decorators(self, db: &'db dyn Db) -> bool { + fn has_decorators(self, db: &'db dyn Db) -> bool { self.flags(db).contains(ClassLiteralFlags::HAS_DECORATORS) } - pub(crate) fn has_type_params(self, db: &'db dyn Db) -> bool { + fn has_type_params(self, db: &'db dyn Db) -> bool { self.flags(db).contains(ClassLiteralFlags::HAS_TYPE_PARAMS) } @@ -597,7 +591,7 @@ impl<'db> StaticClassLiteral<'db> { } /// Returns the generic context that should be inherited by any constructor methods of this class. - pub(super) fn inherited_generic_context(self, db: &'db dyn Db) -> Option> { + fn inherited_generic_context(self, db: &'db dyn Db) -> Option> { self.generic_context(db) } @@ -859,7 +853,7 @@ impl<'db> StaticClassLiteral<'db> { && !self.is_protocol(db) { Some(DisjointBase::due_to_decorator(self)) - } else if SlotsKind::from(db, self) == SlotsKind::NotEmpty { + } else if self.has_nonempty_slots(db) { Some(DisjointBase::due_to_dunder_slots(ClassLiteral::Static( self, ))) @@ -868,17 +862,16 @@ impl<'db> StaticClassLiteral<'db> { } } - /// Iterate over this class's explicit bases, resolving them in the same way as MRO - /// construction, filtering out any bases that are not fully static class objects. - fn fully_static_explicit_bases(self, db: &'db dyn Db) -> impl Iterator> { + /// Iterate over the explicit bases that contribute to metaclass selection. + fn metaclass_bases(self, db: &'db dyn Db) -> impl Iterator> { let env = ProgramEnvironment::from_scope(self.body_scope(db)); self.explicit_bases(db) .iter() .copied() .filter_map(move |ty| { ClassBase::try_from_type(db, &env, ty, Some(ClassLiteral::Static(self))) - .and_then(ClassBase::into_class) }) + .filter(|base| matches!(base, ClassBase::Class(_) | ClassBase::Protocol)) } /// Determine if this class is a protocol. @@ -1210,7 +1203,7 @@ impl<'db> StaticClassLiteral<'db> { /// Return the module defining the `TypedDict` base of this class. #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] - pub(crate) fn typed_dict_module(self, db: &'db dyn Db) -> Option { + pub(crate) fn typed_dict_module(self, db: &'db dyn Db) -> Option { self.iter_mro(db, None) .find_map(ClassBase::typed_dict_module) } @@ -1398,16 +1391,22 @@ impl<'db> StaticClassLiteral<'db> { /// Return the metaclass of this class, or `type[Unknown]` if the metaclass cannot be inferred. pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { + let env = ProgramEnvironment::from_scope(self.body_scope(db)); + self.inferred_metaclass(db).to_type(db, &env) + } + + pub(in crate::types) fn inferred_metaclass(self, db: &'db dyn Db) -> ClassMetaclass<'db> { self.try_metaclass(db) - .map(|(ty, _)| ty) - .unwrap_or_else(|_| SubclassOfType::subclass_of_unknown()) + .map(|(metaclass, _)| metaclass) + .unwrap_or_else(|_| ClassMetaclass::Selected(SubclassOfType::subclass_of_unknown())) } - /// Return the metaclass of this class, or an error if the metaclass cannot be inferred. + /// Return the selected metaclass or protocol fallback, or an error if it cannot be inferred. pub(in crate::types) fn try_metaclass( self, db: &'db dyn Db, - ) -> Result<(Type<'db>, Option>), MetaclassError<'db>> { + ) -> Result<(ClassMetaclass<'db>, Option>), MetaclassError<'db>> + { #[salsa::tracked( returns(clone), cycle_initial=|_, _, _| Err(MetaclassError { @@ -1418,23 +1417,25 @@ impl<'db> StaticClassLiteral<'db> { fn try_metaclass_inner<'db>( db: &'db dyn Db, class: StaticClassLiteral<'db>, - ) -> Result<(Type<'db>, Option>), MetaclassError<'db>> { + ) -> Result<(ClassMetaclass<'db>, Option>), MetaclassError<'db>> + { let program_file = class.program_file(db); let python_file = program_file.python_file(db); let env = ProgramEnvironment::from_file(program_file); tracing::trace!("StaticClassLiteral::try_metaclass: {}", class.name(db)); // Identify the class's own metaclass (or take the first base class's metaclass). - let mut base_classes = class.fully_static_explicit_bases(db).peekable(); + let mut base_classes = class.metaclass_bases(db).peekable(); - if base_classes.peek().is_some() && class.inheritance_cycle(db).is_some() { + if (base_classes.peek().is_some() && class.inheritance_cycle(db).is_some()) + || class.try_mro(db, None).is_err_and(StaticMroError::is_cycle) + { // We emit diagnostics for cyclic class definitions elsewhere. // Avoid attempting to infer the metaclass if the class is cyclically defined. - return Ok((SubclassOfType::subclass_of_unknown(), None)); - } - - if class.try_mro(db, None).is_err_and(StaticMroError::is_cycle) { - return Ok((SubclassOfType::subclass_of_unknown(), None)); + return Ok(( + ClassMetaclass::Selected(SubclassOfType::subclass_of_unknown()), + None, + )); } let module = parsed_module(db, python_file).load(db); @@ -1457,25 +1458,28 @@ impl<'db> StaticClassLiteral<'db> { } } - let (metaclass, class_metaclass_was_from) = if let Some(metaclass) = explicit_metaclass - { - (metaclass, class) - } else if let Some(base_class) = base_classes.next() { - // For dynamic classes, we can't get a StaticClassLiteral, so use this class for - // tracking. - let base_class_literal = base_class - .static_class_literal(db) - .map(|(lit, _)| lit) - .unwrap_or(class); - (base_class.metaclass(db), base_class_literal) + let mut has_protocol_fallback = false; + let mut base_metaclasses = base_classes.filter_map(|base| { + match base.inferred_metaclass(db, &env, ClassLiteral::Static(class)) { + ClassMetaclass::Selected(metaclass) => Some((base, metaclass)), + ClassMetaclass::ProtocolFallback => { + has_protocol_fallback = true; + None + } + } + }); + let (metaclass, base) = if let Some(metaclass) = explicit_metaclass { + (metaclass, None) + } else if let Some((base_class, metaclass)) = base_metaclasses.next() { + (metaclass, Some(base_class)) } else { - (KnownClass::Type.to_class_literal(db, &env), class) + (KnownClass::Type.to_class_literal(db, &env), None) }; let mut candidate = if let Some(metaclass_ty) = metaclass.to_class_type(db) { MetaclassCandidate { metaclass: metaclass_ty, - explicit_metaclass_of: class_metaclass_was_from, + base, } } else { let name = Type::string_literal(db, class.name(db)); @@ -1507,7 +1511,8 @@ impl<'db> StaticClassLiteral<'db> { }), }; - return return_ty_result.map(|ty| (ty.to_meta_type(db, &env), None)); + return return_ty_result + .map(|ty| (ClassMetaclass::Selected(ty.to_meta_type(db, &env)), None)); }; // Reconcile all base classes' metaclasses with the candidate metaclass. @@ -1515,35 +1520,25 @@ impl<'db> StaticClassLiteral<'db> { // See: // - https://docs.python.org/3/reference/datamodel.html#determining-the-appropriate-metaclass // - https://github.com/python/cpython/blob/83ba8c2bba834c0b92de669cac16fcda17485e0e/Objects/typeobject.c#L3629-L3663 - for base_class in base_classes { - let metaclass = base_class.metaclass(db); + for (base_class, metaclass) in base_metaclasses { let Some(metaclass) = metaclass.to_class_type(db) else { continue; }; - // For dynamic classes, we can't get a StaticClassLiteral, so use this class for - // tracking. - let base_class_literal = base_class - .static_class_literal(db) - .map(|(lit, _)| lit) - .unwrap_or(class); + if candidate.metaclass.is_subclass_of(db, &env, metaclass) { + continue; + } if metaclass.is_subclass_of(db, &env, candidate.metaclass) { candidate = MetaclassCandidate { metaclass, - explicit_metaclass_of: base_class_literal, + base: Some(base_class), }; continue; } - if candidate.metaclass.is_subclass_of(db, &env, metaclass) { - continue; - } return Err(MetaclassError { kind: MetaclassErrorKind::Conflict { - candidate1: candidate, - candidate2: MetaclassCandidate { - metaclass, - explicit_metaclass_of: base_class_literal, - }, - candidate1_is_base_class: explicit_metaclass.is_none(), + candidate, + base_metaclass: metaclass, + base: base_class, }, }); } @@ -1556,14 +1551,28 @@ impl<'db> StaticClassLiteral<'db> { }) .map(|params| MetaclassTransformInfo { params, - from_explicit_metaclass: candidate.explicit_metaclass_of == class, + from_explicit_metaclass: candidate.base.is_none(), }); - Ok((candidate.metaclass.into(), transform_info)) + let use_protocol_fallback = has_protocol_fallback + && !class + .known(db) + .is_some_and(|known| known.has_known_type_metaclass(env.python_version(db))); + Ok(( + ClassMetaclass::with_protocol_fallback( + db, + candidate.metaclass.into(), + use_protocol_fallback, + ), + transform_info, + )) } if !self.has_explicit_bases(db) && !self.has_explicit_metaclass(db) { let env = ProgramEnvironment::from_scope(self.body_scope(db)); - return Ok((KnownClass::Type.to_class_literal(db, &env), None)); + return Ok(( + ClassMetaclass::Selected(KnownClass::Type.to_class_literal(db, &env)), + None, + )); } try_metaclass_inner(db, self) } @@ -1591,7 +1600,40 @@ impl<'db> StaticClassLiteral<'db> { name: &str, policy: MemberLookupPolicy, ) -> PlaceAndQualifiers<'db> { - self.class_member_from_mro(db, env, name, policy, self.iter_mro(db, specialization)) + // An unspecialized MRO retains mappings such as `Parent[T@Child]`, so ordinary members + // accessed through `Child` must use its default arguments. Constructor methods are different: + // we add their class's type variables to the callable's generic context, so those variables + // are genuinely inferable and must remain generic instead of using the default arguments. + if specialization.is_none() + && let Some(generic_context) = self.generic_context(db) + { + match name { + "__new__" | "__init__" => { + // Specifically apply the identity specialization; otherwise `iter_mro` will + // apply the default specialization for us. + let specialization = generic_context.identity_specialization(db); + self.class_member_from_mro( + db, + env, + name, + policy, + self.iter_mro(db, Some(specialization)), + ) + } + _ => { + let member = + self.class_member_from_mro(db, env, name, policy, self.iter_mro(db, None)); + let specialization = generic_context.default_specialization(db, self.known(db)); + // An inherited method's `Self` bound can still contain this class's type + // variables, so the default arguments must also specialize that bound. + member.map_type(|ty| { + ty.apply_optional_owner_specialization_to_member(db, Some(specialization)) + }) + } + } + } else { + self.class_member_from_mro(db, env, name, policy, self.iter_mro(db, specialization)) + } } pub(crate) fn class_member_from_mro( @@ -1762,7 +1804,27 @@ impl<'db> StaticClassLiteral<'db> { } }); - if member.is_undefined() { + // The inherited `object.__dict__` annotation already describes dictionary access. A + // synthesized slot descriptor would incorrectly replace the class's own namespace. + if name != "__dict__" + && self + .slot_names(db) + .is_some_and(|slots| slots.iter().any(|slot| slot == name)) + && (self.has_generated_slots(db) + || !self.has_own_class_binding(db, name) + || self.file(db).is_stub(db) && self.has_instance_slot(db, name)) + { + return Member::definitely_declared(self.own_slot_descriptor( + db, + env, + specialization, + name, + )); + } + + if member.is_undefined() + || name == "__slots__" && self.has_generated_slots(db) && !self.has_explicit_slots(db) + { if let Some(synthesized_member) = self.own_synthesized_member( db, env, @@ -1773,7 +1835,7 @@ impl<'db> StaticClassLiteral<'db> { return Member::definitely_declared(synthesized_member); } // The symbol was not found in the class scope. It might still be implicitly defined in `@classmethod`s. - return Self::implicit_attribute(db, body_scope, name, MethodDecorator::ClassMethod); + return self.implicit_attribute(db, name, MethodDecorator::ClassMethod); } // For dataclass-like classes, `KW_ONLY` sentinel fields are not real @@ -1869,12 +1931,7 @@ impl<'db> StaticClassLiteral<'db> { ) }), ); - CallableType::new( - db, - signatures, - CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, - ) + CallableType::new(db, signatures, CallableTypeKind::FunctionLike) }); return Some(synthesized_callables.into_type(db, env)); @@ -2342,24 +2399,6 @@ impl<'db> StaticClassLiteral<'db> { .map(|(name, _)| Type::string_literal(db, name)); Some(Type::heterogeneous_tuple(db, env, match_args)) } - (field_policy @ CodeGeneratorKind::DataclassLike(_), "__weakref__") - if env.python_version(db) >= PythonVersion::PY311 => - { - if !self.has_dataclass_param(db, field_policy, DataclassFlags::WEAKREF_SLOT) - || !self.has_dataclass_param(db, field_policy, DataclassFlags::SLOTS) - { - return None; - } - - // This could probably be `weakref | None`, but it does not seem important enough to - // model it precisely. - Some(UnionType::from_two_elements( - db, - env, - Type::any(), - Type::none(db, env), - )) - } (CodeGeneratorKind::NamedTuple, name) if name != "__init__" => { KnownClass::NamedTupleFallback .to_class_literal(db, env) @@ -2375,7 +2414,7 @@ impl<'db> StaticClassLiteral<'db> { new_upper_bound: determine_upper_bound( db, env, - ClassLiteral::Static(self), + self.apply_optional_specialization(db, specialization), |base| { base.into_class() .is_some_and(|c| c.is_known(db, KnownClass::Tuple)) @@ -2456,7 +2495,6 @@ impl<'db> StaticClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, ))); } } @@ -2481,6 +2519,14 @@ impl<'db> StaticClassLiteral<'db> { { self.has_dataclass_param(db, field_policy, DataclassFlags::SLOTS) .then(|| { + if let Some(slots) = self.slot_names(db) { + return Type::heterogeneous_tuple( + db, + env, + slots.iter().map(|name| Type::string_literal(db, name)), + ); + } + let fields = self.fields(db, specialization, field_policy); let slots = fields.keys().map(|name| Type::string_literal(db, name)); Type::heterogeneous_tuple(db, env, slots) @@ -2583,7 +2629,6 @@ impl<'db> StaticClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, ))) } @@ -2719,12 +2764,7 @@ impl<'db> StaticClassLiteral<'db> { if let Some(member) = self.own_synthesized_member(db, env, specialization, None, name) { Place::bound(member).into() } else { - let class = match specialization { - Some(specialization) => { - ClassType::Generic(GenericAlias::new(db, self, specialization)) - } - None => self.identity_specialization(db), - }; + let class = self.apply_optional_specialization(db, specialization); let Some(module) = self.typed_dict_module(db) else { return Place::Undefined.into(); }; @@ -3061,11 +3101,20 @@ impl<'db> StaticClassLiteral<'db> { } if let Some(attr_ty) = attr.place.ignore_possibly_undefined() { - let mut default_ty = if field_policy == CodeGeneratorKind::TypedDict { + // Annotation-only declarations in stubs also act as bindings for attribute + // lookup, but they do not supply field defaults. + let mut default_ty = if field_policy == CodeGeneratorKind::TypedDict + || (self.file(db).is_stub(db) + && !first_declaration.is_some_and(|definition| { + matches!( + definition.kind(db), + DefinitionKind::AnnotatedAssignment(annotation) + if annotation.has_value() + ) + })) { None } else { - let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); - place_from_bindings(db, &env, bindings) + place_from_bindings(db, &env, use_def.end_of_scope_symbol_bindings(symbol_id)) .place .ignore_possibly_undefined() }; @@ -3423,7 +3472,7 @@ impl<'db> StaticClassLiteral<'db> { specialization: Option>, name: &str, ) -> PlaceAndQualifiers<'db> { - if self.is_typed_dict(db) { + if self.is_typed_dict(db) || self.lacks_instance_storage(db, name) { return Place::Undefined.into(); } @@ -3449,373 +3498,6 @@ impl<'db> StaticClassLiteral<'db> { } } - /// Tries to find declarations/bindings of an attribute named `name` that are only - /// "implicitly" defined (`self.x = …`, `cls.x = …`) in a method of the class that - /// corresponds to `class_body_scope`. The `target_method_decorator` parameter is - /// used to skip methods that do not have the expected decorator. - fn implicit_attribute( - db: &'db dyn Db, - class_body_scope: ScopeId<'db>, - name: &str, - target_method_decorator: MethodDecorator, - ) -> Member<'db> { - // Collect names in a tracked query so unrelated edits can preserve dependent member - // lookups, and avoid retaining query entries for names that no method can define. - let names = implicit_attribute_names(db, class_body_scope); - let Ok(name_index) = names.binary_search_by(|candidate| candidate.as_str().cmp(name)) - else { - return Member::unbound(); - }; - - Self::implicit_attribute_inner( - db, - ImplicitAttributeName::new( - db, - class_body_scope, - &names[name_index], - target_method_decorator, - ), - ) - } - - #[salsa::tracked( - returns(copy), - cycle_fn=implicit_attribute_cycle_recover, - cycle_initial=|_, id, _| Member { - inner: Place::bound(Type::divergent(id)).into(), - }, - heap_size=ruff_memory_usage::heap_size, - )] - fn implicit_attribute_inner( - db: &'db dyn Db, - attribute: ImplicitAttributeName<'db>, - ) -> Member<'db> { - let class_body_scope = attribute.class_body_scope(db); - let name = attribute.name(db).as_str(); - let target_method_decorator = attribute.target_method_decorator(db); - let program_file = class_body_scope.program_file(db); - let python_file = program_file.python_file(db); - let env = &ProgramEnvironment::from_file(program_file); - - // If we do not see any declarations of an attribute, neither in the class body nor in - // any method, we build a union of the raw types inferred from all bindings of that - // attribute, then apply public-type promotion to the final union. - let mut union_of_inferred_types = UnionBuilder::new(db, env); - let mut qualifiers = TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE; - - let mut is_attribute_bound = false; - let mut provenance = Provenance::Unknown; - - let module = parsed_module(db, python_file).load(db); - let index = semantic_index(db, program_file); - let class_map = use_def_map(db, class_body_scope); - let class_table = place_table(db, class_body_scope); - let is_valid_scope = |method_scope: &Scope| { - let Some(method_def) = method_scope.node().as_function() else { - return true; - }; - - // Check the decorators directly on the AST node to determine if this method - // is a classmethod or staticmethod. This is more reliable than checking the - // final evaluated type, which may be wrapped by other decorators like @cache. - let function_node = method_def.node(&module); - let definition = index.expect_single_definition(method_def); - - let mut is_classmethod = false; - let mut is_staticmethod = false; - - for decorator in &function_node.decorator_list { - let decorator_ty = - definition_expression_type(db, definition, &decorator.expression); - if let Type::ClassLiteral(class) = decorator_ty { - match class.known(db) { - Some(KnownClass::Classmethod) => is_classmethod = true, - Some(KnownClass::Staticmethod) => is_staticmethod = true, - _ => {} - } - } - } - - // Also check for implicit classmethods/staticmethods based on method name - let method_name = function_node.name.as_str(); - if is_implicit_classmethod(method_name) { - is_classmethod = true; - } - if is_implicit_staticmethod(method_name) { - is_staticmethod = true; - } - - match target_method_decorator { - MethodDecorator::None => !is_classmethod && !is_staticmethod, - MethodDecorator::ClassMethod => is_classmethod, - MethodDecorator::StaticMethod => is_staticmethod, - } - }; - - // First check declarations - for (attribute_declarations, method_scope_id) in - attribute_declarations(db, class_body_scope, name) - { - let method_scope = index.scope(method_scope_id); - if !is_valid_scope(method_scope) { - continue; - } - - for attribute_declaration in attribute_declarations { - let DefinitionState::Defined(declaration) = attribute_declaration.declaration - else { - continue; - }; - - let DefinitionKind::AnnotatedAssignment(assignment) = declaration.kind(db) else { - continue; - }; - - // We found an annotated assignment of one of the following forms (using 'self' in these - // examples, but we support arbitrary names for the first parameters of methods): - // - // self.name: - // self.name: = … - - let Some(annotation) = inferred_declaration(db, declaration).declared() else { - continue; - }; - let annotation = Place::declared(annotation.inner) - .with_definition(declaration) - .with_qualifiers( - annotation.qualifiers | TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE, - ); - - if let Some(all_qualifiers) = annotation.is_bare_final() { - if let Some(value) = assignment.value(&module) { - // If we see an annotated assignment with a bare `Final` as in - // `self.SOME_CONSTANT: Final = 1`, infer the type from the value - // on the right-hand side. - - let inferred_ty = infer_expression_type( - db, - index.expression(value), - TypeContext::default(), - ); - return Member { - inner: Place::bound(inferred_ty) - .with_definition(declaration) - .with_qualifiers(all_qualifiers), - }; - } - - // If there is no right-hand side, just record that we saw a `Final` qualifier - qualifiers |= all_qualifiers; - continue; - } - - return Member { inner: annotation }; - } - } - - for (attribute_assignments, attribute_binding_scope_id) in - attribute_assignments(db, class_body_scope, name) - { - let binding_scope = index.scope(attribute_binding_scope_id); - if !is_valid_scope(binding_scope) { - continue; - } - - let scope_for_reachability_analysis = { - if binding_scope.node().as_function().is_some() { - binding_scope - } else if binding_scope.is_eager() { - let mut eager_scope_parent = binding_scope; - while eager_scope_parent.is_eager() - && let Some(parent) = eager_scope_parent.parent() - { - eager_scope_parent = index.scope(parent); - } - eager_scope_parent - } else { - binding_scope - } - }; - - // The attribute assignment inherits the reachability of the method which contains it - let is_method_reachable = - if let Some(method_def) = scope_for_reachability_analysis.node().as_function() { - let method = index.expect_single_definition(method_def); - let method_place = class_table - .symbol_id(&method_def.node(&module).name) - .unwrap(); - class_map - .reachable_symbol_bindings(method_place) - .find_map(|bind| { - (bind.binding.is_defined_and(|def| def == method)) - .then(|| binding_reachability(db, class_map, &bind)) - }) - .unwrap_or(Truthiness::AlwaysFalse) - } else { - Truthiness::AlwaysFalse - }; - if is_method_reachable.is_always_false() { - continue; - } - - for attribute_assignment in attribute_assignments { - if let DefinitionState::Undefined = attribute_assignment.binding { - continue; - } - - let DefinitionState::Defined(binding) = attribute_assignment.binding else { - continue; - }; - - if !is_method_reachable.is_always_false() { - is_attribute_bound = true; - } - - let inferred_ty = match binding.kind(db) { - DefinitionKind::AnnotatedAssignment(_) => { - // Annotated assignments were handled above. This branch is not - // unreachable (because of the `continue` above), but there is - // nothing to do here. - None - } - DefinitionKind::Assignment(assign) => match assign.unpack() { - Some(unpack) => { - // We found an unpacking assignment like: - // - // .., self.name, .. = - // (.., self.name, ..) = - // [.., self.name, ..] = - - let unpacked = infer_unpack_types(db, unpack); - Some(unpacked.expression_type(assign.target(&module))) - } - None => { - // We found an un-annotated attribute assignment of the form: - // - // self.name = - - Some(infer_expression_type( - db, - index.expression(assign.value(&module)), - TypeContext::default(), - )) - } - }, - DefinitionKind::For(for_stmt) => match for_stmt.target_kind() { - TargetKind::Sequence(_, unpack) => { - // We found an unpacking assignment like: - // - // for .., self.name, .. in : - - let unpacked = infer_unpack_types(db, unpack); - Some(unpacked.expression_type(for_stmt.target(&module))) - } - TargetKind::Single => { - // We found an attribute assignment like: - // - // for self.name in : - - let iterable_ty = infer_expression_type( - db, - index.expression(for_stmt.iterable(&module)), - TypeContext::default(), - ); - // TODO: Potential diagnostics resulting from the iterable are currently not reported. - Some( - iterable_ty - .iterate(db, env) - .homogeneous_element_type(db, env), - ) - } - }, - DefinitionKind::WithItem(with_item) => match with_item.target_kind() { - TargetKind::Sequence(_, unpack) => { - // We found an unpacking assignment like: - // - // with as .., self.name, ..: - - let unpacked = infer_unpack_types(db, unpack); - Some(unpacked.expression_type(with_item.target(&module))) - } - TargetKind::Single => { - // We found an attribute assignment like: - // - // with as self.name: - - let context_ty = infer_expression_type( - db, - index.expression(with_item.context_expr(&module)), - TypeContext::default(), - ); - Some(if with_item.is_async() { - context_ty.aenter(db, env) - } else { - context_ty.enter(db, env) - }) - } - }, - DefinitionKind::Comprehension(comprehension) => { - match comprehension.target_kind() { - TargetKind::Sequence(_, unpack) => { - // We found an unpacking assignment like: - // - // [... for .., self.name, .. in ] - - let unpacked = infer_unpack_types(db, unpack); - Some(unpacked.expression_type(comprehension.target(&module))) - } - TargetKind::Single => { - // We found an attribute assignment like: - // - // [... for self.name in ] - - let iterable_ty = infer_expression_type( - db, - index.expression(comprehension.iterable(&module)), - TypeContext::default(), - ); - // TODO: Potential diagnostics resulting from the iterable are currently not reported. - Some( - iterable_ty - .iterate(db, env) - .homogeneous_element_type(db, env), - ) - } - } - } - DefinitionKind::AugmentedAssignment(_) => { - Some(infer_definition_types(db, binding).binding_type(binding)) - } - DefinitionKind::NamedExpression(_) => { - // A named expression whose target is an attribute is syntactically prohibited - None - } - _ => None, - }; - - if let Some(inferred_ty) = inferred_ty { - provenance = provenance.or(Provenance::SingleDefinition(binding)); - union_of_inferred_types = union_of_inferred_types.add(inferred_ty); - } - } - } - - Member { - inner: if is_attribute_bound { - Place::bound( - union_of_inferred_types - .build() - .promote_in(db, env, class_body_scope.file(db)) - .promote_singletons(db, env), - ) - .with_provenance(provenance) - .with_qualifiers(qualifiers) - } else { - Place::Undefined.with_qualifiers(qualifiers) - }, - } - } - /// A helper function for `instance_member` that looks up the `name` attribute only on /// this class, not on its superclasses. pub(super) fn own_instance_member( @@ -3869,7 +3551,8 @@ impl<'db> StaticClassLiteral<'db> { if qualifiers.contains(TypeQualifiers::INIT_VAR) { // We ignore `InitVar` declarations on the class body, unless that attribute is overwritten // by an implicit assignment in a method - if Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) + if self + .implicit_attribute(db, name, MethodDecorator::None) .is_undefined() { return Member::unbound(); @@ -3888,13 +3571,15 @@ impl<'db> StaticClassLiteral<'db> { let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); let inferred = place_from_bindings(db, env, bindings).place; - let has_binding = !inferred.is_undefined(); + // Stub assignments to slots describe instance storage, not runtime class + // attributes. + let has_binding = !(inferred.is_undefined() + || self.file(db).is_stub(db) && self.has_instance_slot(db, name)); if has_binding { // The attribute is declared and bound in the class body. - let implicit = - Self::implicit_attribute(db, body_scope, name, MethodDecorator::None); + let implicit = self.implicit_attribute(db, name, MethodDecorator::None); if let Place::Defined(DefinedPlace { ty: implicit_ty, provenance: implicit_provenance, @@ -3968,14 +3653,10 @@ impl<'db> StaticClassLiteral<'db> { ty: implicit_ty, provenance: implicit_provenance, .. - }) = Self::implicit_attribute( - db, - body_scope, - name, - MethodDecorator::None, - ) - .inner - .place + }) = self + .implicit_attribute(db, name, MethodDecorator::None) + .inner + .place { Member { inner: Place::Defined(DefinedPlace { @@ -4008,14 +3689,14 @@ impl<'db> StaticClassLiteral<'db> { // The attribute is not *declared* in the class body. It could still be declared/bound // in a method. - Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) + self.implicit_attribute(db, name, MethodDecorator::None) } } } else { // This attribute is neither declared nor bound in the class body. // It could still be implicitly defined in a method. - Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) + self.implicit_attribute(db, name, MethodDecorator::None) } } @@ -4288,11 +3969,8 @@ impl<'db> VarianceInferable<'db> for StaticClassLiteral<'db> { db: &'db dyn Db, _: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - let bivariant_private_attributes = db - .analysis_settings(self.body_scope(db).file(db)) - .bivariant_private_attributes; - self.variance_of_owner(db, typevar, bivariant_private_attributes) + ) -> VarianceTerm<'db> { + VarianceTerm::variable(db, VarianceOrigin::Class(self), typevar) } } @@ -4312,11 +3990,16 @@ impl<'db> StaticClassLiteral<'db> { db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, ) -> bool { - self.variance_of_owner(db, typevar, false) == TypeVarVariance::Bivariant + self.variance_equation_with(db, typevar, false).evaluate(db) == TypeVarVariance::Bivariant } - /// The variance this class's own members require of `typevar`, ignoring its bases. - pub(crate) fn own_variance_of( + /// The variance this class's *non-method* members require of `typevar`, ignoring its bases. + /// + /// A method that contradicts the declared variance is reported against the method itself, + /// which names it and points at the offending annotation. Counting methods here as well would + /// report the same contradiction a second time against the class header, so this answers only + /// for the members nothing else covers. + pub(crate) fn own_non_method_variance_of( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, @@ -4324,35 +4007,71 @@ impl<'db> StaticClassLiteral<'db> { let bivariant_private_attributes = db .analysis_settings(self.body_scope(db).file(db)) .bivariant_private_attributes; - self.own_variance_of_with(db, typevar, bivariant_private_attributes) + self.own_variance_of_with(db, typevar, bivariant_private_attributes, false) + .evaluate(db) } } #[salsa::tracked] impl<'db> StaticClassLiteral<'db> { - #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] - fn variance_of_owner( + /// Build a definition-site equation before substituting type arguments. Supported protocols + /// use their structural interface; `TypedDict` classes use their fields. Other classes retain + /// the ordinary attribute and base-class variance rules. + pub(in crate::types) fn variance_equation( + self, + db: &'db dyn Db, + typevar: BoundTypeVarIdentity<'db>, + ) -> VarianceTerm<'db> { + let bivariant_private_attributes = db + .analysis_settings(self.body_scope(db).file(db)) + .bivariant_private_attributes; + self.variance_equation_with(db, typevar, bivariant_private_attributes) + } + + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size)] + fn variance_equation_with( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, bivariant_private_attributes: bool, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { let env = ProgramEnvironment::from_scope(self.body_scope(db)); + + if self.is_typed_dict(db) { + return TypedDictType::new(self.identity_specialization(db)) + .variance_of_items(db, &env, typevar); + } + let typevar_in_generic_context = self .generic_context(db) .is_some_and(|generic_context| generic_context.contains(db, typevar)); if !typevar_in_generic_context { - return TypeVarVariance::Bivariant; + return VarianceTerm::BIVARIANT; } + + if self.is_protocol(db) + && let Some(protocol) = self.identity_specialization(db).into_protocol_class(db) + && protocol.supports_variance_inference(db) + { + return protocol.interface(db).variance_of(db, &env, typevar); + } + let explicit_bases_variances = self .explicit_bases(db) .iter() .map(|class| class.variance_of(db, &env, typevar)); - std::iter::once(self.own_variance_of_with(db, typevar, bivariant_private_attributes)) - .chain(explicit_bases_variances) - .collect() + VarianceTerm::join( + db, + std::iter::once(self.own_variance_of_with( + db, + typevar, + bivariant_private_attributes, + true, + )) + .chain(explicit_bases_variances), + ) } } @@ -4364,20 +4083,21 @@ impl<'db> StaticClassLiteral<'db> { /// This is what a declared variance has to agree with: an incompatible base is reported /// against that base instead, so folding the bases in here would report the same problem /// twice. - #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn own_variance_of_with( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, bivariant_private_attributes: bool, - ) -> TypeVarVariance { + include_methods: bool, + ) -> VarianceTerm<'db> { let env = &ProgramEnvironment::from_file(self.program_file(db)); let typevar_in_generic_context = self .generic_context(db) .is_some_and(|generic_context| generic_context.contains(db, typevar)); if !typevar_in_generic_context { - return TypeVarVariance::Bivariant; + return VarianceTerm::BIVARIANT; } let class_body_scope = self.body_scope(db); @@ -4440,30 +4160,25 @@ impl<'db> StaticClassLiteral<'db> { let use_def_map = index.use_def_map(class_body_scope.file_scope_id(db)); let table = place_table(db, class_body_scope); - let attribute_places_and_qualifiers = - use_def_map - .all_end_of_scope_symbol_declarations() - .map(|(symbol_id, declarations)| { - let place_and_qual = place_from_declarations(db, env, declarations) - .ignore_conflicting_declarations(); - (symbol_id, place_and_qual) - }) - .chain(use_def_map.all_end_of_scope_symbol_bindings().map( - |(symbol_id, bindings)| { - ( - symbol_id, - place_from_bindings(db, env, bindings).place.into(), - ) - }, - )) - .filter_map(|(symbol_id, place_and_qual)| { - if let Some(name) = table.place(symbol_id).as_symbol().map(Symbol::name) { - (![init_name, new_name].contains(&name)) - .then_some((name.to_string(), place_and_qual)) - } else { - None - } - }); + // A declaration in a stub also creates a binding with no qualifiers. Resolve + // both together so `value: Final[T]` is not also treated as a mutable `T`. + let attribute_places_and_qualifiers = use_def_map + .all_end_of_scope_symbol_declarations() + .filter_map(|(symbol_id, _)| { + let name = table.symbol(symbol_id).name(); + if [init_name, new_name].contains(&name) { + return None; + } + + let place_and_qualifiers = place_by_id( + db, + class_body_scope, + symbol_id.into(), + RequiresExplicitReExport::No, + ConsideredDefinitions::EndOfScope, + ); + Some((name.to_string(), place_and_qualifiers)) + }); // Dataclasses can have some additional synthesized methods (`__eq__`, `__hash__`, // `__lt__`, etc.) but none of these will have field types type variables in their signatures, so we @@ -4489,7 +4204,10 @@ impl<'db> StaticClassLiteral<'db> { .chain(attribute_places_and_qualifiers) .dedup() .filter_map(|(name, place_and_qual)| { - place_and_qual.ignore_possibly_undefined().map(|ty| { + place_and_qual.ignore_possibly_undefined().and_then(|ty| { + if !include_methods && ty.is_function_literal() { + return None; + } // A private member is invisible to external observers, so it can't be used to // tell two specializations of its class apart, and therefore can't constrain // the class's variance at all. Dunders are excluded: they are part of the @@ -4528,28 +4246,14 @@ impl<'db> StaticClassLiteral<'db> { } else { default_attribute_variance }; - ty.with_polarity(variance).variance_of(db, env, typevar) + Some(ty.with_polarity(variance).variance_of(db, env, typevar)) }) }); - let extra_items_variance = TypedDictType::new(self.identity_specialization(db)) - .explicit_extra_items(db) - .map(|extra_items| { - let polarity = if extra_items.is_read_only() { - TypeVarVariance::Covariant - } else { - TypeVarVariance::Invariant - }; - extra_items - .declared_ty - .with_polarity(polarity) - .variance_of(db, env, typevar) - }); - - attribute_variances - .chain(unfrozen_inherited_field_variances) - .chain(extra_items_variance) - .collect() + VarianceTerm::join( + db, + attribute_variances.chain(unfrozen_inherited_field_variances), + ) } } @@ -4784,52 +4488,6 @@ fn explicit_bases_cycle_fn<'db>( } } -#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] -struct ImplicitAttributeName<'db> { - #[returns(copy)] - class_body_scope: ScopeId<'db>, - #[returns(ref)] - name: Name, - #[returns(copy)] - target_method_decorator: MethodDecorator, -} - -// The Salsa heap is tracked separately. -impl get_size2::GetSize for ImplicitAttributeName<'_> {} - -#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] -fn implicit_attribute_names<'db>(db: &'db dyn Db, class_body_scope: ScopeId<'db>) -> Box<[Name]> { - let index = semantic_index(db, class_body_scope.program_file(db)); - let mut names = Vec::new(); - - for function_scope_id in attribute_scopes(db, class_body_scope) { - names.extend( - index - .place_table(function_scope_id) - .members() - .filter_map(|member| member.as_instance_attribute().map(Name::new)), - ); - } - - names.sort_unstable(); - names.dedup(); - names.into_boxed_slice() -} - -fn implicit_attribute_cycle_recover<'db>( - db: &'db dyn Db, - cycle: &salsa::Cycle, - previous_member: &Member<'db>, - member: Member<'db>, - attribute: ImplicitAttributeName<'db>, -) -> Member<'db> { - let env = ProgramEnvironment::from_scope(attribute.class_body_scope(db)); - let inner = member - .inner - .cycle_normalized(db, &env, previous_member.inner, cycle); - Member { inner } -} - /// If `definition` is an annotated assignment whose annotation is /// `Annotated[T, ..., (...), ...]`, return the field-specifier /// instance carried in the metadata. PEP 681 allows a field specifier diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs index 3752342cf7..27b97d43b0 100644 --- a/crates/ty_python_semantic/src/types/class/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -10,8 +10,10 @@ use ty_module_resolver::KnownModule; use crate::place::PlaceAndQualifiers; use crate::place::known_module_symbol; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; -use crate::types::class::{DynamicClassHeaderAnchor, dynamic_class_header_range}; +use crate::types::callable::CallableTypeKind; +use crate::types::class::{ + DynamicClassHeaderAnchor, DynamicClassScopeOffset, dynamic_class_header_range, +}; use crate::types::generics::GenericContext; use crate::types::member::Member; use crate::types::mro::Mro; @@ -24,7 +26,7 @@ use crate::types::typed_dict::{ use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, CallableType, ClassBase, ClassLiteral, ClassType, KnownClass, MemberLookupPolicy, Type, TypeContext, TypeMapping, TypeVarVariance, - TypedDictModule, TypedDictType, UnionType, determine_upper_bound, + TypedDictType, TypingModule, UnionType, determine_upper_bound, }; use crate::{Db, FxIndexMap}; use ty_python_core::definition::Definition; @@ -219,7 +221,6 @@ fn synthesize_typed_dict_init<'db>( db, CallableSignature::from_overloads([map_overload, keyword_overload]), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -261,7 +262,6 @@ fn synthesize_typed_dict_getitem<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -321,7 +321,6 @@ fn synthesize_typed_dict_setitem<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -375,7 +374,6 @@ fn synthesize_typed_dict_delitem<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -503,7 +501,6 @@ fn synthesize_typed_dict_get<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -653,7 +650,6 @@ fn synthesize_typed_dict_pop<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -704,7 +700,6 @@ fn synthesize_typed_dict_setdefault<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -825,7 +820,6 @@ fn synthesize_typed_dict_merge<'db>( db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, )) } @@ -848,12 +842,12 @@ pub enum DynamicTypedDictAnchor<'db> { /// The `TypedDict()` call is "dangling" (not assigned to a variable). /// - /// The offset is relative to the enclosing scope's anchor node index. The eagerly - /// computed `spec` preserves field types for inline uses like + /// The [`DynamicClassScopeOffset`] locates the call relative to the enclosing scope. + /// The eagerly computed `spec` preserves field types for inline uses like /// `TypedDict("Point", {"x": int})(x=1)`. ScopeOffset { scope: ScopeId<'db>, - offset: u32, + offset: DynamicClassScopeOffset, schema: TypedDictSchema<'db>, openness: TypedDictOpenness<'db>, }, @@ -923,14 +917,13 @@ pub struct DynamicTypedDictLiteral<'db> { /// /// - `Definition`: The call is assigned to a variable. The definition /// uniquely identifies this TypedDict and can be used to find the call. - /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset - /// is relative to the enclosing scope's anchor node index, and the - /// eagerly computed spec is stored on the anchor. + /// - `ScopeOffset`: The call is "dangling" (not assigned). Its location + /// is relative to the enclosing scope, and the eagerly computed spec is stored on the anchor. #[returns(ref)] pub(crate) anchor: DynamicTypedDictAnchor<'db>, #[returns(copy)] - pub(crate) typed_dict_module: TypedDictModule, + pub(crate) typed_dict_module: TypingModule, } impl get_size2::GetSize for DynamicTypedDictLiteral<'_> {} @@ -1186,7 +1179,7 @@ pub(in crate::types) fn synthesized_typed_dict_class_member<'db>( db, env, typed_dict, - TypedDictModule::Typing, + TypingModule::Typing, lookup_policy, name, || Type::TypedDict(typed_dict), @@ -1196,13 +1189,13 @@ pub(in crate::types) fn synthesized_typed_dict_class_member<'db>( pub(super) fn typed_dict_fallback_class_member<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, - module: TypedDictModule, + module: TypingModule, lookup_policy: MemberLookupPolicy, name: &str, ) -> PlaceAndQualifiers<'db> { let fallback = match module { - TypedDictModule::Typing => KnownClass::TypedDictFallback, - TypedDictModule::TypingExtensions => KnownClass::ExtensionTypedDictFallback, + TypingModule::Typing => KnownClass::TypedDictFallback, + TypingModule::TypingExtensions => KnownClass::ExtensionTypedDictFallback, }; fallback @@ -1215,12 +1208,10 @@ pub(super) fn typed_dict_class_member<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, class: ClassType<'db>, - module: TypedDictModule, + module: TypingModule, lookup_policy: MemberLookupPolicy, name: &str, ) -> PlaceAndQualifiers<'db> { - let self_class = class.class_literal(db); - typed_dict_inherited_class_member( db, env, @@ -1228,7 +1219,7 @@ pub(super) fn typed_dict_class_member<'db>( module, lookup_policy, name, - || determine_upper_bound(db, env, self_class, ClassBase::is_typed_dict), + || determine_upper_bound(db, env, class, ClassBase::is_typed_dict), ) } @@ -1236,7 +1227,7 @@ fn typed_dict_inherited_class_member<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, typed_dict: TypedDictType<'db>, - module: TypedDictModule, + module: TypingModule, lookup_policy: MemberLookupPolicy, name: &str, new_upper_bound: impl FnOnce() -> Type<'db>, diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 1058afa7f5..c244873788 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -1,12 +1,16 @@ +use std::fmt::Display; + +use ty_module_resolver::{SearchPath, file_to_module}; + use crate::ProgramEnvironment; -use crate::types::class::CodeGeneratorKind; +use crate::types::class::{ClassMetaclass, CodeGeneratorKind}; use crate::types::generics::{ApplySpecialization, Specialization}; use crate::types::mro::MroIterator; use crate::types::tuple::TupleType; use crate::types::{ ApplyTypeMappingVisitor, ClassLiteral, ClassType, DivergentType, DynamicType, KnownClass, KnownInstanceType, MaterializationKind, SpecialFormType, StaticMroError, Type, TypeContext, - TypeMapping, TypedDictModule, todo_type, + TypeMapping, TypingModule, todo_type, }; use crate::{Db, DisplaySettings}; @@ -35,7 +39,7 @@ pub enum ClassBase<'db> { /// but nonetheless appears in the MRO of classes that inherit from `Generic[T]`, /// `Protocol[T]`, or bare `Protocol`. Generic, - TypedDict(TypedDictModule), + TypedDict(TypingModule), } impl<'db> ClassBase<'db> { @@ -68,6 +72,7 @@ impl<'db> ClassBase<'db> { ClassBase::Dynamic( DynamicType::Unknown | DynamicType::UnknownGeneric(_) + | DynamicType::UnknownLambdaParameter | DynamicType::InvalidConcatenateUnknown | DynamicType::AmbiguousOverload, ) => "Unknown", @@ -89,7 +94,7 @@ impl<'db> ClassBase<'db> { self.typed_dict_module().is_some() } - pub(super) const fn typed_dict_module(self) -> Option { + pub(super) const fn typed_dict_module(self) -> Option { match self { ClassBase::TypedDict(module) => Some(module), _ => None, @@ -102,7 +107,7 @@ impl<'db> ClassBase<'db> { /// pseudo-base when detecting duplicate or conflicting bases. pub(super) const fn mro_identity(self) -> Self { match self { - Self::TypedDict(_) => Self::TypedDict(TypedDictModule::Typing), + Self::TypedDict(_) => Self::TypedDict(TypingModule::Typing), _ => self, } } @@ -183,7 +188,7 @@ impl<'db> ClassBase<'db> { .iter() .find_map(|element| ClassBase::try_from_type(db, env, *element, subclass)), Type::Union(union) => { - if let Some(module) = TypedDictModule::from_type(db, ty) { + if let Some(module) = TypingModule::from_typed_dict_type(db, ty) { return Some(ClassBase::TypedDict(module)); } @@ -225,6 +230,7 @@ impl<'db> ClassBase<'db> { } Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::EnumComplement(_) | Type::LiteralValue(_) | Type::FunctionLiteral(_) @@ -330,7 +336,7 @@ impl<'db> ClassBase<'db> { db, env, fields.values().map(|field| field.declared_ty), - )? + ) .to_class_type(db) .into(), subclass, @@ -383,18 +389,35 @@ impl<'db> ClassBase<'db> { } } - /// Return the metaclass of this class base. - pub(crate) fn metaclass(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { - match self { - Self::Class(class) => class.metaclass(db), + /// Return this base's selected metaclass or inferred protocol fallback. + /// + /// `subclass` is the class whose declaration names this base. Only a direct `Protocol` base + /// depends on whether its declaration is in the bundled or configured typeshed stdlib; + /// named bases retain their own metaclass constraints or fallback wherever they are inherited. + pub(super) fn inferred_metaclass( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + subclass: ClassLiteral<'db>, + ) -> ClassMetaclass<'db> { + let metaclass = match self { + Self::Class(class) => return class.inferred_metaclass(db), + Self::Protocol => { + if subclass.file(db).is_stub(db) + && file_to_module(db, subclass.program_file(db).resolver_file(db)) + .and_then(|module| module.search_path(db)) + .is_some_and(SearchPath::is_standard_library) + { + return ClassMetaclass::ProtocolFallback; + } + KnownClass::ProtocolMeta.to_class_literal(db, env) + } Self::Any => Type::Dynamic(DynamicType::Any), Self::Dynamic(dynamic) => Type::Dynamic(dynamic), Self::Divergent(divergent) => Type::Divergent(divergent), - // TODO: all `Protocol` classes actually have `_ProtocolMeta` as their metaclass. - Self::Protocol | Self::Generic | Self::TypedDict(_) => { - KnownClass::Type.to_instance(db, env) - } - } + Self::Generic | Self::TypedDict(_) => KnownClass::Type.to_instance(db, env), + }; + ClassMetaclass::Selected(metaclass) } fn apply_type_mapping_impl<'a>( @@ -429,7 +452,7 @@ impl<'db> ClassBase<'db> { let new_self = self.apply_type_mapping_impl( db, env, - &TypeMapping::ApplySpecialization(ApplySpecialization::Specialization( + &TypeMapping::ApplySpecialization(ApplySpecialization::specialization( specialization, )), TypeContext::default(), @@ -517,37 +540,18 @@ impl<'db> ClassBase<'db> { db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, display_settings: DisplaySettings<'db>, - ) -> impl std::fmt::Display + 'env { - struct ClassBaseDisplay<'env, 'db> { - db: &'db dyn Db, - env: &'env ProgramEnvironment<'db>, - base: ClassBase<'db>, - settings: DisplaySettings<'db>, - } - - impl std::fmt::Display for ClassBaseDisplay<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - match self.base { - ClassBase::Any => f.write_str("Any"), - ClassBase::Dynamic(dynamic) => dynamic.fmt(f), - ClassBase::Divergent(_) => f.write_str("Divergent"), - ClassBase::Class(class) => Type::from(class) - .display_with(db, self.env, self.settings.clone()) - .fmt(f), - ClassBase::Protocol => f.write_str("typing.Protocol"), - ClassBase::Generic => f.write_str("typing.Generic"), - ClassBase::TypedDict(_) => f.write_str("typing.TypedDict"), - } - } - } - - ClassBaseDisplay { - db, - env, - base: self, - settings: display_settings, - } + ) -> impl Display + 'env { + std::fmt::from_fn(move |f| match self { + ClassBase::Any => f.write_str("Any"), + ClassBase::Dynamic(dynamic) => dynamic.fmt(f), + ClassBase::Divergent(_) => f.write_str("Divergent"), + ClassBase::Class(class) => Type::from(class) + .display_with(db, env, display_settings.clone()) + .fmt(f), + ClassBase::Protocol => f.write_str("typing.Protocol"), + ClassBase::Generic => f.write_str("typing.Generic"), + ClassBase::TypedDict(_) => f.write_str("typing.TypedDict"), + }) } } diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 9246a0b9ef..a97855a9dd 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -88,7 +88,6 @@ use std::cell::{Cell, RefCell}; use std::cmp::Ordering; -use std::collections::VecDeque; use std::convert::Infallible; use std::fmt::{Debug, Display}; use std::iter; @@ -96,7 +95,6 @@ use std::marker::PhantomData; use std::ops::{ControlFlow, Range}; use std::sync::{Arc, LazyLock}; -use indexmap::map::Entry; use itertools::Itertools; use ruff_index::{Idx, IndexVec, newtype_index}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -106,21 +104,28 @@ use ty_python_core::rank::RankBitBox; use ty_static::EnvVars; use crate::types::class::GenericAlias; +use crate::types::constraints::projection::{ProjectionError, SolutionBudget}; use crate::types::constraints::support::{Support, SupportId}; -use crate::types::typevar::{BoundTypeVarIdentity, TypeVarSet}; -use crate::types::variance::VarianceInferable; +use crate::types::typevar::{BoundTypeVarIdentity, TypeVarInstance, TypeVarSet}; use crate::types::visitor::{ - TypeCollector, TypeKind, TypeVisitor, any_over_type, walk_non_atomic_type, - walk_type_with_recursion_guard, + TypeCollector, TypeKind, TypeVisitor, walk_non_atomic_type, walk_type_with_recursion_guard, }; use crate::types::{ - ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Type, TypeContext, + ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Parameters, Type, TypeContext, TypeMapping, TypePair, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, }; use crate::{Db, FxIndexMap, FxIndexSet, FxOrderSet, ProgramEnvironment}; +pub(crate) mod paths; +pub(crate) mod projection; +mod sequents; +mod solutions; mod support; +use paths::PathAssignments; +use sequents::SequentMap; +use solutions::SolutionWalker; + /// An extension trait for building constraint sets from [`Option`] values. pub(crate) trait OptionConstraintsExtension { /// Returns a constraint set that is always satisfiable if the option is `None`; otherwise @@ -257,7 +262,7 @@ struct OwnedConstraintSetInner<'db> { constraints: Box<[Constraint<'db>]>, constraint_supports: Box<[SupportId]>, constraint_indices: RankBitBox, - typevars: IndexVec>, + typevars: IndexVec>, nodes: Box<[InteriorNodeData]>, node_supports: Box<[SupportId]>, node_indices: RankBitBox, @@ -317,10 +322,11 @@ impl<'db> OwnedConstraintSet<'db> { f(&builder, set) } - /// Returns the types in constraints that are still reachable from the decision diagram. + /// Returns the typevars and stored bound types still reachable from the decision diagram. /// - /// Source ordering also retains quantified-away constraints to preserve binding order, but - /// their type variables must not participate in semantic walks or callable freshening. + /// Source ordering can retain constraints that are no longer in the diagram, but their type + /// variables must not participate in semantic walks or callable freshening. + /// Synthetic defaults are not stored types and must not affect these walks either. pub(crate) fn types(&self) -> impl Iterator> + '_ { self.inner.iter().flat_map(|inner| { inner @@ -330,9 +336,8 @@ impl<'db> OwnedConstraintSet<'db> { .unique() .map(|constraint| inner.constraints[inner.retained_constraint_index(constraint)]) .flat_map(|constraint| { - std::iter::once(Type::TypeVar(constraint.typevar)) - .chain(constraint.bounds.lower) - .chain(constraint.bounds.upper) + iter::once(Type::TypeVar(constraint.typevar)) + .chain(constraint.iter_stored_bounds().map(ConstraintBound::ty)) }) }) } @@ -435,17 +440,39 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { lower: Type<'db>, upper: Type<'db>, ) -> Self { - Self::constrain_typevar_with_bounds(db, env, builder, typevar, Some(lower), Some(upper)) + Self::from_constraint( + db, + env, + builder, + Constraint::from_evidence(typevar, Some(lower), Some(upper)), + ) + } + + /// Creates a constraint set for the range described by a constraint. + pub(crate) fn from_constraint( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + builder: &'c ConstraintSetBuilder<'db>, + constraint: Constraint<'db>, + ) -> Self { + Self::constrain_typevar_with_bounds( + db, + env, + builder, + constraint.typevar(), + constraint.stored_lower_bound(), + constraint.stored_upper_bound(), + ) } /// Returns a constraint set that constrains a typevar with explicit lower and/or upper bounds. - pub(crate) fn constrain_typevar_with_bounds( + fn constrain_typevar_with_bounds( db: &'db dyn Db, env: &ProgramEnvironment<'db>, builder: &'c ConstraintSetBuilder<'db>, typevar: BoundTypeVarInstance<'db>, - lower: Option>, - upper: Option>, + lower: Option>, + upper: Option>, ) -> Self { let mut storage = builder.storage.borrow_mut(); let (node, source_order) = @@ -461,7 +488,12 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { typevar: BoundTypeVarInstance<'db>, lower: Type<'db>, ) -> Self { - Self::constrain_typevar_with_bounds(db, env, builder, typevar, Some(lower), None) + Self::from_constraint( + db, + env, + builder, + Constraint::from_evidence(typevar, Some(lower), None), + ) } /// Returns a constraint set that constrains a typevar to be a subtype of `upper`. @@ -472,7 +504,12 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { typevar: BoundTypeVarInstance<'db>, upper: Type<'db>, ) -> Self { - Self::constrain_typevar_with_bounds(db, env, builder, typevar, None, Some(upper)) + Self::from_constraint( + db, + env, + builder, + Constraint::from_evidence(typevar, None, Some(upper)), + ) } /// Verifies that this constraint set was created by `builder` @@ -481,13 +518,47 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { debug_assert!(std::ptr::eq(self.builder, builder)); } - /// Returns whether this constraint set never holds. + /// Returns whether this constraint set never holds, without checking the type variables' + /// declared bounds or constraints. Use [`Self::has_no_valid_solutions`] to include those. pub(crate) fn is_never_satisfied(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { let mut storage = self.builder.storage.borrow_mut(); self.node .is_never_satisfied(db, env, &mut storage, self.source_order) } + /// Returns whether no specialization satisfying the type variables' upper bounds and + /// constraints can satisfy this constraint set. + /// + /// Unlike [`Self::is_never_satisfied`], this validates solutions against the type variables' + /// upper bounds and constraints. For example, `T = int` is not contradictory by itself, but has + /// no valid solution if `T` has an upper bound of `str`. + /// + /// If the solver reaches its computation limit, we do not know whether a valid solution exists. + /// This returns `false` in that case: stopping the search is not proof that there is no solution. + pub(crate) fn has_no_valid_solutions( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + if self.is_never_satisfied(db, env) { + return true; + } + + let inferable = { + let storage = self.builder.storage.borrow(); + let Some(support) = storage.node_support(self.node) else { + return false; + }; + // For overlap, every mentioned type variable can choose a valid specialization. + TypeVarSet::from_typevars(db, support.iter().map(|id| storage.typevar_data(id))) + }; + + matches!( + self.solutions(db, env, inferable), + Ok(Solutions::Unsatisfiable) + ) + } + /// Returns whether this constraint set is the `never` terminal. /// /// A nonterminal constraint set can also never be satisfied, so `false` does not prove that @@ -518,6 +589,14 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { self.node == ALWAYS_TRUE } + /// Returns whether this constraint set mentions the given type-variable identity. + pub(super) fn mentions_typevar(self, typevar: BoundTypeVarInstance<'db>) -> bool { + let storage = self.builder.storage.borrow(); + storage + .node_support(self.node) + .is_some_and(|support| support.iter().any(|id| storage.typevar_data(id) == typevar)) + } + /// Returns the constraints under which `lhs` is a subtype of `rhs`, assuming that the /// constraints in this constraint set hold. Panics if neither of the types being compared are /// a typevar. (That case is handled by `Type::has_relation_to`.) @@ -538,33 +617,6 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Self::from_node(builder, node, source_order) } - /// Returns whether this constraint set is satisfied by all of the typevars that it mentions. - /// - /// Each typevar has a set of _valid specializations_, which is defined by any upper bound or - /// constraints that the typevar has. - /// - /// Each typevar is also either _inferable_ or _non-inferable_. (You provide a list of the - /// `inferable` typevars; all others are considered non-inferable.) For an inferable typevar, - /// then there must be _some_ valid specialization that satisfies the constraint set. For a - /// non-inferable typevar, then _all_ valid specializations must satisfy it. - /// - /// Note that we don't have to consider typevars that aren't mentioned in the constraint set, - /// since the constraint set cannot be affected by any typevars that it does not mention. That - /// means that those additional typevars trivially satisfy the constraint set, regardless of - /// whether they are inferable or not. - pub(crate) fn satisfied_by_all_typevars( - &self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - builder: &'c ConstraintSetBuilder<'db>, - inferable: TypeVarSet<'db>, - ) -> bool { - self.verify_builder(builder); - let mut storage = builder.storage.borrow_mut(); - self.node - .satisfied_by_all_typevars(db, env, &mut storage, inferable, self.source_order) - } - /// Updates this constraint set to hold the union of itself and another constraint set. /// /// In the result's source order, `self` will appear before `other`. @@ -685,11 +737,27 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { to_remove: TypeVarSet<'db>, ) -> Self { self.verify_builder(builder); + if to_remove == TypeVarSet::None { + return self; + } let mut storage = builder.storage.borrow_mut(); let (node, derived_source_order) = self.node .exists(db, env, &mut storage, to_remove, self.source_order); - let source_order = storage.ordered_source_order(self.source_order, derived_source_order); + // The eliminated typevars must also leave the source-order history. Otherwise recursive + // relations can re-import each other's quantified constraints after their live graphs have + // stabilized. Keep the original order of the remaining entries and append derived facts. + let source_order = storage + .calculate_source_orders(self.source_order) + .into_iter() + .fold(None, |source_order, constraint| { + if storage.constraint_mentions_typevars(db, constraint, to_remove) { + return source_order; + } + let constraint_source_order = storage.constraint_source_order(constraint); + storage.ordered_source_order(source_order, Some(constraint_source_order)) + }); + let source_order = storage.ordered_source_order(source_order, derived_source_order); Self::from_node(builder, node, source_order) } @@ -750,6 +818,10 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { let constraint = storage.constraint_data(constraint_id); constraints.push((constraint_id, constraint)); }); + // Mapping can intern constraints and typevars. Preserve their source order rather than + // letting the old diagram's variable order determine the rebuilt diagram's ordering. + let source_orders = storage.calculate_source_orders(self.source_order); + constraints.sort_unstable_by_key(|(constraint, _)| source_orders.get_index_of(constraint)); drop(storage); let mut mapped_constraints = FxHashMap::default(); @@ -765,14 +837,12 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { tcx, visitor, ); - let lower = constraint - .bounds - .lower - .map(|lower| lower.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)); - let upper = constraint - .bounds - .upper - .map(|upper| upper.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)); + let lower = constraint.stored_lower_bound().map(|lower| { + lower.map(|ty| ty.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)) + }); + let upper = constraint.stored_upper_bound().map(|upper| { + upper.map(|ty| ty.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)) + }); let env = visitor.env; let mut storage = self.builder.storage.borrow_mut(); @@ -783,7 +853,9 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Some(lower) => storage.load( db, env, - &lower.when_constraint_set_assignable_to_owned(db, env, subject), + &lower + .ty() + .when_constraint_set_assignable_to_owned(db, env, subject), ), None => (ALWAYS_TRUE, None), }; @@ -791,7 +863,7 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Some(upper) => storage.load( db, env, - &subject.when_constraint_set_assignable_to_owned(db, env, upper), + &subject.when_constraint_set_assignable_to_owned(db, env, upper.ty()), ), None => (ALWAYS_TRUE, None), }; @@ -805,8 +877,7 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { } let mut storage = self.builder.storage.borrow_mut(); - let source_order = storage - .calculate_source_orders(self.source_order) + let source_order = source_orders .into_iter() .fold(None, |source_order, constraint| { mapped_constraints.get(&constraint).map_or( @@ -858,74 +929,15 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { .negate(db, builder) } - /// Computes solutions for each BDD path, using a caller-provided hook to select solutions. - /// - /// The `choose` hook is called for each typevar on each BDD path with the typevar's variance - /// and explicit lower and upper bounds. It returns: - /// - `Some(ty)` to use `ty` as the solution for this typevar on this path - /// - `None` to fall back to the default solution selection logic - /// - /// For multi-path BDDs, the hook is called per-path. The caller is responsible for combining - /// results across paths (typically via union). - pub(crate) fn solutions( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - builder: &'c ConstraintSetBuilder<'db>, - inferable: TypeVarSet<'db>, - ) -> Solutions<'db> { - self.solutions_with(db, env, builder, inferable, |_variance, path_bound| { - PathBounds::default_solve(db, env, builder, path_bound) - }) - } - - pub(crate) fn solutions_with( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - builder: &'c ConstraintSetBuilder<'db>, - inferable: TypeVarSet<'db>, - choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> Result>, ()>, - ) -> Solutions<'db> { - self.verify_builder(builder); - let mut storage = builder.storage.borrow_mut(); - let path_bounds = PathBounds::compute( - db, - env, - &mut storage, - self.node, - inferable, - self.source_order, - ); - drop(storage); - path_bounds.solve_with(choose) - } - pub(crate) fn display( self, db: &'db dyn Db, env: &'c ProgramEnvironment<'db>, ) -> impl Display + 'c { - struct DisplayConstraintSet<'c, 'db> { - node: NodeId, - db: &'db dyn Db, - env: &'c ProgramEnvironment<'db>, - builder: &'c ConstraintSetBuilder<'db>, - } - - impl Display for DisplayConstraintSet<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let storage = self.builder.storage.borrow(); - Display::fmt(&self.node.display(self.db, self.env, &storage), f) - } - } - - DisplayConstraintSet { - node: self.node, - db, - env, - builder: self.builder, - } + std::fmt::from_fn(move |f| { + let storage = self.builder.storage.borrow(); + self.node.display(db, env, &storage).fmt(f) + }) } #[expect(dead_code)] // Keep this around for debugging purposes @@ -939,33 +951,10 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { 'db: 'a, 'c: 'a, { - struct DisplayConstraintSet<'a, 'c, 'db> { - node: NodeId, - prefix: &'a dyn Display, - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - builder: &'c ConstraintSetBuilder<'db>, - } - - impl Display for DisplayConstraintSet<'_, '_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let storage = self.builder.storage.borrow(); - Display::fmt( - &self - .node - .display_graph(self.db, self.env, &storage, self.prefix), - f, - ) - } - } - - DisplayConstraintSet { - node: self.node, - prefix, - db, - env, - builder: self.builder, - } + std::fmt::from_fn(move |f| { + let storage = self.builder.storage.borrow(); + self.node.display_graph(db, env, &storage, prefix).fmt(f) + }) } } @@ -1027,7 +1016,7 @@ struct ConstraintSetStorage<'db> { /// /// The ordering of typevars within this arena defines which typevars can be the lower/upper /// bounds of another (e.g., whether we encode `T ≤ U` as `Never ≤ T ≤ U` or `T ≤ U ≤ object`). - typevars: IndexVec>, + typevars: IndexVec>, /// The BDD nodes that appear in any of the constraint sets constructed in this builder. nodes: IndexVec, @@ -1041,8 +1030,8 @@ struct ConstraintSetStorage<'db> { /// appear in the source code. This ensures that any union and intersections types that appear /// in solutions are constructed in a stable (and source-consistent) order. /// - /// This is encoded as a binary tree over [`ConstraintId`]s. A preorder traversal of that tree - /// defines the ordering. + /// This is encoded as an interned binary DAG over [`ConstraintId`]s. The first occurrence of + /// each constraint in a left-first traversal defines the ordering. source_orders: IndexVec, // Everything below are the memoization tables for the arenas and for our BDD operations. @@ -1071,7 +1060,7 @@ struct ConstraintSetStorage<'db> { constraint_set_subtype_cache: FxHashMap<(Type<'db>, Type<'db>), bool>, } -impl ConstraintSetStorage<'_> { +impl<'db> ConstraintSetStorage<'db> { fn ensure_overlay_identity_caches(&mut self) { let Some(compacted) = &self.compacted else { return; @@ -1080,12 +1069,6 @@ impl ConstraintSetStorage<'_> { return; } - self.typevar_cache.extend( - compacted - .typevars - .iter_enumerated() - .map(|(id, typevar)| (*typevar, id)), - ); self.constraint_cache.extend( compacted .constraint_indices @@ -1110,6 +1093,23 @@ impl ConstraintSetStorage<'_> { ); } + // This is a separate method from `ensure_overlay_identity_caches` because it requires a `db`. + fn ensure_overlay_typevar_identity_cache(&mut self, db: &'db dyn Db) { + let Some(compacted) = &self.compacted else { + return; + }; + if !self.typevar_cache.is_empty() { + return; + } + + self.typevar_cache.extend( + compacted + .typevars + .iter_enumerated() + .map(|(id, typevar)| (typevar.identity(db), id)), + ); + } + fn adjusted_node_id(&self, id: NodeId) -> NodeId { if let Some(compacted) = &self.compacted { return id + compacted.node_indices.len(); @@ -1175,10 +1175,10 @@ impl<'db> ConstraintSetBuilder<'db> { .expect("non-terminal BDD should have source_order"); // Combining constraint sets can allocate a new source-order tree even when the BDD is - // unchanged. Preserve each constraint's first source position, but rebuild the persisted - // sidecar densely so redundant combinations cannot affect its IDs or owned-set equality. - // Unlike node and constraint IDs, source-order IDs are not embedded in the BDD, so the - // sidecar can be rebuilt without remapping the BDD. + // unchanged. Preserve each relevant constraint's first source position, but rebuild the + // persisted sidecar densely so redundant combinations cannot affect its IDs or owned-set + // equality. Unlike node and constraint IDs, source-order IDs are not embedded in the BDD, + // so the sidecar can be rebuilt without remapping the BDD. let mut storage = self.storage.into_inner(); let source_constraints = storage.calculate_source_orders(Some(source_order)); @@ -1207,10 +1207,27 @@ impl<'db> ConstraintSetBuilder<'db> { let mut source_orders: IndexVec = IndexVec::with_capacity(source_constraints.len().saturating_mul(2).saturating_sub(1)); + let live_support = storage.node_support(node); let source_order = source_constraints .into_iter() .fold(None, |left, source_constraint| { + // Preserve ordering history for absorbed constraints related to the live graph. + // Unrelated history can retain fresh typevars and prevent recursive Salsa queries + // from reaching a fixed point. Incomplete supports may hide a relationship, so + // preserve those entries. + let constraint_support_id = storage.constraint_support_id(source_constraint); + let constraint_support = storage.support_data(constraint_support_id); + if !used_constraints[source_constraint.index()] + && let Some(live_support) = live_support + && live_support.is_complete() + && constraint_support.is_complete() + && !constraint_support.overlaps_with(live_support) + { + return left; + } used_constraints.set(source_constraint.index(), true); + // Source-order-only constraints are reloaded too, so retain their supports. + used_supports.set(constraint_support_id.index(), true); let right = source_orders.push(SourceOrder::Constraint(source_constraint)); Some(match left { @@ -1305,12 +1322,13 @@ impl<'db> ConstraintSetBuilder<'db> { impl<'db> ConstraintSetStorage<'db> { /// Interns a single typevar, giving it a stable order in this builder fn intern_typevar(&mut self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarId { - let identity = typevar.identity(db); self.ensure_overlay_identity_caches(); + self.ensure_overlay_typevar_identity_cache(db); + let identity = typevar.identity(db); if let Some(id) = self.typevar_cache.get(&identity) { return *id; } - let id = self.typevars.push(identity); + let id = self.typevars.push(typevar); let id = self.adjusted_typevar_id(id); self.typevar_cache.insert(identity, id); id @@ -1340,6 +1358,15 @@ impl<'db> ConstraintSetStorage<'db> { false } + fn notify_skipped_lazy_type_attributes(&self) { + self.support.borrow_mut().mark_incomplete(); + } + + fn visit_type_var_type(&self, _db: &'db dyn Db, _typevar: TypeVarInstance<'db>) { + // Declaration bounds, constraints, and defaults are not occurrences in the + // constraint itself and must not contribute to its support. + } + fn visit_generic_alias_type(&self, db: &'db dyn Db, alias: GenericAlias<'db>) { for ty in alias.specialization(db).types(db) { self.visit_type(db, *ty); @@ -1371,16 +1398,12 @@ impl<'db> ConstraintSetStorage<'db> { &mut self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, - typevar: BoundTypeVarInstance<'db>, - bounds: ConstraintBounds<'db>, + constraint: Constraint<'db>, ) -> Support { let mut support = Support::default(); - support.insert(self.intern_typevar(db, typevar)); - if let Some(lower) = bounds.lower { - self.intern_mentioned_typevars_in_type(db, env, lower, &mut support); - } - if let Some(upper) = bounds.upper { - self.intern_mentioned_typevars_in_type(db, env, upper, &mut support); + support.insert(self.intern_typevar(db, constraint.typevar())); + for bound in constraint.iter_stored_bounds() { + self.intern_mentioned_typevars_in_type(db, env, bound.ty(), &mut support); } support } @@ -1391,7 +1414,7 @@ impl<'db> ConstraintSetStorage<'db> { env: &ProgramEnvironment<'db>, data: Constraint<'db>, ) -> ConstraintId { - let support = self.intern_constraint_typevars(db, env, data.typevar, data.bounds); + let support = self.intern_constraint_typevars(db, env, data); self.ensure_overlay_identity_caches(); if let Some(id) = self.constraint_cache.get(&data) { @@ -1428,6 +1451,7 @@ impl<'db> ConstraintSetStorage<'db> { fn typevar_id(&mut self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarId { let identity = typevar.identity(db); self.ensure_overlay_identity_caches(); + self.ensure_overlay_typevar_identity_cache(db); self.typevar_cache .get(&identity) .copied() @@ -1462,32 +1486,6 @@ impl<'db> ConstraintSetStorage<'db> { depth } - /// Returns how much sequent fuel is needed to derive this constraint. - /// - /// This cost is driven by two factors. - /// - /// First, nested types containing typevars can produce increasingly complex families of - /// derived constraints. Charge more fuel for those constraints so that each additional level - /// of typevar depth shortens the remaining derivation chain. - /// - /// Second, even without considering typevars, the lower and upper bounds can become more - /// structurally complex. We consider a type to be more complex if it has deeper nesting of - /// type constructors. Each sequent is charged the _increase_ in that complexity between its - /// antecedents and its consequent. (Measuring growth rather than absolute depth avoids - /// penalizing a complex concrete bound that is merely propagated unchanged.) - fn sequent_fuel_cost( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - constraint: ConstraintId, - antecedent_constructor_depth: u16, - ) -> u16 { - let (constructor_depth, typevar_depth) = - self.cached_constraint_bound_depth(db, env, constraint); - let constructor_growth = constructor_depth.saturating_sub(antecedent_constructor_depth); - typevar_depth.max(constructor_growth).saturating_add(1) - } - fn cached_constraint_implies( &mut self, db: &'db dyn Db, @@ -1583,26 +1581,24 @@ impl<'db> ConstraintSetStorage<'db> { &self, source_order: Option, ) -> FxIndexSet { - fn walk( - storage: &ConstraintSetStorage, - current: SourceOrderId, - result: &mut FxIndexSet, - ) { - match storage.source_order_data(current) { + // Source-order sidecars share interned subtrees. Revisiting a subtree cannot contribute + // an earlier occurrence of any constraint, and can expand a small DAG exponentially. + let mut pending = Vec::from_iter(source_order); + let mut visited = FxHashSet::default(); + let mut result = FxIndexSet::default(); + while let Some(current) = pending.pop() { + if !visited.insert(current) { + continue; + } + match self.source_order_data(current) { SourceOrder::Ordered(left, right) => { - walk(storage, left, result); - walk(storage, right, result); + pending.extend([right, left]); } SourceOrder::Constraint(constraint) => { result.insert(constraint); } } } - - let mut result = FxIndexSet::default(); - if let Some(source_order) = source_order { - walk(self, source_order, &mut result); - } result } @@ -1611,7 +1607,7 @@ impl<'db> ConstraintSetStorage<'db> { self.adjusted_support_id(id) } - fn typevar_data(&self, typevar: TypeVarId) -> BoundTypeVarIdentity<'db> { + fn typevar_data(&self, typevar: TypeVarId) -> BoundTypeVarInstance<'db> { if let Some(compacted) = &self.compacted { let index = typevar.index(); let split = compacted.typevars.len(); @@ -1653,6 +1649,17 @@ impl<'db> ConstraintSetStorage<'db> { self.support_data(self.constraint_support_id(constraint)) } + fn constraint_mentions_typevars( + &self, + db: &'db dyn Db, + constraint: ConstraintId, + typevars: TypeVarSet<'db>, + ) -> bool { + self.constraint_support(constraint) + .iter() + .any(|typevar| self.typevar_data(typevar).is_inferable(db, typevars)) + } + fn node_support_id(&self, node: NodeId) -> Option { if node.is_terminal() { return None; @@ -1722,10 +1729,20 @@ impl<'db> ConstraintSetStorage<'db> { .as_ref() .expect("storage-free owned constraint sets must have terminal roots"); - // Load all of the constraints into the this storage first, to maximize the chance that the - // constraints and typevars will appear in the same order. (This is important because many - // of our mdtests try to force a particular ordering, to test that our algorithms are all - // order-independent.) + // Restore the saved order of referenced typevars before rebuilding constraints. A stored + // `T <= U` can have `U` as its subject and `T` as its lower bound. Interning that subject + // first would reverse the original typevar order, causing successive loads to alternate + // between equivalent representations and preventing recursive Salsa queries from converging. + // Keep existing destination IDs, and omit typevars used only by discarded constraints. + let mut referenced_typevars = Support::default(); + for support in &inner.constraint_supports { + referenced_typevars |= &inner.supports[inner.retained_support_index(*support)]; + } + for typevar in referenced_typevars.iter() { + self.intern_typevar(db, inner.typevars[typevar]); + } + + // Rebuild constraints in their saved order, using the destination's typevar ordering. let constraints: Box<[_]> = inner .constraints .iter() @@ -1735,8 +1752,8 @@ impl<'db> ConstraintSetStorage<'db> { env, self, old_constraint.typevar, - old_constraint.bounds.lower, - old_constraint.bounds.upper, + old_constraint.stored_lower_bound(), + old_constraint.stored_upper_bound(), ) }) .collect(); @@ -1847,7 +1864,7 @@ pub struct ConstraintId; #[derive(get_size2::GetSize)] struct SourceOrderId; -/// The nodes of the tree that defines source ordering for a constraint set. +/// The nodes of the DAG that defines source ordering for a constraint set. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] enum SourceOrder { Ordered(SourceOrderId, SourceOrderId), @@ -1862,151 +1879,388 @@ pub(crate) struct Constraint<'db> { bounds: ConstraintBounds<'db>, } -/// The explicit lower and upper bounds inferred for a typevar on one constraint path. -/// -/// Missing bounds are represented as `None`; callers can materialize them to the logical defaults -/// (`Never` for lower bounds, `object` for upper bounds) when they need to reason about -/// satisfiability. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -pub(crate) struct ConstraintBounds<'db> { - pub(crate) lower: Option>, - pub(crate) upper: Option>, -} +impl<'db> Constraint<'db> { + fn new( + typevar: BoundTypeVarInstance<'db>, + lower: Option>, + upper: Option>, + ) -> Self { + Self { + typevar, + bounds: ConstraintBounds::new(lower, upper), + } + } -impl<'db> ConstraintBounds<'db> { - pub(crate) fn new(lower: Option>, upper: Option>) -> Self { - Self { lower, upper } + /// Records supplied endpoints as inference evidence, including explicit `Never` and `object`. + pub(crate) fn from_evidence( + typevar: BoundTypeVarInstance<'db>, + lower: Option>, + upper: Option>, + ) -> Self { + Self::new( + typevar, + lower.map(ConstraintBound::Evidence), + upper.map(ConstraintBound::Evidence), + ) + } + + pub(crate) fn exact(typevar: BoundTypeVarInstance<'db>, ty: Type<'db>) -> Self { + Self { + typevar, + bounds: ConstraintBounds::exact(ty), + } } - pub(crate) fn exact(ty: Type<'db>) -> Self { - Self::new(Some(ty), Some(ty)) + pub(crate) fn typevar(self) -> BoundTypeVarInstance<'db> { + self.typevar } - fn has_lower(self) -> bool { - self.lower.is_some() + /// Returns the effective lower endpoint with its provenance. + /// + /// An absent endpoint defaults to `Validity(Never)`, or a validity bound for the bottom + /// parameter list of a bare `ParamSpec`. Explicit `Evidence(Never)` is returned unchanged. + fn lower_bound(self, db: &'db dyn Db) -> ConstraintBound<'db> { + self.stored_lower_bound() + .unwrap_or_else(|| ConstraintBound::Validity(self.default_lower_bound(db))) } - fn has_upper(self) -> bool { - self.upper.is_some() + /// Returns the effective upper endpoint with its provenance. + /// + /// An absent endpoint defaults to `Validity(object)`, or a validity bound for the top + /// parameter list of a bare `ParamSpec`. Explicit `Evidence(object)` is returned unchanged. + fn upper_bound(self, db: &'db dyn Db) -> ConstraintBound<'db> { + self.stored_upper_bound() + .unwrap_or_else(|| ConstraintBound::Validity(self.default_upper_bound(db))) } - fn as_equality(self) -> Option> { - let lower = self.lower?; - let upper = self.upper?; - (lower == upper).then_some(lower) + fn default_lower_bound(self, db: &'db dyn Db) -> Type<'db> { + if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { + Type::paramspec_value_callable(db, Parameters::bottom()) + } else { + Type::Never + } + } + + fn default_upper_bound(self, db: &'db dyn Db) -> Type<'db> { + if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { + Type::paramspec_value_callable(db, Parameters::top()) + } else { + Type::object() + } + } + + /// Returns the stored lower endpoint with its provenance, or `None` if absent. + /// + /// Use this to test for absence or copy bounds without inserting a default. + fn stored_lower_bound(self) -> Option> { + self.bounds.lower + } + + /// Returns the stored upper endpoint with its provenance, or `None` if absent. + /// + /// Use this to test for absence or copy bounds without inserting a default. + fn stored_upper_bound(self) -> Option> { + self.bounds.upper + } + + /// Visits supplied endpoints in lower-then-upper order without inserting defaults. + fn iter_stored_bounds(self) -> impl Iterator> { + iter::chain(self.stored_lower_bound(), self.stored_upper_bound()) } - fn materialized_lower(self) -> Type<'db> { - self.lower.unwrap_or(Type::Never) + fn as_equality(self) -> Option> { + self.bounds.as_equality() } - fn materialized_upper(self) -> Type<'db> { - self.upper.unwrap_or(Type::object()) + fn has_concrete_bounds(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + self.bounds.is_concrete(db, env) } } -/// A factored conjunction of upper-bound clauses accumulated for one typevar. +/// The lower or upper bound of a constraint, along with its _provenance_ /// -/// Each stored type is one clause in the conjunction that forms the upper bound. Importantly, each -/// clause may be a union. This keeps bounds such as `(A | B) & (C | D)` factored in a CNF-like -/// form instead of immediately converting them to the DNF representation that [`Type`] uses. +/// Most bounds come from specific relationships found at the call site — for instance, the +/// relationship between the argument type and parameter annotation when invoking a generic +/// function. These bounds express actual user intent, and are called _evidence_ bounds. /// -/// An empty `UpperBound` represents a _missing_ upper bound, which (in the absence of other -/// constraints) we solve to `Unknown`. An upper bound of `object` is treated as an explicit -/// request for "any type" as a solution, so we solve it to `object`. +/// Other bounds are background limitations on which specializations are valid — for instance, a +/// typevar's declared `bound_or_constraints`. These are called _validity_ bounds. Importantly, we +/// don't want to choose a validity bound as a solution unless we have no other choice. There is +/// often an evidence bound that is a better choice. /// -/// Redundant clauses are retained while accumulating the bound, avoiding repeated relation checks -/// for every newly discovered clause. Consumers that require one effective bound can recover it -/// with [`UpperBound::as_single_bound`] without eagerly expanding large intersections of unions. -#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -pub(crate) struct UpperBound<'db> { - clauses: FxOrderSet>, +/// A bound derived only from validity remains validity. Any derivation that also depends on +/// evidence is itself evidence. +/// +/// Missing endpoints are stored as `None`. [`Constraint`] supplies validity defaults appropriate +/// for its typevar: `Never`/`object` for ordinary types, and bottom/top parameter lists for bare +/// `ParamSpec`s. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +enum ConstraintBound<'db> { + Validity(Type<'db>), + Evidence(Type<'db>), } -impl<'db> UpperBound<'db> { - fn none() -> Self { - Self::default() +impl<'db> ConstraintBound<'db> { + /// The ordinary lower identity used by storage canonicalization and path aggregation. + const fn missing_lower() -> Self { + Self::Validity(Type::Never) } - /// Creates an upper bound from one explicit clause. - /// - /// This preserves an explicit `object` clause so callers can distinguish `T <= object` from a - /// missing upper bound. Use [`UpperBound::add_clause`] when accumulating multiple clauses. - fn from_clause(clause: Type<'db>) -> Self { - let clauses = FxOrderSet::from_iter([clause]); - Self { clauses } + /// The ordinary upper identity used by storage canonicalization and path aggregation. + const fn missing_upper() -> Self { + Self::Validity(Type::object()) } - fn is_empty(&self) -> bool { - self.clauses.is_empty() + fn ty(self) -> Type<'db> { + match self { + Self::Validity(ty) | Self::Evidence(ty) => ty, + } } - fn has_explicit_bound(&self) -> bool { - !self.is_empty() + const fn is_missing_lower(self) -> bool { + matches!(self, Self::Validity(Type::Never)) } - /// Returns an existing upper-bound clause if every other clause is redundant with it. - /// - /// This preserves constrained type variables without distributing unions: expanding - /// `S & (int | str)` into `(S & int) | (S & str)` would otherwise lose `S` as the single - /// effective bound. Returns `None` instead of materializing intersections when no existing - /// clause dominates the others. A missing bound remains distinct from an explicit `object`. - pub(crate) fn as_single_bound( - &self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ) -> Option> { - let mut clauses = self.clauses.iter().copied(); - let first = clauses.next()?; - let candidate = clauses.fold(first, |candidate, clause| { - if candidate.is_redundant_with(db, env, clause) { - candidate - } else { - clause - } - }); + const fn is_missing_upper(self) -> bool { + matches!(self, Self::Validity(Type::NominalInstance(instance)) if instance.is_object()) + } - self.clauses - .iter() - .all(|clause| candidate.is_redundant_with(db, env, *clause)) - .then_some(candidate) + fn map(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + match self { + Self::Validity(ty) => Self::Validity(f(ty)), + Self::Evidence(ty) => Self::Evidence(f(ty)), + } } - fn is_never(&self) -> bool { - self.clauses.len() == 1 && self.clauses.contains(&Type::Never) + fn with_type(self, ty: Type<'db>) -> Self { + self.map(|_| ty) } - fn add_clause(&mut self, clause: Type<'db>) { - if self.is_never() { - return; + /// Creates a bound produced by mathematically combining `lhs` and `rhs`. + /// + /// If one operand already equals the result, that operand alone establishes the combined + /// bound, so its provenance is retained. Otherwise, the result is validity only if both + /// operands are validity. + fn from_combination(combined: Type<'db>, lhs: Self, rhs: Self) -> Self { + match (combined == lhs.ty(), combined == rhs.ty()) { + (true, false) => lhs.with_type(combined), + (false, true) => rhs.with_type(combined), + _ => match (lhs, rhs) { + (Self::Validity(_), Self::Validity(_)) => Self::Validity(combined), + _ => Self::Evidence(combined), + }, } + } - if clause.is_never() { - self.clauses.clear(); - self.clauses.insert(Type::Never); - return; + /// Creates a bound derived by transitivity from two constraint bounds. + /// + /// Unlike a union or intersection on one typevar, neither premise is redundant merely because + /// the result has the same type as one of them. For example, deriving `int ≤ S` from + /// `int ≤ T` and `T ≤ S` requires both premises even though the resulting bound type is still + /// `int`. + /// + /// The result depends on both premises, so it is validity if both premises are validity and + /// evidence otherwise. + fn from_transitive_derivation(combined: Type<'db>, lhs: Self, rhs: Self) -> Self { + match (lhs, rhs) { + (Self::Validity(_), Self::Validity(_)) => Self::Validity(combined), + _ => Self::Evidence(combined), + } + } + + /// Applies the source range's provenance to an evidence bound derived by comparing that range. + /// Bounds not produced by the comparison retain their existing provenance. + /// + /// Missing source endpoints supply only validity bounds, so the derived bound remains evidence + /// exactly when at least one stored source endpoint is evidence. + fn with_source_provenance(self, source: Constraint<'db>) -> Self { + match self { + Self::Evidence(ty) + if !source + .iter_stored_bounds() + .any(|bound| matches!(bound, Self::Evidence(_))) => + { + Self::Validity(ty) + } + _ => self, + } + } +} + +/// The lower and upper bounds for a typevar on one constraint path. +/// +/// Missing bounds are stored as `None`, making equality and hashing cheaper for this common case. +/// Ordinary validity identities (`Never`/`object`) are canonicalized to absence. The owning +/// [`Constraint`] supplies effective defaults appropriate for its typevar. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +struct ConstraintBounds<'db> { + lower: Option>, + upper: Option>, +} + +impl<'db> ConstraintBounds<'db> { + fn new(lower: Option>, upper: Option>) -> Self { + // Canonicalize missing lower/upper bounds so that we always store them as `None`, instead + // of `Some(Validity(Never/object))`. + Self { + lower: lower.filter(|bound| !bound.is_missing_lower()), + upper: upper.filter(|bound| !bound.is_missing_upper()), + } + } + + fn exact(ty: Type<'db>) -> Self { + Self::new( + Some(ConstraintBound::Evidence(ty)), + Some(ConstraintBound::Evidence(ty)), + ) + } + + fn as_equality(self) -> Option> { + let lower = self.lower?.ty(); + let upper = self.upper?.ty(); + (lower == upper).then_some(lower) + } + + fn is_concrete(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + iter::chain(self.lower, self.upper).all(|bound| { + let bound = bound.ty(); + !bound.has_typevar(db, env) + && !bound.has_provisional_marker(db, env) + && bound.bottom_materialization(db, env) == bound.top_materialization(db, env) + }) + } +} + +/// A factored conjunction of upper-bound clauses accumulated for one typevar. +/// +/// Validity and evidence clauses are stored separately. Clauses may be unions, keeping +/// bounds such as `(A | B) & (C | D)` factored rather than distributing them into the DNF +/// representation used by [`Type`]. +/// +/// An empty validity set represents an unconstrained validity upper bound of `object`. This avoids +/// allocating or checking the intersection identity on every path. An explicit evidence bound of +/// `object` remains meaningful because evidence and validity clauses are stored separately. +/// +/// Redundant clauses are retained to preserve evidence even when a validity restriction is +/// stronger. Consumers that require one effective bound can recover it with +/// [`UpperBound::as_single_bound`] without eagerly expanding intersections of unions. +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +struct UpperBound<'db> { + evidence: FxOrderSet>, + validity: FxOrderSet>, +} + +impl<'db> UpperBound<'db> { + fn unconstrained() -> Self { + Self::default() + } + + /// Creates an upper bound from one explicit evidence clause. + fn from_clause(clause: Type<'db>) -> Self { + let mut upper = Self::default(); + upper.evidence.insert(clause); + upper + } + + fn is_empty(&self) -> bool { + self.evidence.is_empty() && self.validity.is_empty() + } + + fn iter_evidence(&self) -> impl Iterator> + Clone + '_ { + self.evidence.iter().copied().map(ConstraintBound::Evidence) + } + + fn iter_validity(&self) -> impl Iterator> + Clone + '_ { + self.validity.iter().copied().map(ConstraintBound::Validity) + } + + fn iter_clauses(&self) -> impl Iterator> + Clone + '_ { + iter::chain(self.iter_evidence(), self.iter_validity()) + } + + fn has_evidence(&self) -> bool { + !self.evidence.is_empty() + } + + /// Returns an existing upper-bound clause if every other clause is redundant with it. + /// + /// This preserves constrained type variables without distributing unions: expanding + /// `S & (int | str)` into `(S & int) | (S & str)` would otherwise lose `S` as the single + /// effective bound. Returns `None` instead of materializing intersections when no existing + /// clause dominates the others. An unconstrained validity bound remains distinct from an + /// explicit evidence bound of `object`. + fn as_single_bound(&self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Option> { + let clauses = self.iter_clauses(); + if clauses.clone().next().is_none() { + Some(Type::object()) + } else { + Self::single_bound_from_iterator(db, env, clauses) + } + } + + fn single_bound_from_iterator( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + mut clauses: impl Iterator> + Clone, + ) -> Option> { + let candidate = clauses + .clone() + .map(ConstraintBound::ty) + .reduce(|candidate, clause| { + if candidate.is_redundant_with(db, env, clause) { + candidate + } else { + clause + } + })?; + + clauses + .all(|clause| candidate.is_redundant_with(db, env, clause.ty())) + .then_some(candidate) + } + + fn add_clause(&mut self, clause: ConstraintBound<'db>) { + if clause == ConstraintBound::missing_upper() + || (matches!(clause, ConstraintBound::Evidence(_)) + && self.evidence.contains(&Type::Never)) + { + return; + } + + match clause { + ConstraintBound::Evidence(Type::Never) => { + self.evidence.clear(); + self.evidence.insert(Type::Never); + } + ConstraintBound::Validity(Type::Never) => { + self.validity.clear(); + self.validity.insert(Type::Never); + } + ConstraintBound::Evidence(ty) => { + self.evidence.insert(ty); + } + ConstraintBound::Validity(ty) => { + if !self.validity.contains(&Type::Never) { + self.validity.insert(ty); + } + } } - - self.clauses.insert(clause); } fn shrink_to_fit(&mut self) { - self.clauses.shrink_to_fit(); + self.evidence.shrink_to_fit(); + self.validity.shrink_to_fit(); } /// Exact conversion to an ordinary [`Type`]. This may be expensive: if any stored clause is a /// union, [`IntersectionType::from_elements`] converts this factored CNF representation into /// ty's ordinary DNF representation by distributing intersections over unions. - pub(crate) fn materialize_exact( - &self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ) -> Type<'db> { - IntersectionType::from_elements(db, env, self.clauses.iter().copied()) + fn materialize_exact(&self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + IntersectionType::from_elements(db, env, self.iter_clauses().map(ConstraintBound::ty)) } fn has_visible_union_clause(&self) -> bool { - self.clauses.iter().copied().any(Type::is_union) + self.iter_clauses().any(|clause| clause.ty().is_union()) } fn is_satisfied_by( @@ -2015,9 +2269,8 @@ impl<'db> UpperBound<'db> { env: &ProgramEnvironment<'db>, ty: Type<'db>, ) -> bool { - self.clauses - .iter() - .all(|clause| ty.is_constraint_set_assignable_to(db, env, *clause)) + self.iter_clauses() + .all(|clause| ty.is_constraint_set_assignable_to(db, env, clause.ty())) } /// Returns the constraints under which `lower` is assignable to every stored upper clause. @@ -2030,8 +2283,8 @@ impl<'db> UpperBound<'db> { ) -> (NodeId, Option) { let mut node = ALWAYS_TRUE; let mut source_order = None; - for clause in &self.clauses { - let when_clause = lower.when_constraint_set_assignable_to_owned(db, env, *clause); + for clause in self.iter_clauses() { + let when_clause = lower.when_constraint_set_assignable_to_owned(db, env, clause.ty()); let (clause_node, clause_source_order) = storage.load(db, env, &when_clause); node = node.and(storage, clause_node); source_order = storage.ordered_source_order(source_order, clause_source_order); @@ -2052,7 +2305,14 @@ impl ConstraintId { lower: Type<'db>, upper: Type<'db>, ) -> ConstraintId { - Self::new_with_bounds(db, env, storage, typevar, Some(lower), Some(upper)) + Self::new_with_bounds( + db, + env, + storage, + typevar, + Some(ConstraintBound::Evidence(lower)), + Some(ConstraintBound::Evidence(upper)), + ) } fn new_with_bounds<'db>( @@ -2060,17 +2320,10 @@ impl ConstraintId { env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, typevar: BoundTypeVarInstance<'db>, - lower: Option>, - upper: Option>, + lower: Option>, + upper: Option>, ) -> ConstraintId { - storage.intern_constraint( - db, - env, - Constraint { - typevar, - bounds: ConstraintBounds::new(lower, upper), - }, - ) + storage.intern_constraint(db, env, Constraint::new(typevar, lower, upper)) } } @@ -2151,7 +2404,7 @@ pub(crate) fn max_constructor_and_typevar_depth<'db>( impl<'db> Constraint<'db> { fn bound_depth(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> (u16, u16) { - let both_bounds = iter::chain(self.bounds.lower, self.bounds.upper); + let both_bounds = self.iter_stored_bounds().map(ConstraintBound::ty); both_bounds.fold((0, 0), |(constructor_depth, typevar_depth), bound| { let (bound_constructor_depth, bound_typevar_depth) = max_constructor_and_typevar_depth(db, env, bound); @@ -2180,16 +2433,14 @@ impl<'db> Constraint<'db> { keeps_lower || keeps_upper } - /// Returns a new range constraint, preserving whether each bound was present explicitly. - /// - /// Panics if present `lower` and `upper` bounds are not fully static. + /// Returns a new range constraint, preserving the presence and provenance of both bounds. fn new_node_with_bounds( db: &'db dyn Db, env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, typevar: BoundTypeVarInstance<'db>, - mut lower: Option>, - mut upper: Option>, + mut lower: Option>, + mut upper: Option>, ) -> (NodeId, Option) { if lower.is_none() && upper.is_none() { return (ALWAYS_TRUE, None); @@ -2203,7 +2454,9 @@ impl<'db> Constraint<'db> { // T ≤ (α & β) ⇔ (T ≤ α) ∧ (T ≤ β) // T ≤ (¬α & ¬β) ⇔ (T ≤ ¬α) ∧ (T ≤ ¬β) // (α | β) ≤ T ⇔ (α ≤ T) ∧ (β ≤ T) - if let Some(Type::Union(lower_union)) = lower { + if let Some(lower_bound) = lower + && let Type::Union(lower_union) = lower_bound.ty() + { let mut result = ALWAYS_TRUE; let mut source_order = None; for lower_element in lower_union.elements(db) { @@ -2212,7 +2465,7 @@ impl<'db> Constraint<'db> { env, storage, typevar, - Some(*lower_element), + Some(lower_bound.with_type(*lower_element)), upper, ); result = result.and(storage, element_node); @@ -2223,7 +2476,8 @@ impl<'db> Constraint<'db> { // A negated type ¬α is represented as an intersection with no positive elements, and a // single negative element. We _don't_ want to treat that an "intersection" for the // purposes of simplifying upper bounds. - if let Some(Type::Intersection(upper_intersection)) = upper + if let Some(upper_bound) = upper + && let Type::Intersection(upper_intersection) = upper_bound.ty() && !upper_intersection.is_simple_negation(db) { let mut result = ALWAYS_TRUE; @@ -2235,7 +2489,7 @@ impl<'db> Constraint<'db> { storage, typevar, lower, - Some(upper_element), + Some(upper_bound.with_type(upper_element)), ); result = result.and(storage, element_node); source_order = storage.ordered_source_order(source_order, element_source_order); @@ -2247,7 +2501,7 @@ impl<'db> Constraint<'db> { storage, typevar, lower, - Some(upper_element.negate(db, env)), + Some(upper_bound.with_type(upper_element.negate(db, env))), ); result = result.and(storage, element_node); source_order = storage.ordered_source_order(source_order, element_source_order); @@ -2257,7 +2511,7 @@ impl<'db> Constraint<'db> { // Two identical typevars must always solve to the same type, so it is not useful to have // an upper or lower bound that is the typevar being constrained. - match lower { + match lower.map(ConstraintBound::ty) { Some(Type::TypeVar(lower_bound_typevar)) if typevar.is_same_typevar_as(db, lower_bound_typevar) => { @@ -2287,7 +2541,7 @@ impl<'db> Constraint<'db> { } _ => {} } - match upper { + match upper.map(ConstraintBound::ty) { Some(Type::TypeVar(upper_bound_typevar)) if typevar.is_same_typevar_as(db, upper_bound_typevar) => { @@ -2305,20 +2559,22 @@ impl<'db> Constraint<'db> { _ => {} } - storage.intern_constraint_typevars(db, env, typevar, ConstraintBounds::new(lower, upper)); + let constraint = Constraint::new(typevar, lower, upper); + storage.intern_constraint_typevars(db, env, constraint); // If `lower ≰ upper` for every possible assignment of typevars, then the constraint cannot // be satisfied, since there is no type that is both greater than `lower`, and less than // `upper`. We use an existential check here ("is there *some* assignment where // `lower ≤ upper`?") rather than a universal check, because the bounds may mention // typevars — e.g., `Sequence[int] ≤ A ≤ Sequence[T]` is satisfiable when `int ≤ T`. - let effective_lower = lower.unwrap_or(Type::Never); - let effective_upper = upper.unwrap_or(Type::object()); - let when = - effective_lower.when_constraint_set_assignable_to_owned(db, env, effective_upper); - let is_never_satisfied = when.query(|_storage, when| when.is_never_satisfied(db, env)); - if is_never_satisfied { - return (ALWAYS_FALSE, None); + let effective_lower = constraint.lower_bound(db).ty(); + let effective_upper = constraint.upper_bound(db).ty(); + if lower.is_some() && upper.is_some() { + let when = + effective_lower.when_constraint_set_assignable_to_owned(db, env, effective_upper); + if when.query(|_storage, when| when.is_never_satisfied(db, env)) { + return (ALWAYS_FALSE, None); + } } // We have an (arbitrary) ordering for typevars. If the upper and/or lower bounds are @@ -2329,35 +2585,39 @@ impl<'db> Constraint<'db> { // therefore the typevar that the constraint applies to. match (effective_lower, effective_upper) { // L ≤ T ≤ L == (T ≤ [L] ≤ T) - (Type::TypeVar(lower), Type::TypeVar(upper)) if lower.is_same_typevar_as(db, upper) => { - let (bound, typevar) = if lower.can_be_bound_for(db, storage, typevar) { - (lower, typevar) - } else { - (typevar, lower) - }; - let constraint = ConstraintId::new( + (Type::TypeVar(lower_typevar), Type::TypeVar(upper_typevar)) + if lower_typevar.is_same_typevar_as(db, upper_typevar) => + { + let (bound, subject, lower, upper) = + if lower_typevar.can_be_bound_for(db, storage, typevar) { + (lower_typevar, typevar, lower, upper) + } else { + (typevar, lower_typevar, upper, lower) + }; + let bound = Type::TypeVar(bound); + let constraint = ConstraintId::new_with_bounds( db, env, storage, - typevar, - Type::TypeVar(bound), - Type::TypeVar(bound), + subject, + lower.map(|lower| lower.with_type(bound)), + upper.map(|upper| upper.with_type(bound)), ); Node::new_constraint(storage, constraint) } // L ≤ T ≤ U == ([L] ≤ T) && (T ≤ [U]) - (Type::TypeVar(lower), Type::TypeVar(upper)) - if typevar.can_be_bound_for(db, storage, lower) - && typevar.can_be_bound_for(db, storage, upper) => + (Type::TypeVar(lower_typevar), Type::TypeVar(upper_typevar)) + if typevar.can_be_bound_for(db, storage, lower_typevar) + && typevar.can_be_bound_for(db, storage, upper_typevar) => { let lower_constraint = ConstraintId::new_with_bounds( db, env, storage, - lower, + lower_typevar, None, - Some(Type::TypeVar(typevar)), + lower.map(|lower| lower.with_type(Type::TypeVar(typevar))), ); let (lower_node, lower_source_order) = Node::new_constraint(storage, lower_constraint); @@ -2365,8 +2625,8 @@ impl<'db> Constraint<'db> { db, env, storage, - upper, - Some(Type::TypeVar(typevar)), + upper_typevar, + upper.map(|upper| upper.with_type(Type::TypeVar(typevar))), None, ); let (upper_node, upper_source_order) = @@ -2378,22 +2638,21 @@ impl<'db> Constraint<'db> { } // L ≤ T ≤ U == ([L] ≤ T) && ([T] ≤ U) - (Type::TypeVar(lower), _) if typevar.can_be_bound_for(db, storage, lower) => { + (Type::TypeVar(lower_typevar), _) + if typevar.can_be_bound_for(db, storage, lower_typevar) => + { let lower_constraint = ConstraintId::new_with_bounds( db, env, storage, - lower, + lower_typevar, None, - Some(Type::TypeVar(typevar)), + lower.map(|lower| lower.with_type(Type::TypeVar(typevar))), ); let (lower_node, lower_source_order) = Node::new_constraint(storage, lower_constraint); - let (upper_node, upper_source_order) = if upper.is_none() { - (ALWAYS_TRUE, None) - } else { - Constraint::new_node_with_bounds(db, env, storage, typevar, None, upper) - }; + let (upper_node, upper_source_order) = + Constraint::new_node_with_bounds(db, env, storage, typevar, None, upper); let node = lower_node.and(storage, upper_node); let source_order = storage.ordered_source_order(lower_source_order, upper_source_order); @@ -2401,18 +2660,17 @@ impl<'db> Constraint<'db> { } // L ≤ T ≤ U == (L ≤ [T]) && (T ≤ [U]) - (_, Type::TypeVar(upper)) if typevar.can_be_bound_for(db, storage, upper) => { - let (lower_node, lower_source_order) = if lower.is_none() { - (ALWAYS_TRUE, None) - } else { - Constraint::new_node_with_bounds(db, env, storage, typevar, lower, None) - }; + (_, Type::TypeVar(upper_typevar)) + if typevar.can_be_bound_for(db, storage, upper_typevar) => + { + let (lower_node, lower_source_order) = + Constraint::new_node_with_bounds(db, env, storage, typevar, lower, None); let upper_constraint = ConstraintId::new_with_bounds( db, env, storage, - upper, - Some(Type::TypeVar(typevar)), + upper_typevar, + upper.map(|upper| upper.with_type(Type::TypeVar(typevar))), None, ); let (upper_node, upper_source_order) = @@ -2491,18 +2749,12 @@ impl ConstraintId { { return false; } - other_constraint - .bounds - .materialized_lower() - .is_constraint_set_assignable_to(db, env, self_constraint.bounds.materialized_lower()) - && self_constraint - .bounds - .materialized_upper() - .is_constraint_set_assignable_to( - db, - env, - other_constraint.bounds.materialized_upper(), - ) + let self_lower = self_constraint.lower_bound(db).ty(); + let self_upper = self_constraint.upper_bound(db).ty(); + let other_lower = other_constraint.lower_bound(db).ty(); + let other_upper = other_constraint.upper_bound(db).ty(); + other_lower.is_constraint_set_assignable_to(db, env, self_lower) + && self_upper.is_constraint_set_assignable_to(db, env, other_upper) } /// Returns the intersection of two range constraints, or `None` if the intersection is empty. @@ -2516,31 +2768,38 @@ impl ConstraintId { let self_constraint = storage.constraint_data(self); let other_constraint = storage.constraint_data(other); - // A typevar cannot be exactly equal to two different types under any specialization. This - // is stronger than checking whether the types are disjoint: two classes can have a common - // subclass, which makes their upper-bound constraints compatible, but that subclass is not - // exactly equal to either class. - if let Some(left) = self_constraint.bounds.as_equality() - && let Some(right) = other_constraint.bounds.as_equality() + // A typevar cannot be exactly equal to two different statically eligible types under any + // specialization. Gradual bounds cannot prove this incompatibility because the resulting + // pair-impossibility sequent participates in transitive closure. + if let Some(left) = self_constraint.as_equality() + && let Some(right) = other_constraint.as_equality() + && left.is_static_sequent_eligible(db, env) + && right.is_static_sequent_eligible(db, env) && !left.can_be_constraint_set_equivalent_to(db, env, right) { return IntersectionResult::Disjoint; } // (s₁ ≤ α ≤ t₁) ∧ (s₂ ≤ α ≤ t₂) = (s₁ ∪ s₂) ≤ α ≤ (t₁ ∩ t₂)) - let lower = match (self_constraint.bounds.lower, other_constraint.bounds.lower) { - (Some(left), Some(right)) => Some(UnionType::from_two_elements(db, env, left, right)), + let lower = match ( + self_constraint.stored_lower_bound(), + other_constraint.stored_lower_bound(), + ) { + (Some(left), Some(right)) => { + let combined = UnionType::from_two_elements(db, env, left.ty(), right.ty()); + Some(ConstraintBound::from_combination(combined, left, right)) + } (Some(lower), None) | (None, Some(lower)) => Some(lower), (None, None) => None, }; - let mut merged_upper = UpperBound::none(); - if let Some(upper) = self_constraint.bounds.upper { + let mut merged_upper = UpperBound::unconstrained(); + if let Some(upper) = self_constraint.stored_upper_bound() { merged_upper.add_clause(upper); } - if let Some(upper) = other_constraint.bounds.upper { + if let Some(upper) = other_constraint.stored_upper_bound() { merged_upper.add_clause(upper); } - let effective_lower = lower.unwrap_or(Type::Never); + let effective_lower = lower.map_or(Type::Never, ConstraintBound::ty); // If `lower ≰ upper` for every possible assignment of typevars, then the intersection is // empty, since there is no type that is both greater than `lower`, and less than `upper`. @@ -2558,20 +2817,34 @@ impl ConstraintId { // intersections, since those can be broken apart into BDDs over simpler constraints. If the // merged upper contains a union clause, keep any useful disjointness result from above but // do not try to derive a factored upper-bound constraint. - if lower.is_some_and(Type::is_union) || merged_upper.has_visible_union_clause() { + if lower.is_some_and(|bound| bound.ty().is_union()) + || merged_upper.has_visible_union_clause() + { return IntersectionResult::CannotSimplify; } - let upper = (!merged_upper.is_empty()).then(|| merged_upper.materialize_exact(db, env)); - - if upper.is_some_and(|upper| upper.is_nontrivial_intersection(db)) { - return IntersectionResult::CannotSimplify; - } + let upper = if merged_upper.is_empty() { + None + } else { + let effective_upper = merged_upper.materialize_exact(db, env); + if effective_upper.is_nontrivial_intersection(db) { + return IntersectionResult::CannotSimplify; + } + match ( + self_constraint.stored_upper_bound(), + other_constraint.stored_upper_bound(), + ) { + (Some(left), Some(right)) => Some(ConstraintBound::from_combination( + effective_upper, + left, + right, + )), + (Some(upper), None) | (None, Some(upper)) => Some(upper.with_type(effective_upper)), + (None, None) => None, + } + }; - IntersectionResult::Simplified(Constraint { - typevar: self_constraint.typevar, - bounds: ConstraintBounds::new(lower, upper), - }) + IntersectionResult::Simplified(Constraint::new(self_constraint.typevar, lower, upper)) } fn display<'db, 'a>( @@ -2642,9 +2915,9 @@ impl NodeId { fn with_uncertain( storage: &mut ConstraintSetStorage<'_>, constraint: ConstraintId, - if_true: NodeId, + mut if_true: NodeId, if_uncertain: NodeId, - if_false: NodeId, + mut if_false: NodeId, ) -> NodeId { debug_assert!( if_true @@ -2672,10 +2945,20 @@ impl NodeId { return ALWAYS_TRUE; } - if if_true == if_false { - if if_true == if_uncertain { - return if_true; + // A guarded branch covered by the uncertain branch adds no satisfying assignments. + // Keep the proof bounded and non-allocating: speculative intersections here can trigger + // further coverage checks and expand a compact disjunction exponentially. + if if_uncertain != ALWAYS_FALSE { + let mut remaining_visits = 64; + if if_true.is_covered_by(storage, if_uncertain, &mut remaining_visits) { + if_true = ALWAYS_FALSE; } + if if_false.is_covered_by(storage, if_uncertain, &mut remaining_visits) { + if_false = ALWAYS_FALSE; + } + } + + if if_true == if_false { if if_true == ALWAYS_FALSE { return if_uncertain; } @@ -2688,14 +2971,6 @@ impl NodeId { // the local equality check has already engaged. } - if if_true == if_uncertain && if_false == ALWAYS_FALSE { - return if_uncertain; - } - - if if_false == if_uncertain && if_true == ALWAYS_FALSE { - return if_uncertain; - } - storage.intern_interior_node(InteriorNodeData { constraint, if_true, @@ -2703,6 +2978,67 @@ impl NodeId { if_false, }) } + + /// Proves coverage using existing TDD branches, without constructing another diagram. + /// + /// This is deliberately incomplete: a branch must be covered by one target alternative, + /// rather than by a union assembled from several alternatives. Exhausting the shared + /// traversal budget also returns false, leaving the original branch unchanged. + fn is_covered_by( + self, + storage: &ConstraintSetStorage<'_>, + other: Self, + remaining_visits: &mut usize, + ) -> bool { + if self == other || self == ALWAYS_FALSE || other == ALWAYS_TRUE { + return true; + } + let Some(remaining) = remaining_visits.checked_sub(1) else { + return false; + }; + *remaining_visits = remaining; + let (Node::Interior(left), Node::Interior(right)) = (self.node(), other.node()) else { + return false; + }; + let left = storage.interior_node_data(left.node()); + let right = storage.interior_node_data(right.node()); + match left.constraint.ordering().cmp(&right.constraint.ordering()) { + Ordering::Less => { + left.if_true.is_covered_by(storage, other, remaining_visits) + && left + .if_uncertain + .is_covered_by(storage, other, remaining_visits) + && left + .if_false + .is_covered_by(storage, other, remaining_visits) + } + Ordering::Equal => { + left.if_uncertain + .is_covered_by(storage, other, remaining_visits) + && (left + .if_true + .is_covered_by(storage, right.if_true, remaining_visits) + || left.if_true.is_covered_by( + storage, + right.if_uncertain, + remaining_visits, + )) + && (left + .if_false + .is_covered_by(storage, right.if_false, remaining_visits) + || left.if_false.is_covered_by( + storage, + right.if_uncertain, + remaining_visits, + )) + } + Ordering::Greater => { + self.is_covered_by(storage, right.if_uncertain, remaining_visits) + || (self.is_covered_by(storage, right.if_true, remaining_visits) + && self.is_covered_by(storage, right.if_false, remaining_visits)) + } + } + } } impl Node { @@ -2835,7 +3171,7 @@ impl NodeId { Node::AlwaysTrue => true, Node::AlwaysFalse => false, Node::Interior(interior) => { - let mut path = interior.path_assignments(storage, source_order); + let mut path = interior.path_assignments(db, env, storage, source_order); path.visit_negated(db, env, storage, self, &mut IsNeverSatisfiedVisitor) .is_continue() } @@ -2876,8 +3212,8 @@ impl NodeId { } let constraint = storage.constraint_data(interior.constraint); - found_lower |= constraint.bounds.lower.is_some(); - found_upper |= constraint.bounds.upper.is_some(); + found_lower |= constraint.stored_lower_bound().is_some(); + found_upper |= constraint.stored_upper_bound().is_some(); if found_lower && found_upper { // Might be a single conjunction, but doesn't contain _only_ // lower-bound-only or upper-bound-only constraints @@ -2901,7 +3237,7 @@ impl NodeId { let result = if simple_conjunction_is_satisfiable(storage, self) { false } else { - let mut path = interior.path_assignments(storage, source_order); + let mut path = interior.path_assignments(db, env, storage, source_order); path.visit(db, env, storage, self, &mut IsNeverSatisfiedVisitor) .is_continue() }; @@ -3037,6 +3373,9 @@ impl NodeId { /// Returns the `and` or intersection of two BDDs. fn and(self, storage: &mut ConstraintSetStorage<'_>, other: Self) -> Self { + if self == other { + return self; + } match (self.node(), other.node()) { (Node::AlwaysFalse, _) | (_, Node::AlwaysFalse) => ALWAYS_FALSE, (Node::AlwaysTrue, _) => other, @@ -3063,15 +3402,6 @@ impl NodeId { a_and_b.or(storage, not_a_and_not_b) } - /// Returns the `if-then-else` of three BDDs: when `self` evaluates to `true`, it returns what - /// `then_node` evaluates to; otherwise it returns what `else_node` evaluates to. - fn ite(self, storage: &mut ConstraintSetStorage<'_>, then_node: Self, else_node: Self) -> Self { - let if_true = self.and(storage, then_node); - let negated = self.negate(storage); - let if_false = negated.and(storage, else_node); - if_true.or(storage, if_false) - } - /// Returns the TDD `if-then-else` of four BDDs: when `self` evaluates to `true`, it returns /// what `then_node` evaluates to; when `self` evaluates to `false`, it returns what /// `else_node` evaluates to; and `uncertain_node` is included regardless of `self`'s value. @@ -3151,14 +3481,16 @@ impl NodeId { storage, bound_typevar, None, - Some(rhs.bottom_materialization(db, env)), + Some(ConstraintBound::Evidence( + rhs.bottom_materialization(db, env), + )), ), (_, Type::TypeVar(bound_typevar)) => Constraint::new_node_with_bounds( db, env, storage, bound_typevar, - Some(lhs.top_materialization(db, env)), + Some(ConstraintBound::Evidence(lhs.top_materialization(db, env))), None, ), _ => panic!("at least one type should be a typevar"), @@ -3168,97 +3500,6 @@ impl NodeId { (node, constraint_source_order) } - fn satisfied_by_all_typevars<'db>( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - inferable: TypeVarSet<'db>, - source_order: Option, - ) -> bool { - match self.node() { - Node::AlwaysTrue => return true, - Node::AlwaysFalse => return false, - Node::Interior(_) => {} - } - - let mut typevars = FxHashSet::default(); - self.for_each_unique_constraint_mut(storage, &mut |storage, constraint| { - let constraint = storage.constraint_data(constraint); - typevars.insert(constraint.typevar); - }); - - // Specializations can introduce constraints that do not appear in the original BDD. - // Compose full constraint sets so those constraints retain their source orders when the - // resulting BDD is traversed. - - // Returns if some specialization satisfies this constraint set. - let some_specialization_satisfies = - |storage: &mut ConstraintSetStorage<'db>, - specializations: (NodeId, Option)| { - let (specializations, specializations_source_order) = specializations; - let when_satisfied = specializations - .implies(storage, self) - .and(storage, specializations); - let source_order = - storage.ordered_source_order(source_order, specializations_source_order); - !when_satisfied.is_never_satisfied(db, env, storage, source_order) - }; - - // Returns if all specializations satisfy this constraint set. - let all_specializations_satisfy = - |storage: &mut ConstraintSetStorage<'db>, - specializations: (NodeId, Option)| { - let (specializations, specializations_source_order) = specializations; - let when_satisfied = specializations - .implies(storage, self) - .and(storage, specializations) - .iff(storage, specializations); - let source_order = - storage.ordered_source_order(source_order, specializations_source_order); - when_satisfied.is_always_satisfied(db, env, storage, source_order) - }; - - #[expect( - clippy::iter_over_hash_type, - reason = "all type variables must pass the check regardless of order" - )] - for typevar in typevars { - if typevar.is_inferable(db, inferable) { - // If the typevar is in inferable position, we need to verify that some valid - // specialization satisfies the constraint set. - let valid_specializations = typevar.valid_specializations(db, env, storage); - if !some_specialization_satisfies(storage, valid_specializations) { - return false; - } - } else { - // If the typevar is in non-inferable position, we need to verify that all required - // specializations satisfy the constraint set. Complicating things, the typevar - // might have gradual constraints. For those, we need to know the range of valid - // materializations, but we only need some materialization to satisfy the - // constraint set. - // - // NB: We could also model this by introducing a synthetic typevar for the gradual - // constraint, treating that synthetic typevar as always inferable (so that we only - // need to verify for some materialization), and then update this typevar's - // constraint to refer to the synthetic typevar instead of the original gradual - // constraint. - let (static_specializations, gradual_constraints) = - typevar.required_specializations(db, env, storage); - if !all_specializations_satisfy(storage, static_specializations) { - return false; - } - for gradual_constraint in gradual_constraints { - if !some_specialization_satisfies(storage, gradual_constraint) { - return false; - } - } - } - } - - true - } - /// Returns a new BDD that is the _existential abstraction_ of `self` for a set of typevars. /// The result will return true whenever `self` returns true for _any_ assignment of those /// typevars. The result will not contain any constraints that mention those typevars. @@ -3289,19 +3530,20 @@ impl NodeId { result } - fn remove_noninferable<'db>( + fn remove_noninferable<'db, L: SolutionLimits>( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, inferable: TypeVarSet<'db>, source_order: Option, - ) -> (Self, Option) { + limits: &mut L, + ) -> ControlFlow)> { match self.node() { - Node::AlwaysTrue => (ALWAYS_TRUE, None), - Node::AlwaysFalse => (ALWAYS_FALSE, None), + Node::AlwaysTrue => ControlFlow::Continue((ALWAYS_TRUE, None)), + Node::AlwaysFalse => ControlFlow::Continue((ALWAYS_FALSE, None)), Node::Interior(interior) => { - interior.remove_noninferable(db, env, storage, inferable, source_order) + interior.remove_noninferable(db, env, storage, inferable, source_order, limits) } } } @@ -3335,30 +3577,6 @@ impl NodeId { walk(self, storage, &mut FxHashSet::default(), f); } - fn for_each_unique_constraint_mut<'db>( - self, - storage: &mut ConstraintSetStorage<'db>, - f: &mut dyn FnMut(&mut ConstraintSetStorage<'db>, ConstraintId), - ) { - fn walk<'db>( - node: NodeId, - storage: &mut ConstraintSetStorage<'db>, - seen: &mut FxHashSet, - f: &mut dyn FnMut(&mut ConstraintSetStorage<'db>, ConstraintId), - ) { - if node.is_terminal() || !seen.insert(node) { - return; - } - let interior = storage.interior_node_data(node); - f(storage, interior.constraint); - walk(interior.if_true, storage, seen, f); - walk(interior.if_uncertain, storage, seen, f); - walk(interior.if_false, storage, seen, f); - } - - walk(self, storage, &mut FxHashSet::default(), f); - } - /// Returns clauses describing all of the variable assignments that cause this BDD to evaluate /// to `true`. (This translates the boolean function that this BDD represents into DNF form.) fn satisfied_clauses(self, storage: &ConstraintSetStorage<'_>) -> SatisfiedClauses { @@ -3406,36 +3624,14 @@ impl NodeId { // Render the BDD directly as an unsimplified DNF formula. Each root-to-true path becomes // one clause, with true, uncertain, and false edges contributing positive, unconstrained, // and negative assignments respectively. - struct DisplayNode<'db, 'c> { - node: NodeId, - db: &'db dyn Db, - env: &'c ProgramEnvironment<'db>, - storage: &'c ConstraintSetStorage<'db>, - } - - impl Display for DisplayNode<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.node.node() { - Node::AlwaysTrue => f.write_str("always"), - Node::AlwaysFalse => f.write_str("never"), - Node::Interior(_) => Display::fmt( - &self.node.satisfied_clauses(self.storage).display( - self.db, - self.env, - self.storage, - ), - f, - ), - } - } - } - - DisplayNode { - node: self, - db, - env, - storage, - } + std::fmt::from_fn(move |f| match self.node() { + Node::AlwaysTrue => f.write_str("always"), + Node::AlwaysFalse => f.write_str("never"), + Node::Interior(_) => Display::fmt( + &self.satisfied_clauses(storage).display(db, env, storage), + f, + ), + }) } /// Displays the full graph structure of this BDD. `prefix` will be output before each line @@ -3463,15 +3659,6 @@ impl NodeId { storage: &'a ConstraintSetStorage<'db>, prefix: &'a dyn Display, ) -> impl Display + 'a { - struct DisplayNode<'a, 'db> { - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - storage: &'a ConstraintSetStorage<'db>, - node: NodeId, - prefix: &'a dyn Display, - seen: RefCell>, - } - fn format_node<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -3532,31 +3719,11 @@ impl NodeId { } } - impl Display for DisplayNode<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - format_node( - db, - self.env, - self.storage, - self.node, - self.prefix, - &self.seen, - f, - ) - } - } - - DisplayNode { - db, - env, - storage, - node: self, - prefix, - seen: RefCell::default(), - } - } -} + std::fmt::from_fn(move |f| { + format_node(db, env, storage, self, prefix, &RefCell::default(), f) + }) + } +} impl Debug for NodeId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -3607,18 +3774,18 @@ struct InteriorNodeData { if_false: NodeId, } -/// Accumulates lower and upper bounds for a single typevar on a single BDD path. +/// Accumulates validity and evidence bounds for a single typevar on one TDD path. /// -/// Lower bounds are collected into a union (they are alternatives for the minimum type the -/// typevar can specialize to). Upper bounds are kept as a factored intersection (the typevar -/// must satisfy all of them simultaneously). Once the path has been fully traversed, the -/// accumulated bounds are stored in a [`PathBound`]. +/// Separate lower-bound unions preserve inference evidence even when a wider validity restriction +/// determines the effective minimum. Upper clauses retain their individual provenance and stay +/// factored to avoid distributing intersections over unions. #[derive(Default)] struct ConstraintBoundsBuilder<'db> { - lower: FxIndexSet>, + evidence_lower: FxIndexSet>, + validity_lower: FxIndexSet>, upper: UpperBound<'db>, - // Classify each bound before aggregation: unioning lower bounds can otherwise make separate - // gradual and static evidence indistinguishable from a single gradual union. + // Classify each evidence bound before aggregation: a union can otherwise make gradual and + // static argument evidence indistinguishable from a single gradual union. has_gradual_evidence: bool, has_static_evidence: bool, } @@ -3635,18 +3802,38 @@ impl<'db> ConstraintBoundsBuilder<'db> { } } - fn add_lower(&mut self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>) { + fn add_lower( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + bound: ConstraintBound<'db>, + ) { // Lower bounds are unioned. Our type representation is in DNF, so unioning a new // element is typically cheap (in that it does not involve a combinatorial // explosion from distributing the clause through an existing disjunction). So we // don't need to be as clever here as in `add_upper`. - self.classify_evidence(db, env, ty); - self.lower.insert(ty); + match bound { + ConstraintBound::Evidence(ty) => { + self.classify_evidence(db, env, ty); + self.evidence_lower.insert(ty); + } + ConstraintBound::Validity(ty) if bound != ConstraintBound::missing_lower() => { + self.validity_lower.insert(ty); + } + ConstraintBound::Validity(_) => {} + } } - fn add_upper(&mut self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>) { - self.classify_evidence(db, env, ty); - self.upper.add_clause(ty); + fn add_upper( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + bound: ConstraintBound<'db>, + ) { + if let ConstraintBound::Evidence(ty) = bound { + self.classify_evidence(db, env, ty); + } + self.upper.add_clause(bound); } fn finish( @@ -3656,28 +3843,75 @@ impl<'db> ConstraintBoundsBuilder<'db> { bound_typevar: BoundTypeVarInstance<'db>, ) -> PathBound<'db> { let Self { - lower, + evidence_lower, + validity_lower, mut upper, has_gradual_evidence, has_static_evidence, } = self; - let lower = (!lower.is_empty()).then(|| UnionType::from_elements(db, env, lower)); + let evidence_lower = + (!evidence_lower.is_empty()).then(|| UnionType::from_elements(db, env, evidence_lower)); + let validity_lower = if validity_lower.is_empty() { + Type::Never + } else { + UnionType::from_elements(db, env, validity_lower) + }; upper.shrink_to_fit(); PathBound { bound_typevar, - lower, + evidence_lower, + validity_lower, upper, has_only_gradual_evidence: has_gradual_evidence && !has_static_evidence, } } } +/// The result of selecting a type for one typevar on one constraint path. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PathBoundSolution<'db> { + Solved(Type<'db>), + /// The path provides no type to infer for this variable. + Unsolved, + /// The bounds cannot be satisfied, so the entire path must be rejected. + Unsatisfiable, + /// Computing the solution exceeded the type-construction budget. A previously known type + /// can still be used as a conservative fallback, but is not a complete solution. + BudgetExceeded { + fallback: Option>, + }, +} + +impl<'db> PathBoundSolution<'db> { + /// Transforms a selected type without losing whether it is only a budget-exhaustion fallback. + pub(crate) fn map(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + match self { + Self::Solved(ty) => Self::Solved(f(ty)), + Self::BudgetExceeded { fallback } => Self::BudgetExceeded { + fallback: fallback.map(f), + }, + Self::Unsolved | Self::Unsatisfiable => self, + } + } + + /// Returns the selected type, including a fallback when the budget was exceeded. + /// Match the outcome directly when completeness or the reason no type was selected matters. + pub(crate) fn as_type(self) -> Option> { + match self { + Self::Solved(ty) => Some(ty), + Self::Unsolved | Self::Unsatisfiable => None, + Self::BudgetExceeded { fallback } => fallback, + } + } +} + /// The explicit lower and upper bounds inferred for one typevar on one BDD path. #[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct PathBound<'db> { pub(crate) bound_typevar: BoundTypeVarInstance<'db>, - pub(crate) lower: Option>, - pub(crate) upper: UpperBound<'db>, + evidence_lower: Option>, + validity_lower: Type<'db>, + upper: UpperBound<'db>, /// Whether the path contains gradual evidence and no static evidence. has_only_gradual_evidence: bool, } @@ -3686,31 +3920,122 @@ impl<'db> PathBound<'db> { pub(crate) fn exact(bound_typevar: BoundTypeVarInstance<'db>, ty: Type<'db>) -> Self { Self { bound_typevar, - lower: Some(ty), + evidence_lower: Some(ty), + validity_lower: Type::Never, upper: UpperBound::from_clause(ty), has_only_gradual_evidence: false, } } - fn variance(&self) -> TypeVarVariance { - match (self.lower, self.has_upper()) { - (None, true) => TypeVarVariance::Covariant, - (Some(_), false) => TypeVarVariance::Contravariant, - (Some(_), true) => TypeVarVariance::Invariant, - (None, false) => TypeVarVariance::Bivariant, + /// Returns lower-bound inference evidence without supplying a default for a missing bound. + pub(crate) fn evidence_lower(&self) -> Option> { + self.evidence_lower + } + + /// Returns one effective upper bound without expanding factored intersections. + pub(crate) fn as_single_upper_bound( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option> { + self.upper.as_single_bound(db, env) + } + + fn effective_lower(&self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + let Some(evidence_lower) = self.evidence_lower else { + return self.validity_lower; + }; + if self.validity_lower.is_never() { + return evidence_lower; } + UnionType::from_elements(db, env, [evidence_lower, self.validity_lower]) } - fn lower_or_never(&self) -> Type<'db> { - self.lower.unwrap_or(Type::Never) + fn variance(&self) -> TypeVarVariance { + match (self.evidence_lower.is_some(), self.has_upper_evidence()) { + (false, true) => TypeVarVariance::Covariant, + (true, false) => TypeVarVariance::Contravariant, + (true, true) => TypeVarVariance::Invariant, + (false, false) => TypeVarVariance::Bivariant, + } } - pub(crate) fn has_upper(&self) -> bool { - self.upper.has_explicit_bound() + pub(crate) fn has_upper_evidence(&self) -> bool { + self.upper.has_evidence() } - fn has_only_gradual_evidence(&self) -> bool { - self.has_only_gradual_evidence + /// Restricts the range of a gradual solution by the upper bounds inferred for this constraint. + /// Returns `None` if constructing an intersection exceeds the solution budget. + fn restrict_gradual_solution( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + solution: Type<'db>, + ) -> Option> { + if self.evidence_lower.is_none() + || self.effective_lower(db, env) != solution + || !self.has_upper_evidence() + || solution.bottom_materialization(db, env) == solution.top_materialization(db, env) + { + return Some(solution); + } + + // Unresolved type-variable relationships must not escape into the specialization. + if solution.has_typevar(db, env) || solution.has_unspecialized_type_var(db, env) { + return Some(solution); + } + + // `Divergent` is not safely reflexive, so we cannot intersect identical bounds. + if UpperBound::single_bound_from_iterator(db, env, self.upper.iter_evidence()) + == Some(solution) + { + return Some(solution); + } + + // Gradual upper bounds are top-materialized, as the lower bound is already gradual. + let materialize_upper = |bound: Type<'db>| { + (!bound.has_typevar(db, env) && !bound.has_unspecialized_type_var(db, env)) + .then(|| bound.top_materialization(db, env)) + .filter(|bound| !bound.is_object()) + }; + + let declared_upper = match self.bound_typevar.typevar(db).bound_or_constraints(db, env) { + // Constrained type variables select solutions from their own set of constraints. + Some(TypeVarBoundOrConstraints::Constraints(_)) => return Some(solution), + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => materialize_upper(bound), + _ => None, + }; + + let mut upper_bounds = self + .upper + .iter_evidence() + .map(ConstraintBound::ty) + .filter_map(materialize_upper); + let Some(first_upper) = upper_bounds.next() else { + return Some(solution); + }; + + let upper_bound = IntersectionType::bounded_from_elements( + db, + env, + iter::once(first_upper) + .chain(upper_bounds) + .chain(declared_upper), + )?; + + // Restrict the range of each gradual solution by the upper bound of this constraint. + let restrict_gradual = |element: Type<'db>| { + if element.bottom_materialization(db, env) == element.top_materialization(db, env) { + Some(element) + } else { + IntersectionType::bounded_from_elements(db, env, [upper_bound, element]) + } + }; + + match solution { + Type::Union(union) => union.try_map(db, env, |element| restrict_gradual(*element)), + _ => restrict_gradual(solution), + } } } @@ -3778,6 +4103,50 @@ pub(crate) enum PathBounds<'db> { Constrained(Box<[Box<[PathBound<'db>]>]>), } +/// Limits shared by the preprocessing and collection walks used to extract solutions. +trait SolutionLimits { + type Break; + + fn visit_node(&mut self) -> ControlFlow { + ControlFlow::Continue(()) + } + + fn satisfied_path(&mut self) -> ControlFlow { + ControlFlow::Continue(()) + } +} + +struct UnboundedSolutionLimits; + +impl SolutionLimits for UnboundedSolutionLimits { + type Break = Infallible; +} + +struct BoundedSolutionLimits { + remaining_paths: usize, + remaining_visits: usize, +} + +impl SolutionLimits for BoundedSolutionLimits { + type Break = ProjectionError; + + fn visit_node(&mut self) -> ControlFlow { + let Some(remaining) = self.remaining_visits.checked_sub(1) else { + return ControlFlow::Break(ProjectionError::TraversalBudgetExceeded); + }; + self.remaining_visits = remaining; + ControlFlow::Continue(()) + } + + fn satisfied_path(&mut self) -> ControlFlow { + let Some(remaining) = self.remaining_paths.checked_sub(1) else { + return ControlFlow::Break(ProjectionError::PathBudgetExceeded); + }; + self.remaining_paths = remaining; + ControlFlow::Continue(()) + } +} + impl<'db> PathBounds<'db> { /// Computes sorted BDD paths and accumulates per-typevar lower/upper bounds for each path. /// @@ -3791,66 +4160,59 @@ impl<'db> PathBounds<'db> { inferable: TypeVarSet<'db>, source_order: Option, ) -> Self { - struct CollectVisitor<'a> { - source_orders: &'a FxIndexSet, - sorted_paths: Vec>, - } - - impl PathFold for CollectVisitor<'_> { - type Result = (); - type Break = Infallible; - - fn satisfied<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - path: &PathAssignments, - ) -> ControlFlow { - let mut path: Vec<_> = path - .positive_constraints() - .map(|(constraint, source_constraint)| { - let source_order = self - .source_orders - .get_index_of(&source_constraint) - .expect("every TDD constraint should have a source order"); - (constraint, source_order) - }) - .collect(); - path.sort_by_key(|(_, source_order)| *source_order); - self.sorted_paths.push(path); - ControlFlow::Continue(()) - } - - fn unsatisfied<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _path: &PathAssignments, - ) -> ControlFlow { - ControlFlow::Continue(()) - } - - fn impossible<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _path: &PathAssignments, - ) -> ControlFlow { - ControlFlow::Continue(()) - } + let ControlFlow::Continue(result) = Self::compute_with_limits( + db, + env, + storage, + node, + inferable, + source_order, + &mut UnboundedSolutionLimits, + ); + result + } - fn combine<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _if_true: Self::Result, - _if_uncertain: Self::Result, - _if_false: Self::Result, - ) -> ControlFlow { - ControlFlow::Continue(()) - } + /// Computes complete path bounds within limits shared by preprocessing and collection. + /// + /// Visits include the concrete-conjunction fast path and both BDD walks. The path limit + /// counts materialized constrained paths; an unconstrained or unsatisfiable result needs no + /// path allowance. No partially collected family is returned when either limit is exhausted. + fn compute_bounded( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + node: NodeId, + inferable: TypeVarSet<'db>, + source_order: Option, + budget: SolutionBudget, + ) -> Result { + let mut limits = BoundedSolutionLimits { + remaining_paths: budget.paths, + remaining_visits: budget.visits, + }; + match Self::compute_with_limits( + db, + env, + storage, + node, + inferable, + source_order, + &mut limits, + ) { + ControlFlow::Continue(result) => Ok(result), + ControlFlow::Break(error) => Err(error), } + } + fn compute_with_limits( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + node: NodeId, + inferable: TypeVarSet<'db>, + source_order: Option, + limits: &mut L, + ) -> ControlFlow { let mut source_orders = storage.calculate_source_orders(source_order); if let Some(path_bounds) = Self::compute_simple_bound_conjunction( db, @@ -3859,78 +4221,34 @@ impl<'db> PathBounds<'db> { &source_orders, node, inferable, - ) { - return path_bounds; + limits, + )? { + return ControlFlow::Continue(path_bounds); } let (node, derived_source_order) = - node.remove_noninferable(db, env, storage, inferable, source_order); + node.remove_noninferable(db, env, storage, inferable, source_order, limits)?; source_orders.extend(storage.calculate_source_orders(derived_source_order)); let interior = match node.node() { - Node::AlwaysTrue => return PathBounds::Unconstrained, - Node::AlwaysFalse => return PathBounds::Unsatisfiable, + Node::AlwaysTrue => { + limits.visit_node()?; + return ControlFlow::Continue(PathBounds::Unconstrained); + } + Node::AlwaysFalse => { + limits.visit_node()?; + return ControlFlow::Continue(PathBounds::Unsatisfiable); + } Node::Interior(interior) => interior, }; - // Sort the constraints in each path by their `source_order`s, to ensure that we construct - // any unions or intersections in our type mappings in a stable order. Constraints might - // come out of `PathAssignment`s with identical `source_order`s, but if they do, those - // "tied" constraints will still be ordered in a stable way. So we need a stable sort to - // retain that stable per-tie ordering. - let mut collect_visitor = CollectVisitor { - source_orders: &source_orders, - sorted_paths: Vec::new(), - }; - // Sequent discovery must also happen in source order. Sorting the collected paths below - // is too late: sequent pairs are not commutative, and TDD traversal order can otherwise + let mut walker = SolutionWalker::new(source_orders); + // Sequent discovery must also happen in source order. Sorting the collected paths is + // too late: sequent pairs are not commutative, and TDD traversal order can otherwise // discard gradual evidence before solution extraction. let path_source_order = storage.ordered_source_order(source_order, derived_source_order); - let mut path = interior.path_assignments(storage, path_source_order); - let _ = path.visit(db, env, storage, node, &mut collect_visitor); - collect_visitor.sorted_paths.sort_by(|path1, path2| { - let source_orders1 = path1.iter().map(|(_, source_order)| *source_order); - let source_orders2 = path2.iter().map(|(_, source_order)| *source_order); - source_orders1.cmp(source_orders2) - }); - - let mut result = Vec::with_capacity(collect_visitor.sorted_paths.len()); - let mut mappings: FxIndexMap, ConstraintBoundsBuilder<'db>> = - FxIndexMap::default(); - - for path in collect_visitor.sorted_paths { - mappings.clear(); - for (constraint, _) in path { - let constraint = storage.constraint_data(constraint); - let typevar = constraint.typevar; - if let Some(lower) = constraint.bounds.lower { - let bounds = mappings.entry(typevar).or_default(); - bounds.add_lower(db, env, lower); - - if let Type::TypeVar(lower_bound_typevar) = lower { - let bounds = mappings.entry(lower_bound_typevar).or_default(); - bounds.add_upper(db, env, Type::TypeVar(typevar)); - } - } - - if let Some(upper) = constraint.bounds.upper { - let bounds = mappings.entry(typevar).or_default(); - bounds.add_upper(db, env, upper); - - if let Type::TypeVar(upper_bound_typevar) = upper { - let bounds = mappings.entry(upper_bound_typevar).or_default(); - bounds.add_lower(db, env, Type::TypeVar(typevar)); - } - } - } - - let path_bounds = mappings - .drain(..) - .map(|(bound_typevar, bounds)| bounds.finish(db, env, bound_typevar)) - .collect(); - result.push(path_bounds); - } - - PathBounds::Constrained(result.into_boxed_slice()) + let mut path = interior.path_assignments(db, env, storage, path_source_order); + walker.visit_node(db, env, storage, &mut path, node, limits)?; + ControlFlow::Continue(walker.finish(db, env, storage)) } /// Accumulates a conjunction of concrete bound constraints without constructing a @@ -3939,47 +4257,53 @@ impl<'db> PathBounds<'db> { /// There are no relationships to derive between these constraints, as the upper and lower /// bounds do not contain typevars. The normal solution-selection logic still validates each /// accumulated bound against the typevar's declared bound or constraints. - fn compute_simple_bound_conjunction( + fn compute_simple_bound_conjunction( db: &'db dyn Db, env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, source_orders: &FxIndexSet, node: NodeId, inferable: TypeVarSet<'db>, - ) -> Option { - match node.node() { - Node::AlwaysTrue => return Some(PathBounds::Unconstrained), - Node::AlwaysFalse => return Some(PathBounds::Unsatisfiable), - Node::Interior(_) => {} - } - + limits: &mut L, + ) -> ControlFlow> { let mut constraints = Vec::default(); let mut current = node; loop { + limits.visit_node()?; match current.node() { - Node::AlwaysTrue => break, - Node::AlwaysFalse => return None, + Node::AlwaysTrue => { + if constraints.is_empty() { + return ControlFlow::Continue(Some(PathBounds::Unconstrained)); + } + limits.satisfied_path()?; + break; + } + Node::AlwaysFalse => { + return ControlFlow::Continue( + constraints.is_empty().then_some(PathBounds::Unsatisfiable), + ); + } Node::Interior(_) => { let interior = storage.interior_node_data(current); if interior.if_uncertain != ALWAYS_FALSE || interior.if_false != ALWAYS_FALSE { - return None; + return ControlFlow::Continue(None); } let constraint = storage.constraint_data(interior.constraint); if !constraint.typevar.is_inferable(db, inferable) { - return None; + return ControlFlow::Continue(None); } - if iter::chain(constraint.bounds.lower, constraint.bounds.upper).any(|bound| { - bound.has_typevar(db, env) || bound.has_unspecialized_type_var(db, env) + let mut bounds = constraint.iter_stored_bounds().map(ConstraintBound::ty); + if bounds.any(|bound| { + bound.has_typevar(db, env) || bound.has_provisional_marker(db, env) }) { - return None; + return ControlFlow::Continue(None); } current = interior.if_true; constraints.push(( - constraint.typevar, - constraint.bounds, + constraint, source_orders .get_index_of(&interior.constraint) .expect("every TDD constraint should have a source order"), @@ -3990,13 +4314,13 @@ impl<'db> PathBounds<'db> { let mut mappings: FxIndexMap, ConstraintBoundsBuilder<'db>> = FxIndexMap::default(); - constraints.sort_by_key(|(_, _, source_order)| *source_order); - for (typevar, constraint, _) in constraints { - let bounds = mappings.entry(typevar).or_default(); - if let Some(lower) = constraint.lower { + constraints.sort_by_key(|(_, source_order)| *source_order); + for (constraint, _) in constraints { + let bounds = mappings.entry(constraint.typevar()).or_default(); + if let Some(lower) = constraint.stored_lower_bound() { bounds.add_lower(db, env, lower); } - if let Some(upper) = constraint.upper { + if let Some(upper) = constraint.stored_upper_bound() { bounds.add_upper(db, env, upper); } } @@ -4005,7 +4329,7 @@ impl<'db> PathBounds<'db> { .drain(..) .map(|(bound_typevar, bounds)| bounds.finish(db, env, bound_typevar)) .collect(); - Some(PathBounds::Constrained(Box::new([path]))) + ControlFlow::Continue(Some(PathBounds::Constrained(Box::new([path])))) } pub(crate) fn solve( @@ -4019,44 +4343,78 @@ impl<'db> PathBounds<'db> { }) } - /// Solves each path by applying a per-typevar solver function, collecting valid solutions. + /// Solves each path by applying a per-typevar solver function, collecting retained solutions. /// - /// The solver receives the path's explicit lower/upper bounds and their variance, and returns: - /// - `Ok(Some(solution))` to add a solution for this typevar on this path - /// - `Ok(None)` to leave this typevar unsolved on this path - /// - `Err(())` to invalidate the entire path + /// A genuinely unsolved variable does not invalidate a path. Budget exhaustion also retains + /// the path's available bindings, but marks the resulting path family as incomplete. pub(crate) fn solve_with( &self, - mut choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> Result>, ()>, + choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, ) -> Solutions<'db> { + let Ok(solutions) = self.try_solve_with(choose, |_| Ok::<(), Infallible>(())); + solutions + } + + /// Checks each retained solution before collecting it or solving the next path. + fn try_solve_with( + &self, + mut choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, + mut check_solution: impl FnMut(&Solution<'db>) -> Result<(), E>, + ) -> Result, E> { let paths = match self { - PathBounds::Unsatisfiable => return Solutions::Unsatisfiable, - PathBounds::Unconstrained => return Solutions::Unconstrained, + PathBounds::Unsatisfiable => return Ok(Solutions::Unsatisfiable), + PathBounds::Unconstrained => return Ok(Solutions::Unconstrained), PathBounds::Constrained(paths) => paths, }; let mut solutions = Vec::with_capacity(paths.len()); - 'paths: for path in paths { - let mut solution = Vec::with_capacity(path.len()); - for path_bound in path { - let variance = path_bound.variance(); - - match choose(variance, path_bound) { - Ok(Some(ty)) => solution.push(TypeVarSolution { - bound_typevar: path_bound.bound_typevar, - solution: ty, - }), - Ok(None) => {} - Err(()) => continue 'paths, - } - } + let mut exceeded_budget = false; + for path in paths { + let Some((solution, path_exceeded_budget)) = Self::solve_path_with(path, &mut choose) + else { + continue; + }; + check_solution(&solution)?; + exceeded_budget |= path_exceeded_budget; solutions.push(solution); } if solutions.is_empty() { - return Solutions::Unsatisfiable; + return Ok(Solutions::Unsatisfiable); + } + Ok(Solutions::Constrained(if exceeded_budget { + SolutionPaths::BudgetExceeded(solutions) + } else { + SolutionPaths::Complete(solutions) + })) + } + + /// Solves one complete path, retaining whether any of its bindings used a fallback. + /// A later unsatisfiable bound rejects the path even if an earlier bound exhausted its budget. + fn solve_path_with( + path: &[PathBound<'db>], + choose: &mut impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, + ) -> Option<(Solution<'db>, bool)> { + let mut solution = Vec::with_capacity(path.len()); + let mut exceeded_budget = false; + for path_bound in path { + let ty = match choose(path_bound.variance(), path_bound) { + PathBoundSolution::Solved(ty) => Some(ty), + PathBoundSolution::Unsolved => None, + PathBoundSolution::Unsatisfiable => return None, + PathBoundSolution::BudgetExceeded { fallback } => { + exceeded_budget = true; + fallback + } + }; + if let Some(ty) = ty { + solution.push(TypeVarSolution { + bound_typevar: path_bound.bound_typevar, + solution: ty, + }); + } } - Solutions::Constrained(solutions) + Some((solution, exceeded_budget)) } /// The default solution selection logic for a single typevar on a single BDD path. @@ -4064,23 +4422,48 @@ impl<'db> PathBounds<'db> { /// Given the explicit lower and upper bounds for a typevar, selects the solution type. /// Missing bounds are materialized to their logical defaults only for satisfiability checks; /// they are not selected as inferred solutions. - /// Returns: - /// - `Ok(Some(solution))` if the typevar is solved on this path - /// - `Ok(None)` if the typevar is unsolved (no solution added) - /// - `Err(())` if the path is invalid (bounds violate the typevar's declared constraints) pub(crate) fn default_solve( db: &'db dyn Db, env: &ProgramEnvironment<'db>, builder: &ConstraintSetBuilder<'db>, path_bound: &PathBound<'db>, - ) -> Result>, ()> { + ) -> PathBoundSolution<'db> { + let preliminary = Self::preliminary_solve(db, env, builder, path_bound); + let PathBoundSolution::Solved(solution) = preliminary else { + return preliminary; + }; + + let Some(restricted) = path_bound.restrict_gradual_solution(db, env, solution) else { + return PathBoundSolution::BudgetExceeded { + fallback: Some(solution), + }; + }; + + // An empty gradual range makes the constraint path unsatisfiable. + if restricted.is_never() && !solution.is_never() { + return PathBoundSolution::Unsatisfiable; + } + + PathBoundSolution::Solved(restricted) + } + + /// Selects a preliminary solution to use as type context during generic call inference. + /// + /// Unlike [`Self::default_solve`], the range of a gradual solution is not restricted by inferred + /// upper bounds, as the inferred types may not have stabilized yet. + pub(crate) fn preliminary_solve( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + builder: &ConstraintSetBuilder<'db>, + path_bound: &PathBound<'db>, + ) -> PathBoundSolution<'db> { // Choose a solution type that satisfies the constraints on this path, as well as any upper // bound or constraints of the typevar itself. // TODO: Handle the upper bound/constraints by conjoining them with the constraint set // before solving. let bound_typevar = path_bound.bound_typevar; - let lower = path_bound.lower_or_never(); + let lower = path_bound.effective_lower(db, env); match bound_typevar .typevar(db) @@ -4091,19 +4474,19 @@ impl<'db> PathBounds<'db> { // basedpython: a declared lower bound raises the floor of every solution. The // narrowest type above both it and any inferred lower bound is their union - let path_lower = match (path_bound.lower, bound_typevar.typevar(db).lower_bound(db)) - { - (Some(inferred), Some(declared)) => { - Some(UnionType::from_two_elements(db, env, inferred, declared)) + let declared_lower = bound_typevar.typevar(db).lower_bound(db); + let lower = match declared_lower { + Some(declared) if path_bound.evidence_lower.is_some() => { + UnionType::from_two_elements(db, env, lower, declared) } - (Some(inferred), None) => Some(inferred), - (None, declared) => declared, + Some(declared) => declared, + None => lower, }; // Prefer the lower bound (often the concrete actual type seen) over the // upper bound (which may include TypeVar bounds/constraints). The upper bound // should only be used as a fallback when no concrete type was inferred. - if let Some(lower) = path_lower { + if path_bound.evidence_lower.is_some() || declared_lower.is_some() { if !path_bound.upper.is_satisfied_by(db, env, lower) { let mut storage = builder.storage.borrow_mut(); let (when_upper, source_order) = @@ -4113,7 +4496,7 @@ impl<'db> PathBounds<'db> { if when_upper.is_never_satisfied(db, env, &mut storage, source_order) { // This path does not satisfy the accumulated upper bound, and is // therefore not a valid specialization. - return Err(()); + return PathBoundSolution::Unsatisfiable; } } @@ -4123,26 +4506,28 @@ impl<'db> PathBounds<'db> { ) { // This path does not satisfy the typevar's declared upper bound, and is // therefore not a valid specialization. - return Err(()); + return PathBoundSolution::Unsatisfiable; } - return Ok(Some(lower)); + return PathBoundSolution::Solved(lower); } - if path_bound.has_upper() { - return Ok(IntersectionType::bounded_from_elements( + if path_bound.has_upper_evidence() { + return IntersectionType::bounded_from_elements( db, env, - path_bound - .upper - .clauses - .iter() - .copied() - .chain([declared_upper]), - )); + iter::chain( + path_bound.upper.iter_clauses().map(ConstraintBound::ty), + [declared_upper], + ), + ) + .map_or( + PathBoundSolution::BudgetExceeded { fallback: None }, + PathBoundSolution::Solved, + ); } - Ok(None) + PathBoundSolution::Unsolved } TypeVarBoundOrConstraints::Constraints(constraints) => { @@ -4189,7 +4574,7 @@ impl<'db> PathBounds<'db> { current_best.is_assignable_to(db, env, candidate); if candidate_assignable_to_best != best_assignable_to_candidate { - if path_bound.lower.is_some() { + if path_bound.evidence_lower.is_some() { candidate_assignable_to_best } else { best_assignable_to_candidate @@ -4240,18 +4625,19 @@ impl<'db> PathBounds<'db> { let Some(compatible_constraint) = compatible_constraint else { // This path does not satisfy any of the constraints, and is therefore not a // valid specialization. - return Err(()); + return PathBoundSolution::Unsatisfiable; }; - if let (Some(ty @ Type::TypeVar(_)), _) | (_, Some(ty @ Type::TypeVar(_))) = - (path_bound.lower, path_bound.upper.as_single_bound(db, env)) - { + if let (ty @ Type::TypeVar(_), _) | (_, Some(ty @ Type::TypeVar(_))) = ( + path_bound.effective_lower(db, env), + path_bound.as_single_upper_bound(db, env), + ) { // This path relates two TypeVars, such as passing `S` to a parameter typed as // `T: (int, str)`. The compatibility check above has verified that at least // one of `T`'s declared constraints can satisfy the path, but choosing a // concrete constraint here would break the relationship between `T` and `S`. // Keep that relationship as the solution instead. - return Ok(Some(ty)); + return PathBoundSolution::Solved(ty); } // See above: If the path solution satisfies exactly one constraint, use that @@ -4262,20 +4648,24 @@ impl<'db> PathBounds<'db> { // as the result if it's gradual. (Checking `Any` against `T: (int, str)` selects // `T = Any`) If the path solution is fully static, we choose the "tightest" // constraint. (Checking `int` against `T: (int, int | str)` selects `T = int`.) - if multiple_compatible_constraints && path_bound.has_only_gradual_evidence() { - if let Some(lower) = path_bound.lower { - Ok(Some(lower)) - } else if path_bound.has_upper() { - Ok(IntersectionType::bounded_from_elements( + if multiple_compatible_constraints && path_bound.has_only_gradual_evidence { + if path_bound.evidence_lower.is_some() { + PathBoundSolution::Solved(path_bound.effective_lower(db, env)) + } else if path_bound.has_upper_evidence() { + IntersectionType::bounded_from_elements( db, env, - path_bound.upper.clauses.iter().copied(), - )) + path_bound.upper.iter_clauses().map(ConstraintBound::ty), + ) + .map_or( + PathBoundSolution::BudgetExceeded { fallback: None }, + PathBoundSolution::Solved, + ) } else { - Ok(None) + PathBoundSolution::Unsolved } } else { - Ok(Some(compatible_constraint)) + PathBoundSolution::Solved(compatible_constraint) } } } @@ -4452,46 +4842,51 @@ impl InteriorNode { bound_typevars: TypeVarSet<'db>, source_order: Option, ) -> (NodeId, Option) { - self.abstract_inner( + let ControlFlow::Continue(result) = self.abstract_inner( db, env, storage, source_order, + &mut UnboundedSolutionLimits, // Remove any node that constrains one of `bound_typevars`, or that has a lower/upper // bound that mentions one of them. Removed constraints are still added to `path`, so // the sequent map can propagate any derived constraints that do not mention the // quantified typevars. &mut |storage: &ConstraintSetStorage<'_>, constraint| { - let support = storage.constraint_support(constraint); - support.iter().any(|typevar| { - let typevar = storage.typevar_data(typevar); - typevar.is_inferable(db, bound_typevars) - }) + storage.constraint_mentions_typevars(db, constraint, bound_typevars) }, - ) + ); + result } - fn remove_noninferable<'db>( + fn remove_noninferable<'db, L: SolutionLimits>( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, inferable: TypeVarSet<'db>, source_order: Option, - ) -> (NodeId, Option) { - let is_bare_inferable_typevar = |ty: Type<'_>| { - ty.as_typevar() - .is_some_and(|bound_typevar| bound_typevar.is_inferable(db, inferable)) + limits: &mut L, + ) -> ControlFlow)> { + let is_bare_inferable_typevar = |bound: Option>| { + bound.is_some_and(|bound| { + matches!( + bound, + ConstraintBound::Evidence(Type::TypeVar(bound_typevar)) + if bound_typevar.is_inferable(db, inferable) + ) + }) }; self.abstract_inner( db, env, storage, source_order, + limits, // We only want to keep constraints on inferable typevars. If the constraint's typevar // is itself inferable, we keep it. We also need to keep some constraints in - // non-inferable typevars, if their lower or upper bound is a bare inferable typevar. - // This ensure that our quantification logic does not depend on typevar ordering. + // non-inferable typevars, if an evidence bound is a bare inferable typevar. This + // ensures that our quantification logic does not depend on typevar ordering. // // For example, `I ≤ N` (where I is inferable and N is non-inferable) could be encoded // either as `Never ≤ I ≤ N` or `I ≤ N ≤ object`, depending on typevar ordering. If we @@ -4500,28 +4895,24 @@ impl InteriorNode { &mut |storage: &ConstraintSetStorage<'_>, constraint| { let constraint = storage.constraint_data(constraint); !constraint.typevar.is_inferable(db, inferable) - && !constraint - .bounds - .lower - .is_some_and(is_bare_inferable_typevar) - && !constraint - .bounds - .upper - .is_some_and(is_bare_inferable_typevar) + && !is_bare_inferable_typevar(constraint.stored_lower_bound()) + && !is_bare_inferable_typevar(constraint.stored_upper_bound()) }, ) } - fn abstract_inner<'db, F>( + fn abstract_inner<'db, F, L>( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, storage: &mut ConstraintSetStorage<'db>, source_order: Option, + limits: &mut L, should_remove: F, - ) -> (NodeId, Option) + ) -> ControlFlow)> where F: FnMut(&ConstraintSetStorage<'_>, ConstraintId) -> bool, + L: SolutionLimits, { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Disposition { @@ -4529,17 +4920,23 @@ impl InteriorNode { Remove, } - struct AbstractVisitor { + struct AbstractVisitor<'a, F, L> { should_remove: F, + limits: &'a mut L, } - impl PathVisitor for AbstractVisitor + impl PathVisitor for AbstractVisitor<'_, F, L> where F: FnMut(&ConstraintSetStorage<'_>, ConstraintId) -> bool, + L: SolutionLimits, { type Result = (NodeId, Option); type Interior = (Disposition, ConstraintId); - type Break = Infallible; + type Break = L::Break; + + fn visit_node(&mut self) -> ControlFlow { + self.limits.visit_node() + } fn visit_satisfied<'db>( &mut self, @@ -4631,23 +5028,16 @@ impl InteriorNode { ) -> ControlFlow { let (disposition, constraint) = interior; match disposition { - // If we are keeping this node, absorb the uncertain branch into both the true - // and false branches before constructing the ITE, matching TDD semantics: when - // the constraint holds the result is C ∨ U, and when it doesn't the result is - // D ∨ U. - // - // NB: We cannot use `Node::new` here, because the recursive calls might introduce new - // derived constraints into the result, and those constraints might appear before this - // one in the BDD ordering. + // Preserve the uncertain branch when rebuilding the node. Recursive calls + // can introduce derived constraints earlier in the variable ordering, so + // use `ite_uncertain` rather than constructing a node directly. Disposition::Keep => { let (guard, guard_source_order) = Node::new_constraint(storage, *constraint); let (if_true, if_true_source_order) = if_true; let (if_uncertain, if_uncertain_source_order) = if_uncertain; let (if_false, if_false_source_order) = if_false; - let if_true = if_true.or(storage, if_uncertain); - let if_false = if_false.or(storage, if_uncertain); - let node = guard.ite(storage, if_true, if_false); + let node = guard.ite_uncertain(storage, if_true, if_uncertain, if_false); let left_source_order = storage.ordered_source_order(guard_source_order, if_true_source_order); let right_source_order = storage @@ -4678,15 +5068,23 @@ impl InteriorNode { } } - let mut path = self.path_assignments(storage, source_order); - let mut visitor = AbstractVisitor { should_remove }; - let ControlFlow::Continue(result) = path.visit(db, env, storage, self.node(), &mut visitor); - result + let mut path = self.path_assignments(db, env, storage, source_order); + let mut visitor = AbstractVisitor { + should_remove, + limits, + }; + let (node, derived_source_order) = + path.visit(db, env, storage, self.node(), &mut visitor)?; + let derived_source_order = + path.projection_source_order(storage, source_order, derived_source_order); + ControlFlow::Continue((node, derived_source_order)) } - fn path_assignments( + fn path_assignments<'db>( self, - storage: &mut ConstraintSetStorage<'_>, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, source_order: Option, ) -> PathAssignments { let mut constraints: SmallVec<[_; 8]> = SmallVec::new(); @@ -4705,7 +5103,26 @@ impl InteriorNode { .get_index_of(constraint) .expect("every BDD constraint should have a source-order entry") }); - PathAssignments::new(constraints) + + if !self.node().is_single_conjunction(storage) { + return PathAssignments::new(constraints, FxHashSet::default()); + } + + let mut independent_typevars = FxHashSet::default(); + let mut dependent_typevars = FxHashSet::default(); + for constraint_id in &constraints { + let constraint = storage.constraint_data(*constraint_id); + let typevar = storage.typevar_id(db, constraint.typevar); + if constraint.has_concrete_bounds(db, env) { + independent_typevars.insert(typevar); + } else { + dependent_typevars.extend(storage.constraint_support(*constraint_id).iter()); + } + } + + independent_typevars.retain(|typevar| !dependent_typevars.contains(typevar)); + + PathAssignments::new(constraints, independent_typevars) } } @@ -4714,18 +5131,46 @@ impl InteriorNode { pub(crate) enum Solutions<'db> { Unsatisfiable, Unconstrained, - Constrained(Vec>), + Constrained(SolutionPaths<'db>), } -pub(crate) type Solution<'db> = Vec>; - -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -pub struct TypeVarSolution<'db> { - pub(crate) bound_typevar: BoundTypeVarInstance<'db>, - pub(crate) solution: Type<'db>, +/// The retained solution paths and whether all their bindings could be computed. +/// +/// An unsolved variable can occur in a complete result when no evidence selects its type. An +/// exhausted budget is different: consumers must not treat the fallback bindings as an exhaustive +/// set of valid specializations. +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum SolutionPaths<'db> { + Complete(Vec>), + BudgetExceeded(Vec>), } -/// An assignment of one BDD variable to either `true` or `false`. (When evaluating a BDD, we +impl<'db> SolutionPaths<'db> { + /// Borrows the available solution paths, including fallback bindings if solving was incomplete. + /// Match the outcome directly when completeness matters. + pub(crate) fn as_slice(&self) -> &[Solution<'db>] { + match self { + Self::Complete(paths) | Self::BudgetExceeded(paths) => paths, + } + } + + /// Returns the available solution paths, discarding completeness information. + pub(crate) fn into_vec(self) -> Vec> { + match self { + Self::Complete(paths) | Self::BudgetExceeded(paths) => paths, + } + } +} + +pub(crate) type Solution<'db> = Vec>; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub struct TypeVarSolution<'db> { + pub(crate) bound_typevar: BoundTypeVarInstance<'db>, + pub(crate) solution: Type<'db>, +} + +/// An assignment of one BDD variable to either `true` or `false`. (When evaluating a BDD, we /// must provide an assignment for each variable present in the BDD.) #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] pub(crate) enum ConstraintAssignment { @@ -4764,1321 +5209,67 @@ impl ConstraintAssignment { env: &'a ProgramEnvironment<'db>, storage: &'a ConstraintSetStorage<'db>, ) -> impl Display + 'a { - struct DisplayConstraintAssignment<'db, 'c> { - assignment: ConstraintAssignment, - db: &'db dyn Db, - env: &'c ProgramEnvironment<'db>, - storage: &'c ConstraintSetStorage<'db>, - } - - impl DisplayConstraintAssignment<'_, '_> { - fn equality_sign(&self) -> &'static str { - match self.assignment { - ConstraintAssignment::Positive(_) => "=", - ConstraintAssignment::Negative(_) => "≠", - ConstraintAssignment::Unconstrained(_) => "=?", - } - } - - fn range_prefix(&self) -> &'static str { - match self.assignment { - ConstraintAssignment::Positive(_) => "", - ConstraintAssignment::Negative(_) => "¬", - ConstraintAssignment::Unconstrained(_) => "?", - } - } - } - - impl Display for DisplayConstraintAssignment<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - - let constraint_data = self.storage.constraint_data(self.assignment.constraint()); - let lower = constraint_data.bounds.materialized_lower(); - let upper = constraint_data.bounds.materialized_upper(); - let typevar = constraint_data.typevar; - if lower.is_equivalent_to(db, self.env, upper) { - // If this typevar is equivalent to another, output the constraint in a - // consistent alphabetical order, regardless of the salsa ordering that we are - // using the in BDD. - if let Type::TypeVar(bound) = lower { - let bound = bound.identity(db).display(db).to_string(); - let typevar = typevar.identity(db).display(db).to_string(); - let (smaller, larger) = if bound < typevar { - (bound, typevar) - } else { - (typevar, bound) - }; - return write!(f, "({} {} {})", smaller, self.equality_sign(), larger); - } - - return write!( - f, - "({} {} {})", - typevar.identity(db).display(db), - self.equality_sign(), - lower.display(db, self.env) - ); - } - - if lower.is_never() && upper.is_object() { - return write!( - f, - "({} {} *)", - typevar.identity(db).display(db), - self.equality_sign() - ); - } - - f.write_str(self.range_prefix())?; - f.write_str("(")?; - if !lower.is_never() { - write!(f, "{} ≤ ", lower.display(db, self.env))?; - } - typevar.identity(db).display(db).fmt(f)?; - if !upper.is_object() { - write!(f, " ≤ {}", upper.display(db, self.env))?; - } - f.write_str(")") - } - } - - DisplayConstraintAssignment { - assignment: self, - db, - env, - storage, - } - } -} - -/// A collection of _sequents_ that describe how the constraints mentioned in a BDD relate to each -/// other. These are used in several BDD operations that need to know about "derived facts" even if -/// they are not mentioned in the BDD directly. These operations involve walking one or more paths -/// from the root node to a terminal node. Each sequent describes paths that are invalid (which are -/// pruned from the search), and new constraints that we can assume to be true even if we haven't -/// seen them directly. -/// -/// Sequent maps are primarily used when walking a BDD path with a [`PathAssignments`]. The -/// `PathAssignments` will hold a sequent map containing all of the constraints that are -/// encountered during the walk. It builds up its sequent map lazily, so that it only has to -/// include sequents for the constraints that are actually encountered. However, we also don't want -/// to perform duplicate work if we perform multiple BDD walks on the same constraint set. The -/// [`for_constraint`][Self::for_constraint] and [`for_constraint_pair`][Self::for_constraint_pair] -/// methods are salsa-tracked, to ensure that we only perform them once for any particular -/// constraint or pair of constraints. `PathAssignments` invokes these methods when it encounters a -/// new constraint, and then merges those cached sequents into its own sequent map. (That means we -/// also share the work of calculating the sequent map across `PathAssignments` for _different_ -/// constraint sets.) -#[derive(Debug, Default)] -struct SequentMap { - sequents: Vec, -} - -/// Describes one rule for deriving new implicit constraints from existing constraints in a BDD -/// path. -#[derive(Clone, Copy, Debug)] -enum Sequent { - /// Sequent of the form `¬C → false` - /// - /// This indicates that `C` is always true. Any path that assumes it is false is impossible and - /// can be pruned. - SingleTautology { ante: ConstraintId }, - - /// Sequent of the form `C₁ ∧ C₂ → false` - /// - /// This indicates that `C₁` and `C₂` are disjoint: it is not possible for both to hold. Any - /// path that assumes both is impossible and can be pruned. - PairImpossibility { - ante1: ConstraintId, - ante2: ConstraintId, - }, - - /// Sequent of the form `C → D` - /// - /// This indicates that `C` on its own is enough to imply `D`. For any path that assumes `C` - /// holds, we can add `D` to the path even if it doesn't appear in the BDD. - SingleImplication { - ante: ConstraintId, - post: ConstraintId, - }, - - /// Sequent of the form `C₁ ∧ C₂ → D` - /// - /// This indicates that if `C₁` and `C₂` are both true, then `D` is guaranteed to be true as - /// well. For any path that assumes both `C₁` and `C₂` hold, we can add `D` to the path even if - /// it doesn't appear in the BDD. - PairImplication { - ante1: ConstraintId, - ante2: ConstraintId, - post: ConstraintId, - }, -} - -impl SequentMap { - /// Returns a sequent map containing the sequents that we can infer from a single constraint in - /// isolation. This method is salsa-tracked so that we only perform this work once per - /// constraint. - fn for_constraint<'db, 'c>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &'c mut ConstraintSetStorage<'db>, - constraint: ConstraintId, - ) -> &'c Self { - let key = constraint; - if !storage.single_sequent_cache.contains_key(&key) { - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - constraint = %constraint.display(db, env, storage), - "add sequents for constraint", - ); - let mut map = SequentMap::default(); - map.add_sequents_for_single(db, env, storage, constraint); - storage.single_sequent_cache.insert(key, map); - } - &storage.single_sequent_cache[&key] - } - - /// Returns a sequent map containing the sequents that we can infer from a pair of constraints. - /// This method is salsa-tracked so that we only perform this work once per constraint pair. - /// - /// (Note that this method is _not_ commutative; you should provide `left` and `right` in the - /// order that they appear in the source code, so that we can construct derived constraints - /// that retain that ordering.) - fn for_constraint_pair<'db, 'c>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &'c mut ConstraintSetStorage<'db>, - left: ConstraintId, - right: ConstraintId, - ) -> &'c Self { - let key = (left, right); - if !storage.pair_sequent_cache.contains_key(&key) { - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - left = %left.display(db, env, storage), - right = %right.display(db, env, storage), - "add sequents for constraint pair", - ); - let mut map = SequentMap::default(); - map.add_sequents_for_pair(db, env, storage, left, right); - storage.pair_sequent_cache.insert(key, map); - } - &storage.pair_sequent_cache[&key] - } - - /// Quickly determines whether two constraints cannot possibly produce any sequents when passed - /// to [`for_constraint_pair`][Self::for_constraint_pair]. If this returns `true`, it is safe - /// to skip calling `for_constraint_pair` for this pair of constraints. - fn pair_cannot_produce_sequents<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left: ConstraintId, - right: ConstraintId, - ) -> bool { - // Currently, the only pattern we look for is when two constraints that have _only_ lower - // bounds, where those lower bounds are disjoint. Given `l₁ ≤ T ∧ l₂ ≤ T`, the only - // sequent we could theoretically produce is `(l₁ | l₂) ≤ T`. But we don't store that as a - // single constraint; we always break that apart into the two smaller constraints that we - // started with. - - let left = storage.constraint_data(left); - let right = storage.constraint_data(right); - if !left.typevar.is_same_typevar_as(db, right.typevar) { - return false; - } - - let ( - ConstraintBounds { - lower: Some(left_lower), - upper: None, - }, - ConstraintBounds { - lower: Some(right_lower), - upper: None, - }, - ) = (left.bounds, right.bounds) - else { - return false; + let (equality_sign, range_prefix) = match self { + ConstraintAssignment::Positive(_) => ("=", ""), + ConstraintAssignment::Negative(_) => ("≠", "¬"), + ConstraintAssignment::Unconstrained(_) => ("=?", "?"), }; - // This call might need its own borrow of the builder's storage, so create a new builder - // that it can use. - let builder = ConstraintSetBuilder::new(); - left_lower - .when_trivially_disjoint_from(db, env, right_lower, &builder, TypeVarSet::None) - .is_trivially_always_satisfied() - } - - fn add_single_tautology(&mut self, ante: ConstraintId) { - self.sequents.push(Sequent::SingleTautology { ante }); - } - - fn add_pair_impossibility(&mut self, ante1: ConstraintId, ante2: ConstraintId) { - self.sequents - .push(Sequent::PairImpossibility { ante1, ante2 }); - } - - fn add_pair_implication<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ante1: ConstraintId, - ante2: ConstraintId, - post: ConstraintId, - ) { - // If the post constraint is unsatisfiable, then the antecedents contradict each other. - let post_data = storage.constraint_data(post); - let (when, source_order) = storage.load( - db, - env, - &post_data - .bounds - .materialized_lower() - .when_constraint_set_assignable_to_owned( - db, - env, - post_data.bounds.materialized_upper(), - ), - ); - if when.is_never_satisfied(db, env, storage, source_order) { - self.add_pair_impossibility(ante1, ante2); - return; - } - - // If either antecedent implies the consequent on its own, this new sequent is redundant. - if ante1.implies(db, env, storage, post) || ante2.implies(db, env, storage, post) { - return; - } - - self.sequents - .push(Sequent::PairImplication { ante1, ante2, post }); - } - - fn add_single_implication(&mut self, ante: ConstraintId, post: ConstraintId) { - if ante == post { - return; - } - - self.sequents - .push(Sequent::SingleImplication { ante, post }); - } - - fn add_sequents_for_single<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - constraint: ConstraintId, - ) { - // If this constraint binds its typevar to `Never ≤ T ≤ object`, then the typevar can take - // on any type, and the constraint is always satisfied. - let constraint_data = storage.constraint_data(constraint); - let lower = constraint_data.bounds.materialized_lower(); - let upper = constraint_data.bounds.materialized_upper(); - if lower.is_never() && upper.is_object() { - self.add_single_tautology(constraint); - return; - } - - // Given a constraint `L ≤ T ≤ U`, `L ≤ U` must also hold. If those bounds contain other - // typevars, we can infer additional constraints. This is easiest to see when the bounds - // _are_ typevars: - // - // 1. `(S ≤ T ≤ U) → (S ≤ U)` - // 2. `(S ≤ T ≤ τ) → (S ≤ τ)` - // 3. `(τ ≤ T ≤ U) → (τ ≤ U)` - // - // but it also holds when the bounds _contain_ typevars: - // - // 4. `(Covariant[S] ≤ T ≤ Covariant[U]) → (S ≤ U)` - // `(Covariant[S] ≤ T ≤ Covariant[τ]) → (S ≤ τ)` - // `(Covariant[τ] ≤ T ≤ Covariant[U]) → (τ ≤ U)` - // - // 5. `(Contravariant[S] ≤ T ≤ Contravariant[U]) → (U ≤ S)` - // `(Contravariant[S] ≤ T ≤ Contravariant[τ]) → (τ ≤ S)` - // `(Contravariant[τ] ≤ T ≤ Contravariant[U]) → (U ≤ τ)` - // - // 6. `(Invariant[S] ≤ T ≤ Invariant[U]) → (S = U)` - // `(Invariant[S] ≤ T ≤ Invariant[τ]) → (S = τ)` - // `(Invariant[τ] ≤ T ≤ Invariant[U]) → (τ = U)` - // - // and whenever the bounds are assignable, even if they don't mention exactly the same - // types: - // - // class Sub(Covariant[int]): ... - // - // 7. `(Covariant[S] ≤ T ≤ Sub) → (S ≤ int)` - // `(Sub ≤ T ≤ Covariant[U]) → (int ≤ U)` - // - // To handle all of these cases, we perform a constraint set assignability check to see - // when `L ≤ U`. This gives us a constraint set, which should be the rhs of the sequent - // implication. (That is, this check directly encodes `(L ≤ T ≤ U) → (L ≤ U)` as an - // implication.) - - // Skip trivial cases where the assignability check won't produce useful results. - if !constraint_data.bounds.has_lower() - || !constraint_data.bounds.has_upper() - || lower.is_never() - || upper.is_object() - { - return; - } - - let (when, source_order) = storage.load( - db, - env, - &lower.when_constraint_set_assignable_to_owned(db, env, upper), - ); - - // If L is _never_ assignable to U, this constraint would violate transitivity, and should - // never have been added. - #[expect(clippy::debug_assert_with_mut_call)] - { - debug_assert!(!when.is_never_satisfied(db, env, storage, source_order)); - } - - // Fast path: If L is trivially always assignable to U, there are no derived constraints - // that we can infer. This would be handled correctly by the logic below, but this is a - // useful early return. Since we only use this check as an early return happy path, we can - // accept false negatives. That lets us use the simpler and cheaper check against - // ALWAYS_TRUE, rather than a more expensive is_always_satisfiable call. - if when == ALWAYS_TRUE { - return; - } - - // Technically, we've just calculated a _constraint set_ as the rhs of this implication. - // Unfortunately, our sequent map can currently only store implications where the rhs is a - // single constraint. - // - // If the constraint set that we get represents a single conjunction, we can still shoehorn - // it into this shape, since we can "break apart" a conjunction on the rhs of an - // implication: - // - // a → b ∧ c ∧ d - // - // becomes - // - // a → b - // a → c - // a → d - // - // That takes care of breaking apart the rhs conjunction: we can add each positive - // constraint as a separate single_implication. - // - // We can also handle _negative_ constraints, because those turn into impossibilities: - // - // a → ¬b - // - // becomes - // - // a ∧ b → false - // - // TODO: This should handle the most common cases. In the future, we could handle arbitrary - // rhs constraint sets by moving this logic into PathAssignments::walk_path, and performing - // it once for _every_ root→always path in the BDD. (That would require resetting the - // PathAssignments state for each of those paths, which is why the logic would have to - // move.) - let mut node = when; - if !node.is_single_conjunction(storage) { - return; - } - - loop { - match node.node() { - Node::AlwaysTrue | Node::AlwaysFalse => break, - Node::Interior(interior) => { - let interior = storage.interior_node_data(interior.node()); - if interior.if_true != ALWAYS_FALSE { - self.add_single_implication(constraint, interior.constraint); - node = interior.if_true; + std::fmt::from_fn(move |f| { + let constraint_data = storage.constraint_data(self.constraint()); + // Render supplied bounds, not the synthetic parameter-list defaults. The ordinary + // identities below retain the existing shorthand for omitted endpoints. + let lower = constraint_data + .stored_lower_bound() + .map_or(Type::Never, ConstraintBound::ty); + let upper = constraint_data + .stored_upper_bound() + .map_or(Type::object(), ConstraintBound::ty); + let typevar = constraint_data.typevar; + if lower.is_equivalent_to(db, env, upper) { + // If this typevar is equivalent to another, output the constraint in a + // consistent alphabetical order, regardless of the salsa ordering that we are + // using the in BDD. + if let Type::TypeVar(bound) = lower { + let bound = bound.identity(db).display(db).to_string(); + let typevar = typevar.identity(db).display(db).to_string(); + let (smaller, larger) = if bound < typevar { + (bound, typevar) } else { - self.add_pair_impossibility(constraint, interior.constraint); - node = interior.if_false; - } - } - } - } - } - - fn add_sequents_for_pair<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left_constraint: ConstraintId, - right_constraint: ConstraintId, - ) { - // If either of the constraints has another typevar as a lower/upper bound, the only - // sequents we can add are for the transitive closure. For instance, if we have - // `(S ≤ T) ∧ (T ≤ int)`, then `(S ≤ int)` will also hold, and we should add a sequent for - // this implication. These are the `mutual_sequents` mentioned below — sequents that come - // about because two typevars are mutually constrained. - // - // Complicating things is that `(S ≤ T)` will be encoded differently depending on how `S` - // and `T` compare in our arbitrary BDD variable ordering. - // - // When `S` comes before `T`, `(S ≤ T)` will be encoded as `(Never ≤ S ≤ T)`, and the - // overall antecedent will be `(Never ≤ S ≤ T) ∧ (T ≤ int)`. Those two individual - // constraints constrain different typevars (`S` and `T`, respectively), and are handled by - // `add_mutual_sequents_for_different_typevars`. - // - // When `T` comes before `S`, `(S ≤ T)` will be encoded as `(S ≤ T ≤ object)`, and the - // overall antecedent will be `(S ≤ T ≤ object) ∧ (T ≤ int)`. Those two individual - // constraints both constrain `T`, and are handled by - // `add_mutual_sequents_for_same_typevars`. - // - // If all of the lower and upper bounds are concrete (i.e., not typevars), then there - // several _other_ sequents that we can add, as handled by `add_concrete_sequents`. - let left_constraint_data = storage.constraint_data(left_constraint); - let left_typevar = left_constraint_data.typevar; - let right_constraint_data = storage.constraint_data(right_constraint); - let right_typevar = right_constraint_data.typevar; - - if !left_typevar.is_same_typevar_as(db, right_typevar) { - self.add_mutual_sequents_for_different_typevars( - db, - env, - storage, - left_constraint, - right_constraint, - ); - self.add_nested_typevar_sequents(db, env, storage, left_constraint, right_constraint); - } else if left_constraint_data - .bounds - .lower - .is_some_and(Type::is_type_var) - || left_constraint_data - .bounds - .upper - .is_some_and(Type::is_type_var) - || right_constraint_data - .bounds - .lower - .is_some_and(Type::is_type_var) - || right_constraint_data - .bounds - .upper - .is_some_and(Type::is_type_var) - { - self.add_mutual_sequents_for_same_typevars( - db, - env, - storage, - left_constraint, - right_constraint, - ); - } else { - self.add_concrete_sequents(db, env, storage, left_constraint, right_constraint); - } - } - - fn add_mutual_sequents_for_different_typevars<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left_constraint: ConstraintId, - right_constraint: ConstraintId, - ) { - // We've structured our constraints so that a typevar's upper/lower bound can only - // be another typevar if the bound is "later" in our arbitrary ordering. That means - // we only have to check this pair of constraints in one direction — though we do - // have to figure out which of the two typevars is constrained, and which one is - // the upper/lower bound. - let left_constraint_data = storage.constraint_data(left_constraint); - let left_typevar = left_constraint_data.typevar; - let right_constraint_data = storage.constraint_data(right_constraint); - let right_typevar = right_constraint_data.typevar; - let (bound_constraint, constrained_constraint) = - if left_typevar.can_be_bound_for(db, storage, right_typevar) { - (left_constraint, right_constraint) - } else { - (right_constraint, left_constraint) - }; - - // We then look for cases where the "constrained" typevar's upper and/or lower bound - // matches the "bound" typevar. If so, we're going to add an implication sequent that - // replaces the upper/lower bound that matched with the bound constraint's corresponding - // bound. - let bound_constraint_data = storage.constraint_data(bound_constraint); - let bound_typevar = bound_constraint_data.typevar; - let constrained_constraint_data = storage.constraint_data(constrained_constraint); - let constrained_typevar = constrained_constraint_data.typevar; - - // Transitive pivots require subtyping; classes with dynamic bases can be assignable to - // unrelated types without being subtypes. - let (new_lower, new_upper) = match ( - constrained_constraint_data.bounds.lower, - constrained_constraint_data.bounds.upper, - bound_constraint_data.bounds.lower, - bound_constraint_data.bounds.upper, - ) { - // (B ≤ C ≤ B) ∧ (BL ≤ B ≤ BU) → (BL ≤ C ≤ BU) - ( - Some(Type::TypeVar(constrained_lower)), - Some(Type::TypeVar(constrained_upper)), - _, - _, - ) if constrained_lower.is_same_typevar_as(db, bound_typevar) - && constrained_upper.is_same_typevar_as(db, bound_typevar) => - { - ( - bound_constraint_data.bounds.lower, - bound_constraint_data.bounds.upper, - ) - } - - // (CL ≤ C ≤ B) ∧ (BL ≤ B ≤ BU) → (CL ≤ C ≤ BU) - (constrained_lower, Some(Type::TypeVar(constrained_upper)), _, _) - if constrained_upper.is_same_typevar_as(db, bound_typevar) => - { - (constrained_lower, bound_constraint_data.bounds.upper) - } - - // (B ≤ C ≤ CU) ∧ (BL ≤ B ≤ BU) → (BL ≤ C ≤ CU) - (Some(Type::TypeVar(constrained_lower)), constrained_upper, _, _) - if constrained_lower.is_same_typevar_as(db, bound_typevar) => - { - (bound_constraint_data.bounds.lower, constrained_upper) - } - - // (CL ≤ C ≤ pivot) ∧ (pivot ≤ B ≤ BU) → (CL ≤ C ≤ B) - (constrained_lower, Some(constrained_upper), Some(bound_lower), _) - if !constrained_upper.is_never() - && !constrained_upper.is_object() - && storage.cached_is_constraint_set_subtype_of( - db, - env, - constrained_upper.top_materialization(db, env), - bound_lower.bottom_materialization(db, env), - ) => - { - (constrained_lower, Some(Type::TypeVar(bound_typevar))) - } - - // (pivot ≤ C ≤ CU) ∧ (BL ≤ B ≤ pivot) → (B ≤ C ≤ CU) - (Some(constrained_lower), constrained_upper, _, Some(bound_upper)) - if !constrained_lower.is_never() - && !constrained_lower.is_object() - && storage.cached_is_constraint_set_subtype_of( - db, - env, - bound_upper.top_materialization(db, env), - constrained_lower.bottom_materialization(db, env), - ) => - { - (Some(Type::TypeVar(bound_typevar)), constrained_upper) - } - - _ => return, - }; - - let mut post_constraints: SmallVec<[ConstraintId; 3]> = SmallVec::new(); - // These are derived logical constraints, not direct inference evidence. Avoid preserving - // explicit bounds that are equivalent to missing lower/upper bounds, so a derived - // `T ≤ U ≤ object` can satisfy a later query for `T ≤ U` without requiring a separate - // materialized-default implication. - let mut constrained_lower = new_lower.filter(|lower| !lower.is_never()); - let mut constrained_upper = new_upper.filter(|upper| !upper.is_object()); - - // The transitive rule above gives us an intended post-condition - // `new_lower ≤ [constrained] ≤ new_upper`. - // - // If a top-level bound typevar is "earlier" than `constrained`, we cannot represent that - // directly as a bound on `constrained` without violating our canonical ordering. - // Instead, split it into equivalent canonical constraints by "moving" that bound onto the - // other typevar: - // - // invalid lower `L ≤ [C]` -> `(Never ≤ [L] ≤ C)` and drop `L` from C's lower bound - // invalid upper `[C] ≤ U` -> `(C ≤ [U] ≤ object)` and drop `U` from C's upper bound - // - // Example: if we derive `[A] ≤ T ≤ [B]` but `A`/`B` are not valid top-level bounds for - // `T` in this ordering, we emit two pair implications: - // `(Never ≤ [A] ≤ T)` and `(T ≤ [B] ≤ object)`. - // This preserves the relationship while keeping all derived constraints canonical. - if let Some(Type::TypeVar(lower_bound_typevar)) = new_lower - && !lower_bound_typevar.can_be_bound_for(db, storage, constrained_typevar) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - lower_bound_typevar, - None, - Some(Type::TypeVar(constrained_typevar)), - )); - constrained_lower = None; - } - - if let Some(Type::TypeVar(upper_bound_typevar)) = new_upper - && !upper_bound_typevar.can_be_bound_for(db, storage, constrained_typevar) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - upper_bound_typevar, - Some(Type::TypeVar(constrained_typevar)), - None, - )); - constrained_upper = None; - } - - if !(constrained_lower.is_none_or(|ty| ty.is_never()) - && constrained_upper.is_none_or(|ty| ty.is_object())) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - constrained_typevar, - constrained_lower, - constrained_upper, - )); - } - - for post_constraint in post_constraints { - self.add_pair_implication( - db, - env, - storage, - left_constraint, - right_constraint, - post_constraint, - ); - } - } - - /// Adds sequents for the case where one constraint's lower or upper bound contains another - /// constraint's typevar nested inside a parameterized type (e.g., `U ≤ Covariant[T]`). - /// - /// This is distinct from `add_mutual_sequents_for_different_typevars`, which handles the case - /// where a typevar appears _directly_ as a top-level lower/upper bound (e.g., `U ≤ T`). A - /// bare `Type::TypeVar` is technically a special case of covariant nesting (since the variance - /// of `T` in `T` itself is covariant), but the existing direct-typevar logic handles it - /// separately because it requires careful canonical ordering of typevar-to-typevar constraints - /// that the generic nested-typevar logic here does not need to worry about. - fn add_nested_typevar_sequents<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left_constraint: ConstraintId, - right_constraint: ConstraintId, - ) { - // Keep this precheck aligned with `variance_of`, which visits lazy types. - let has_typevar_bound = |bounds: ConstraintBounds<'db>| { - bounds - .lower - .is_some_and(|bound| any_over_type(db, env, bound, true, Type::is_type_var)) - || bounds - .upper - .is_some_and(|bound| any_over_type(db, env, bound, true, Type::is_type_var)) - }; - if !has_typevar_bound(storage.constraint_data(left_constraint).bounds) - && !has_typevar_bound(storage.constraint_data(right_constraint).bounds) - { - return; - } - - let mut try_tightening = - |bound_constraint: ConstraintId, constrained_constraint: ConstraintId| { - let bound_data = storage.constraint_data(bound_constraint); - let bound_typevar = bound_data.typevar; - let bound_identity = bound_typevar.identity(db); - let constrained_data = storage.constraint_data(constrained_constraint); - let constrained_typevar = constrained_data.typevar; - let constrained_identity = constrained_typevar.identity(db); - let constrained_lower = constrained_data.bounds.materialized_lower(); - let constrained_upper = constrained_data.bounds.materialized_upper(); - - // If the replacement contains the bound typevar itself (e.g., the bound - // constraint is `_V ≤ G[_V]`), or the constrained typevar (e.g., the bound - // constraint is `_T ≤ G[_V]` and we're about to substitute into `_V ≤ G[_T]`), - // substituting would create a deeper nesting of the same recursive pattern - // that triggers the same substitution again ad infinitum. Skip in both cases. - // - // Fast-path bare typevar replacements (`Type::TypeVar`) using equality checks - // instead of calling `variance_of` on them. This avoids a large number of tiny - // tracked `variance_of` queries in hot paths. - let replacement_mentions_bound_or_constrained = |replacement: Type<'db>| { - replacement.variance_of(db, env, bound_identity) != TypeVarVariance::Bivariant - || replacement.variance_of(db, env, constrained_identity) - != TypeVarVariance::Bivariant - }; - - // Check the upper bound of the constrained constraint for nested occurrences of - // the bound typevar. We use `variance_of` as our combined presence + variance - // check: `Bivariant` means the typevar doesn't appear in the type (or is genuinely - // bivariant, which is semantically equivalent — no implication is needed in either - // case). - // - // Note: if `Bivariant` is ever removed from the `TypeVarVariance` enum, we would - // need an alternative representation for "typevar not present" - // (e.g., `Option`). - let upper_replacement = match ( - constrained_upper.variance_of(db, env, bound_identity), - bound_data.bounds.lower, - bound_data.bounds.upper, - ) { - (TypeVarVariance::Bivariant, _, _) => None, - // Skip bare typevars — those are handled by - // `add_mutual_sequents_for_different_typevars`. - _ if constrained_upper.is_type_var() => None, - // Covariance preserves direction: upper bound on T substitutes into upper - // bound. A ≤ B → G[A] ≤ G[B], so (T ≤ u_B) gives G[T] ≤ G[u_B]. - (TypeVarVariance::Covariant, _, Some(bound_upper)) - if !bound_upper.is_object() => - { - bound_data.bounds.upper - } - // Contravariance flips direction: lower bound on T substitutes into upper - // bound. A ≤ B → G[B] ≤ G[A], so (l_B ≤ T) gives G[T] ≤ G[l_B]. - (TypeVarVariance::Contravariant, Some(bound_lower), _) - if !bound_lower.is_never() => - { - bound_data.bounds.lower - } - // Invariance requires equality: only substitute if l_B = u_B. - (TypeVarVariance::Invariant, Some(bound_lower), Some(bound_upper)) - if bound_lower == bound_upper && !bound_lower.is_never() => - { - bound_data.bounds.lower - } - _ => None, - }; - let upper_replacement = upper_replacement.filter(|replacement| { - // Substituting one typevar for another into large unions can generate many - // very-weak derived constraints and cause severe performance regressions. - // Keep the common/non-union case enabled; skip union upper bounds for this - // specific typevar-to-typevar replacement shape. - if replacement.is_type_var() && constrained_upper.is_union() { - return false; - } - !replacement_mentions_bound_or_constrained(*replacement) - }); - if let Some(replacement) = upper_replacement { - let new_upper = constrained_upper.substitute_one_typevar( - db, - env, - bound_typevar, - replacement, - ); - if new_upper != constrained_upper { - let post = ConstraintId::new_with_bounds( - db, - env, - storage, - constrained_typevar, - constrained_data.bounds.lower, - Some(new_upper), - ); - self.add_pair_implication( - db, - env, - storage, - bound_constraint, - constrained_constraint, - post, - ); - } - } - - // Check the lower bound of the constrained constraint for nested occurrences. - let lower_replacement = match ( - constrained_lower.variance_of(db, env, bound_identity), - bound_data.bounds.lower, - bound_data.bounds.upper, - ) { - (TypeVarVariance::Bivariant, _, _) => None, - _ if constrained_lower.is_type_var() => None, - // Covariance preserves direction: lower bound on T substitutes into lower - // bound. A ≤ B → G[A] ≤ G[B], so (l_B ≤ T) gives G[l_B] ≤ G[T]. - (TypeVarVariance::Covariant, Some(bound_lower), _) - if !bound_lower.is_never() => - { - bound_data.bounds.lower - } - // Contravariance flips direction: upper bound on T substitutes into lower - // bound. A ≤ B → G[B] ≤ G[A], so (T ≤ u_B) gives G[u_B] ≤ G[T]. - (TypeVarVariance::Contravariant, _, Some(bound_upper)) - if !bound_upper.is_object() => - { - bound_data.bounds.upper - } - // Invariance requires equality: only substitute if l_B = u_B. - (TypeVarVariance::Invariant, Some(bound_lower), Some(bound_upper)) - if bound_lower == bound_upper && !bound_lower.is_never() => - { - bound_data.bounds.lower - } - _ => None, - }; - let lower_replacement = lower_replacement.filter(|replacement| { - // Substituting one typevar for another into large intersections can generate - // many very-weak derived constraints and cause severe performance regressions. - // Keep the common/non-intersection case enabled; skip intersection lower - // bounds for this specific typevar-to-typevar replacement shape. - if replacement.is_type_var() && constrained_lower.is_intersection() { - return false; - } - !replacement_mentions_bound_or_constrained(*replacement) - }); - if let Some(replacement) = lower_replacement { - let new_lower = constrained_lower.substitute_one_typevar( - db, - env, - bound_typevar, - replacement, - ); - if new_lower != constrained_lower { - let post = ConstraintId::new_with_bounds( - db, - env, - storage, - constrained_typevar, - Some(new_lower), - constrained_data.bounds.upper, - ); - self.add_pair_implication( - db, - env, - storage, - bound_constraint, - constrained_constraint, - post, - ); - } - } - }; - - try_tightening(left_constraint, right_constraint); - try_tightening(right_constraint, left_constraint); - - // Additionally, check if one constraint's bare typevar *bound* appears nested in the other - // constraint's bounds. This handles the "dual" direction: instead of substituting a - // typevar's concrete bounds into another constraint (tightening), we substitute the - // typevar itself for one of its bare typevar bounds (weakening), creating a cross-typevar - // link. - // - // For example, given `(Covariant[S] ≤ C) ∧ (Never ≤ B ≤ S)`, S is B's upper bound and - // appears covariantly in C's lower bound. Since `B ≤ S`, covariance tells us that - // `Covariant[B] ≤ Covariant[S]`. Transitivity then lets us derive `Covariant[B] ≤ C`. - // - // The derived constraint is weaker than the original, but it introduces a relationship - // between B and C that we need to remember and propagate if we ever existentially quantify - // away S. - // - // TODO: This only handles the case where the bound (in this case, S) is a bare typevar. A - // future extension could handle arbitrary types by pattern-matching on generic alias - // structure. - // - // This is defined as a separate closure because it iterates over the bound constraint's - // bare typevar bounds, which is a different axis than `try_tightening`'s check on the - // bound constraint's typevar. - let mut try_weakening = - |bound_constraint: ConstraintId, constrained_constraint: ConstraintId| { - let bound_data = storage.constraint_data(bound_constraint); - let bound_typevar = bound_data.typevar; - let bound_lower = bound_data.bounds.materialized_lower(); - let constrained_data = storage.constraint_data(constrained_constraint); - let constrained_typevar = constrained_data.typevar; - let constrained_lower = constrained_data.bounds.materialized_lower(); - let constrained_upper = constrained_data.bounds.materialized_upper(); - - let mut try_one_bound = |bound: Type<'db>, is_upper_bound: bool| { - let Some(nested_typevar) = bound.as_typevar() else { - return; + (typevar, bound) }; - - // Skip if the nested typevar is the same as the constrained typevar — that - // case is handled by `add_mutual_sequents_for_different_typevars`. - if nested_typevar.is_same_typevar_as(db, constrained_typevar) - || nested_typevar.is_same_typevar_as(db, bound_typevar) - { - return; - } - - let replacement = Type::TypeVar(bound_typevar); - - // Check the constrained constraint's upper bound for nested occurrences of - // nested_typevar (S). We want to *weaken* (relax) the upper bound by making it - // larger: - // - Covariant + S is B's lower bound (S ≤ B): G[S] ≤ G[B] → weaker. Emit. - // - Contravariant + S is B's upper bound (B ≤ S): G[S] ≤ G[B] → weaker. Emit. - // - Other combinations tighten rather than weaken. Skip. - let should_weaken_upper = !constrained_upper.is_type_var() - && !constrained_upper.is_never() - && !constrained_upper.is_object() - && !constrained_upper.is_dynamic() - && match constrained_upper.variance_of(db, env, nested_typevar.identity(db)) - { - TypeVarVariance::Bivariant => false, - TypeVarVariance::Covariant => !is_upper_bound, - TypeVarVariance::Contravariant => is_upper_bound, - TypeVarVariance::Invariant => { - bound_data.bounds.lower == bound_data.bounds.upper - && !bound_lower.is_never() - } - }; - if should_weaken_upper { - let new_upper = constrained_upper.substitute_one_typevar( - db, - env, - nested_typevar, - replacement, - ); - if new_upper != constrained_upper { - let post = ConstraintId::new_with_bounds( - db, - env, - storage, - constrained_typevar, - constrained_data.bounds.lower, - Some(new_upper), - ); - self.add_pair_implication( - db, - env, - storage, - bound_constraint, - constrained_constraint, - post, - ); - } - } - - // Ditto for the lower bound. - let should_weaken_lower = !constrained_lower.is_type_var() - && !constrained_lower.is_never() - && !constrained_lower.is_object() - && !constrained_lower.is_dynamic() - && match constrained_lower.variance_of(db, env, nested_typevar.identity(db)) - { - TypeVarVariance::Bivariant => false, - TypeVarVariance::Covariant => is_upper_bound, - TypeVarVariance::Contravariant => !is_upper_bound, - TypeVarVariance::Invariant => { - bound_data.bounds.lower == bound_data.bounds.upper - && !bound_lower.is_never() - } - }; - if should_weaken_lower { - let new_lower = constrained_lower.substitute_one_typevar( - db, - env, - nested_typevar, - replacement, - ); - if new_lower != constrained_lower { - let post = ConstraintId::new_with_bounds( - db, - env, - storage, - constrained_typevar, - Some(new_lower), - constrained_data.bounds.upper, - ); - self.add_pair_implication( - db, - env, - storage, - bound_constraint, - constrained_constraint, - post, - ); - } - } - }; - - // For each bare typevar bound S of the bound constraint, check if S appears - // nested in the constrained constraint's bounds. If so, we can substitute B - // (the bound constraint's typevar) for S, producing a weaker but useful - // constraint. - if let Some(upper) = bound_data.bounds.upper { - try_one_bound(upper, true); + return write!(f, "({smaller} {equality_sign} {larger})"); } - if let Some(lower) = bound_data.bounds.lower { - try_one_bound(lower, false); - } - }; - try_weakening(left_constraint, right_constraint); - try_weakening(right_constraint, left_constraint); - } - - fn add_mutual_sequents_for_same_typevars<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left_constraint: ConstraintId, - right_constraint: ConstraintId, - ) { - let mut try_one_direction = - |left_constraint: ConstraintId, right_constraint: ConstraintId| { - let left_constraint_data = storage.constraint_data(left_constraint); - let left_lower = left_constraint_data.bounds.lower; - let left_upper = left_constraint_data.bounds.upper; - let right_constraint_data = storage.constraint_data(right_constraint); - let right_lower = right_constraint_data.bounds.lower; - let right_upper = right_constraint_data.bounds.upper; - let mut new_constraints = - |bound_typevar: BoundTypeVarInstance<'db>, - mut right_lower: Option>, - mut right_upper: Option>| { - if let Some(Type::TypeVar(other_bound_typevar)) = right_lower - && bound_typevar.is_same_typevar_as(db, other_bound_typevar) - { - right_lower = None; - } - if let Some(Type::TypeVar(other_bound_typevar)) = right_upper - && bound_typevar.is_same_typevar_as(db, other_bound_typevar) - { - right_upper = None; - } - - // Same idea as `add_mutual_sequents_for_different_typevars`: if a derived - // post-condition for `[bound]` has top-level typevar bounds in the wrong - // orientation, split it into equivalent canonical constraints instead of - // dropping it. - let mut post_constraints: SmallVec<[ConstraintId; 3]> = SmallVec::new(); - // These are derived logical constraints, not direct inference evidence. - // Avoid preserving explicit bounds that are equivalent to missing - // lower/upper bounds; direct constraints still retain their explicit - // bound presence. - let mut constrained_lower = right_lower.filter(|lower| !lower.is_never()); - let mut constrained_upper = right_upper.filter(|upper| !upper.is_object()); - - if let Some(Type::TypeVar(lower_bound_typevar)) = right_lower - && !lower_bound_typevar.can_be_bound_for(db, storage, bound_typevar) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - lower_bound_typevar, - None, - Some(Type::TypeVar(bound_typevar)), - )); - constrained_lower = None; - } - - if let Some(Type::TypeVar(upper_bound_typevar)) = right_upper - && !upper_bound_typevar.can_be_bound_for(db, storage, bound_typevar) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - upper_bound_typevar, - Some(Type::TypeVar(bound_typevar)), - None, - )); - constrained_upper = None; - } - - if !(constrained_lower.unwrap_or(Type::Never).is_never() - && constrained_upper.unwrap_or(Type::object()).is_object()) - { - post_constraints.push(ConstraintId::new_with_bounds( - db, - env, - storage, - bound_typevar, - constrained_lower, - constrained_upper, - )); - } - - post_constraints - }; - let post_constraints = match (left_lower, left_upper) { - ( - Some(Type::TypeVar(bound_typevar)), - Some(Type::TypeVar(other_bound_typevar)), - ) if bound_typevar.is_same_typevar_as(db, other_bound_typevar) => { - new_constraints(bound_typevar, right_lower, right_upper) - } - (Some(Type::TypeVar(bound_typevar)), _) => { - new_constraints(bound_typevar, None, right_upper) - } - (_, Some(Type::TypeVar(bound_typevar))) => { - new_constraints(bound_typevar, right_lower, None) - } - _ => return, - }; - for post_constraint in post_constraints { - self.add_pair_implication( - db, - env, - storage, - left_constraint, - right_constraint, - post_constraint, - ); - } - }; - - try_one_direction(left_constraint, right_constraint); - try_one_direction(right_constraint, left_constraint); - } - - fn add_concrete_sequents<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - left_constraint: ConstraintId, - right_constraint: ConstraintId, - ) { - // These might seem redundant with the intersection check below, since `a → b` means that - // `a ∧ b = a`. But we are not normalizing constraint bounds, and these clauses help us - // identify constraints that are identical besides e.g. ordering of union/intersection - // elements. (For instance, when processing `T ≤ τ₁ & τ₂` and `T ≤ τ₂ & τ₁`, these clauses - // would add sequents for `(T ≤ τ₁ & τ₂) → (T ≤ τ₂ & τ₁)` and vice versa.) - if storage.cached_constraint_implies(db, env, left_constraint, right_constraint) { - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - left = %left_constraint.display(db, env, storage), - right = %right_constraint.display(db, env, storage), - "left implies right", - ); - self.add_single_implication(left_constraint, right_constraint); - } - if storage.cached_constraint_implies(db, env, right_constraint, left_constraint) { - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - left = %left_constraint.display(db, env, storage), - right = %right_constraint.display(db, env, storage), - "right implies left", - ); - self.add_single_implication(right_constraint, left_constraint); - } - - match left_constraint.intersect(db, env, storage, right_constraint) { - IntersectionResult::Simplified(intersection_constraint_data) => { - let intersection_constraint = - storage.intern_constraint(db, env, intersection_constraint_data); - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - left = %left_constraint.display(db, env, storage), - right = %right_constraint.display(db, env, storage), - intersection = %intersection_constraint.display(db, env, storage), - "left and right overlap", - ); - self.add_pair_implication( - db, - env, - storage, - left_constraint, - right_constraint, - intersection_constraint, + return write!( + f, + "({} {} {})", + typevar.identity(db).display(db), + equality_sign, + lower.display(db, env) ); - self.add_single_implication(intersection_constraint, left_constraint); - self.add_single_implication(intersection_constraint, right_constraint); } - // The sequent map only needs to include constraints that might appear in a BDD. If the - // intersection does not collapse to a single constraint, then there's no new - // constraint that we need to add to the sequent map. - IntersectionResult::CannotSimplify => {} - - IntersectionResult::Disjoint => { - tracing::trace!( - target: "ty_python_semantic::types::constraints::SequentMap", - left = %left_constraint.display(db, env, storage), - right = %right_constraint.display(db, env, storage), - "left and right are disjoint", + if lower.is_never() && upper.is_object() { + return write!( + f, + "({} {} *)", + typevar.identity(db).display(db), + equality_sign ); - self.add_pair_impossibility(left_constraint, right_constraint); - } - } - } - - #[expect(dead_code)] // Keep this around for debugging purposes - fn display<'db, 'a>( - &'a self, - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - storage: &'a ConstraintSetStorage<'db>, - prefix: &'a dyn Display, - ) -> impl Display + 'a { - struct DisplaySequentMap<'a, 'db> { - map: &'a SequentMap, - prefix: &'a dyn Display, - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - storage: &'a ConstraintSetStorage<'db>, - } - - impl Display for DisplaySequentMap<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - let mut first = true; - let mut maybe_write_prefix = |f: &mut std::fmt::Formatter<'_>| { - if first { - first = false; - Ok(()) - } else { - write!(f, "\n{}", self.prefix) - } - }; - - for sequent in &self.map.sequents { - match sequent { - Sequent::SingleTautology { .. } => {} - - Sequent::PairImpossibility { ante1, ante2 } => { - maybe_write_prefix(f)?; - write!( - f, - "{} ∧ {} → false", - ante1.display(db, self.env, self.storage), - ante2.display(db, self.env, self.storage), - )?; - } - - Sequent::PairImplication { ante1, ante2, post } => { - maybe_write_prefix(f)?; - write!( - f, - "{} ∧ {} → {}", - ante1.display(db, self.env, self.storage), - ante2.display(db, self.env, self.storage), - post.display(db, self.env, self.storage), - )?; - } - - Sequent::SingleImplication { ante, post } => { - maybe_write_prefix(f)?; - write!( - f, - "{} → {}", - ante.display(db, self.env, self.storage), - post.display(db, self.env, self.storage) - )?; - } - } - } - - if first { - f.write_str("[no sequents]")?; - } - Ok(()) } - } - DisplaySequentMap { - map: self, - prefix, - db, - env, - storage, - } + f.write_str(range_prefix)?; + f.write_str("(")?; + if !lower.is_never() { + write!(f, "{} ≤ ", lower.display(db, env))?; + } + typevar.identity(db).display(db).fmt(f)?; + if !upper.is_object() { + write!(f, " ≤ {}", upper.display(db, env))?; + } + f.write_str(")") + }) } } @@ -6120,6 +5311,12 @@ trait PathVisitor { type Interior; type Break; + /// Called before visiting any interior or terminal node. Returning `Break` prevents the + /// traversal from entering the node or deriving facts from its outgoing edges. + fn visit_node(&mut self) -> ControlFlow { + ControlFlow::Continue(()) + } + /// Called when we reach the end of a satisfied path. `path` will contain all of the /// assignments on this path. The `Result` value that you return will be propagated back up as /// we "unwind" this path. @@ -6331,759 +5528,25 @@ impl PathFold for IsNeverSatisfiedVisitor { fn impossible<'db>( &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _path: &PathAssignments, - ) -> ControlFlow { - ControlFlow::Continue(()) - } - - fn combine<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _if_true: Self::Result, - _if_uncertain: Self::Result, - _if_false: Self::Result, - ) -> ControlFlow { - ControlFlow::Continue(()) - } -} - -/// The collection of constraints that we know to be true or false at a certain point when -/// traversing a BDD. -/// -/// An important part of this traversal is that not all of those constraints come directly from the -/// BDD, since constraints are not independent. In particular, there can be "implications", which -/// record e.g. when two constraints both being true imply another: -/// `A ≤ list[B] ∧ B ≤ int → A ≤ list[int]`. If we see `A ≤ list[B]` and `B ≤ int` in a BDD path, -/// we can _assume_ that `A ≤ list[int]` also holds, even if it doesn't actually appear in the BDD. -/// -/// Unfortunately, there are certain implications that are technically true, but not helpful; -/// for instance, because they cause us to endlessly expand a constraint by substituting a bound -/// into itself. -/// -/// We use a "fuel" mechanism to prevent these kinds of situations, without having to play -/// whack-a-mole to implement detection patterns for all of the pathological patterns. Each -/// derived constraint costs at least one unit of fuel. Nested typevars increase that cost according -/// to their depth, as does any constructor depth introduced relative to the antecedents. Measuring -/// structural growth instead of absolute depth ensures that propagating an existing complex -/// concrete bound remains cheap, while repeatedly wrapping that bound continues to consume path -/// fuel after no nested typevars remain. -/// -/// We track this fuel in two ways: First, there is a global limit on the total amount of work we -/// are willing to do for a particular BDD path traversal. Second, there is a more focused -/// "per-path" limit, which records how far removed a derived constraint is from a constraint that -/// actually appears in the BDD. If either of those limits are exceeded, we ignore the derived -/// constraint that we are currently considering. -#[derive(Debug)] -pub(crate) struct PathAssignments { - /// All of the rules that we know for inferring derived constraints on the current path. - sequents: Vec, - /// Each assignment's source constraint and the first per-path fuel value with which it was - /// derived. - assignments: FxIndexMap, - /// Additional per-path fuel values that can derive an assignment, keyed by its index in - /// `assignments`. These are stored separately so that branch-local additions can be rolled - /// back by truncating the set. Only the greatest fuel value participates in further - /// derivation. - additional_fuels: Vec<(usize, u16)>, - /// The amount of global fuel that remains across all assignments and paths. - remaining_overall_fuel: u16, - /// Constraints that we have discovered, mapped to whether we have processed them yet. (This - /// ensures a stable order for all of the derived constraints that we create, while still - /// letting us create them lazily.) - discovered: FxIndexMap, - /// Constraint pairs that we have already checked and added to `sequents`. - elaborated_pairs: FxHashSet<(ConstraintId, ConstraintId)>, - - /// Derived assignments that have been queued up to be added to the current path. - assignment_queue: VecDeque<(ConstraintAssignment, AssignmentFuel)>, - - /// The next chunk of derived assignments that have been queued up to add to the current path. - /// If we derive the same assignment multiple times, we keep the derivation that lets us make - /// the most additional progress (more remaining fuel for this derivation chain, less overall - /// fuel consumed). - new_assignments: FxIndexMap, -} - -/// The total amount of fuel that we are willing to spend for this path traversal. This was -/// chosen empirically, to balance performance with accurate ecosystem diagnostics. -const OVERALL_FUEL_BUDGET: u16 = 256; - -/// The maximum number of "trips through the sequent map" that we are willing to take for a -/// derived constraint. This records how far removed we are from a constraint that comes -/// directly from the BDD. -const PATH_FUEL_BUDGET: u16 = 8; - -/// The fuel cost of deriving a particular assignment during BDD path walking. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct AssignmentFuel { - /// The amount of fuel consumed when deriving the assignment, or None if this assignment came - /// directly from the BDD - consumed: Option, - /// The amount of fuel remaining on the derivation path after deriving this assignment - remaining: u16, -} - -impl AssignmentFuel { - fn origin() -> AssignmentFuel { - AssignmentFuel { - consumed: None, - remaining: PATH_FUEL_BUDGET, - } - } - - fn derived(consumed: u16, remaining: u16) -> AssignmentFuel { - AssignmentFuel { - consumed: Some(consumed), - remaining, - } - } - - fn is_derived(self) -> bool { - self.consumed.is_some() - } -} - -impl PartialOrd for AssignmentFuel { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for AssignmentFuel { - fn cmp(&self, other: &Self) -> Ordering { - let self_key = (self.remaining, std::cmp::Reverse(self.consumed)); - let other_key = (other.remaining, std::cmp::Reverse(other.consumed)); - self_key.cmp(&other_key) - } -} - -impl PathAssignments { - fn new(constraints: impl IntoIterator) -> Self { - let discovered = constraints - .into_iter() - .map(|constraint| (constraint, false)) - .collect(); - Self { - sequents: Vec::default(), - assignments: FxIndexMap::default(), - additional_fuels: Vec::default(), - discovered, - elaborated_pairs: FxHashSet::default(), - remaining_overall_fuel: OVERALL_FUEL_BUDGET, - assignment_queue: VecDeque::default(), - new_assignments: FxIndexMap::default(), - } - } - - fn visit<'db, V>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - node: NodeId, - visitor: &mut V, - ) -> ControlFlow - where - V: PathVisitor, - { - self.visit_inner(db, env, storage, node, visitor, false) - } - - /// Visits the paths of the negation of `node`, without constructing that negation eagerly. - fn visit_negated<'db, V>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - node: NodeId, - visitor: &mut V, - ) -> ControlFlow - where - V: PathVisitor, - { - self.visit_inner(db, env, storage, node, visitor, true) - } - - fn visit_inner<'db, V>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - node: NodeId, - visitor: &mut V, - negated: bool, - ) -> ControlFlow - where - V: PathVisitor, - { - match node.node() { - Node::AlwaysTrue if negated => visitor.visit_unsatisfied(db, storage, self), - Node::AlwaysTrue => visitor.visit_satisfied(db, storage, self), - - Node::AlwaysFalse if negated => visitor.visit_satisfied(db, storage, self), - Node::AlwaysFalse => visitor.visit_unsatisfied(db, storage, self), - - Node::Interior(interior) => { - let interior_value = visitor.enter_interior(db, storage, interior)?; - let interior = storage.interior_node_data(node); - - let true_subtree = if negated { - interior.if_true.or(storage, interior.if_uncertain) - } else { - interior.if_true - }; - let if_true = self.walk_edge( - db, - env, - storage, - interior.constraint.when_true(), - |storage, path, new_range, found_conflict| { - let subtree = if found_conflict { - visitor.visit_impossible(db, storage, path) - } else { - path.visit_inner(db, env, storage, true_subtree, visitor, negated) - }; - match subtree { - ControlFlow::Continue(subtree) => visitor.visit_edge( - db, - storage, - &interior_value, - subtree, - path, - new_range, - ), - ControlFlow::Break(b) => ControlFlow::Break(b), - } - }, - )?; - - let if_uncertain = if negated { - let subtree = visitor.visit_impossible(db, storage, self)?; - visitor.visit_edge(db, storage, &interior_value, subtree, self, 0..0)? - } else { - self.walk_edge( - db, - env, - storage, - interior.constraint.when_unconstrained(), - |storage, path, new_range, found_conflict| { - let subtree = if found_conflict { - visitor.visit_impossible(db, storage, path) - } else { - path.visit_inner( - db, - env, - storage, - interior.if_uncertain, - visitor, - false, - ) - }; - match subtree { - ControlFlow::Continue(subtree) => visitor.visit_edge( - db, - storage, - &interior_value, - subtree, - path, - new_range, - ), - ControlFlow::Break(b) => ControlFlow::Break(b), - } - }, - )? - }; - - let false_subtree = if negated { - interior.if_false.or(storage, interior.if_uncertain) - } else { - interior.if_false - }; - let if_false = self.walk_edge( - db, - env, - storage, - interior.constraint.when_false(), - |storage, path, new_range, found_conflict| { - let subtree = if found_conflict { - visitor.visit_impossible(db, storage, path) - } else { - path.visit_inner(db, env, storage, false_subtree, visitor, negated) - }; - match subtree { - ControlFlow::Continue(subtree) => visitor.visit_edge( - db, - storage, - &interior_value, - subtree, - path, - new_range, - ), - ControlFlow::Break(b) => ControlFlow::Break(b), - } - }, - )?; - - visitor.leave_interior( - db, - storage, - &interior_value, - if_true, - if_uncertain, - if_false, - ) - } - } - } - - /// Walks one of the outgoing edges of an internal BDD node. `assignment` describes the - /// constraint that the BDD node checks, and whether we are following the `if_true` or - /// `if_false` edge. - /// - /// This new assignment might cause this path to become impossible — for instance, if we were - /// already assuming (from an earlier edge in the path) a constraint that is disjoint with this - /// one. We might also be able to infer _other_ assignments that do not appear in the BDD - /// directly, but which are implied from a combination of constraints that we _have_ seen. - /// - /// To handle all of this, you provide a callback. If the path has become impossible, we will - /// return `None` _without invoking the callback_. If the path does not contain any - /// contradictions, we will invoke the callback and return its result (wrapped in `Some`). - /// - /// Your callback will also be provided a slice of all of the constraints that we were able to - /// infer from `assignment` combined with the information we already knew. (For borrow-check - /// reasons, we provide this as a [`Range`]; use that range to index into `self.assignments` to - /// get the list of all of the assignments that we learned from this edge.) - /// - /// You will presumably end up making a recursive call of some kind to keep progressing through - /// the BDD. You should make this call from inside of your callback, so that as you get further - /// down into the BDD structure, we remember all of the information that we have learned from - /// the path we're on. - fn walk_edge<'db, R>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - assignment: ConstraintAssignment, - f: impl FnOnce(&mut ConstraintSetStorage<'db>, &mut Self, Range, bool) -> R, - ) -> R { - // Record a snapshot of the assignments that we already knew held — both so that we can - // pass along the range of which assignments are new, and so that we can reset back to this - // point before returning. - let start = self.assignments.len(); - let additional_fuels_start = self.additional_fuels.len(); - let previous_remaining_overall_fuel = self.remaining_overall_fuel; - - // Add the new assignment and anything we can derive from it. - tracing::trace!( - target: "ty_python_semantic::types::constraints::PathAssignment", - before = %format_args!( - "[{}]", - self.assignments[..start].iter().map(|(assignment, _)| { - assignment.display(db, env, storage) - }).format(", "), - ), - edge = %assignment.display(db, env, storage), - "walk edge", - ); - debug_assert!(self.assignment_queue.is_empty()); - self.assignment_queue - .push_back((assignment, AssignmentFuel::origin())); - let source_constraint = assignment.constraint(); - let found_conflict = self - .drain_assignment_queue(db, env, storage, source_constraint) - .is_err(); - if !found_conflict { - tracing::trace!( - target: "ty_python_semantic::types::constraints::PathAssignment", - new = %format_args!( - "[{}]", - self.assignments[start..].iter().map(|(assignment, _)| { - assignment.display(db, env, storage) - }).format(", "), - ), - "new assignments", - ); - } - // Otherwise invoke the callback to keep traversing the BDD. The callback will likely - // traverse additional edges, which might add more to our `assignments` set. But even - // if that happens, `start..end` will mark the assignments that were added by the - // `add_assignment` call above — that is, the new assignment for this edge along with - // the derived information we inferred from it. - let end = self.assignments.len(); - let result = f(storage, self, start..end, found_conflict); - - // Reset back to where we were before following this edge, so that the caller can reuse a - // single instance for the entire BDD traversal. - self.assignment_queue.clear(); - self.assignments.truncate(start); - self.additional_fuels.truncate(additional_fuels_start); - self.remaining_overall_fuel = previous_remaining_overall_fuel; - result - } - - fn positive_constraints(&self) -> impl Iterator + '_ { - self.assignments.iter().filter_map( - |(assignment, (source_constraint, _))| match assignment { - ConstraintAssignment::Positive(constraint) => { - Some((*constraint, *source_constraint)) - } - ConstraintAssignment::Negative(_) | ConstraintAssignment::Unconstrained(_) => None, - }, - ) - } - - fn assignment_holds(&self, assignment: ConstraintAssignment) -> bool { - self.assignments.contains_key(&assignment) - } - - fn contains_constraint(&self, constraint: ConstraintId) -> bool { - self.assignment_holds(constraint.when_true()) - || self.assignment_holds(constraint.when_false()) - || self.assignment_holds(constraint.when_unconstrained()) - } - - /// Returns the greatest remaining fuel for any derivation of `assignment` on this path. - fn max_remaining_fuel_for(&self, assignment: ConstraintAssignment) -> Option { - let (index, _, (_, first_fuel)) = self.assignments.get_full(&assignment)?; - let max_fuel = self - .additional_fuels - .iter() - .filter(|(fuel_index, _)| *fuel_index == index) - .map(|(_, fuel)| *fuel) - .fold(*first_fuel, u16::max); - Some(max_fuel) - } - - /// Update our sequent map to ensure that it holds all of the sequents that involve the given - /// constraint. We do not calculate the new sequents directly. Instead, we call - /// [`SequentMap::for_constraint`] and [`for_constraint_pair`][SequentMap::for_constraint_pair] - /// to calculate _and cache_ the constraints, so that if we walk another constraint set - /// containing this constraint, we reuse the work to calculate its sequents. - fn discover_constraint<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - constraint: ConstraintId, - ) { - // If we've already processed this constraint, we can skip it. - let (constraint_index, existing) = self.discovered.insert_full(constraint, true); - let already_processed = existing.is_some_and(|existing| existing); - if already_processed { - return; - } - - let single_map = SequentMap::for_constraint(db, env, storage, constraint); - self.sequents.extend_from_slice(&single_map.sequents); - - for (existing_index, (existing, _)) in self.discovered.iter().enumerate() { - if *existing == constraint { - continue; - } - - if SequentMap::pair_cannot_produce_sequents(db, env, storage, *existing, constraint) { - continue; - } - - let (a, b) = if existing_index < constraint_index { - (*existing, constraint) - } else { - (constraint, *existing) - }; - if !self.elaborated_pairs.insert((a, b)) { - // We've already elaborated this pair of constraints. - continue; - } - - let pair_map = SequentMap::for_constraint_pair(db, env, storage, a, b); - self.sequents.extend_from_slice(&pair_map.sequents); - } - } - - fn drain_assignment_queue<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - source_constraint: ConstraintId, - ) -> Result<(), PathAssignmentConflict> { - while let Some((assignment, fuel)) = self.assignment_queue.pop_front() { - self.add_assignment(db, env, storage, assignment, source_constraint, fuel)?; - } - Ok(()) - } - - /// Adds a new assignment, along with any derived information that we can infer from the new - /// assignment combined with the assignments we've already seen. If any of this causes the path - /// to become invalid, due to a contradiction, returns a [`PathAssignmentConflict`] error. - fn add_assignment<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - assignment: ConstraintAssignment, - source_constraint: ConstraintId, - fuel: AssignmentFuel, - ) -> Result<(), PathAssignmentConflict> { - if matches!(assignment, ConstraintAssignment::Unconstrained(_)) { - // An `Unconstrained` assignment means "this constraint can go either way". If there is - // already any assignment for this constraint (positive, negative, or unconstrained), - // the existing assignment is at least as informative, and we skip. - if self.contains_constraint(assignment.constraint()) { - return Ok(()); - } - - // Since we don't know whether the assignment's constraint holds or not, we cannot - // derive any additional information from the sequent map. We still want to record the - // assignment, but as an optimization we can return early without actually querying the - // sequent map. - self.assignments - .insert(assignment, (source_constraint, fuel.remaining)); - return Ok(()); - } - - // First add this assignment. If it causes a conflict, return that as an error. - if self.assignments.contains_key(&assignment.negated()) { - tracing::trace!( - target: "ty_python_semantic::types::constraints::PathAssignment", - assignment = %assignment.display(db, env, storage), - facts = %format_args!( - "[{}]", - self.assignments.iter().map(|(assignment, _)| { - assignment.display(db, env, storage) - }).format(", "), - ), - "found contradiction", - ); - return Err(PathAssignmentConflict); - } - - match self.assignments.entry(assignment) { - Entry::Vacant(entry) => { - if let Some(fuel_cost) = fuel.consumed { - self.remaining_overall_fuel = - match self.remaining_overall_fuel.checked_sub(fuel_cost) { - Some(updated_fuel) => updated_fuel, - None => return Ok(()), - }; - } - entry.insert((source_constraint, fuel.remaining)); - } - - Entry::Occupied(mut entry) => { - let index = entry.index(); - let (existing_source_constraint, existing_fuel) = entry.get_mut(); - - // If a constraint appears both as an "origin" constraint (it actually appears in - // the BDD structure) and as a "derived" constraint (we infer it from other - // constraints), we should prefer the origin source constraint, regardless of which - // order we encounter the various constraints in the BDD. - if !fuel.is_derived() { - *existing_source_constraint = source_constraint; - } - - // We've already seen this assignment, and in theory have already queried the - // sequent map for its consequents, which should let us return early. - // - // However, a new derivation chain can replenish the fuel for this assignment, - // giving it more chances to participate in multi-step sequent chains. That means - // there might be some consequents that were skipped previously due to a lack of - // fuel, that can be added now because of the replinished fuel budget. - - // There is another derivation of this assignment that already provides at least as - // much fuel as this constraint. That means replenishing the fuel won't have any - // effect. - if *existing_fuel >= fuel.remaining - || self - .additional_fuels - .iter() - .any(|(fuel_index, existing_fuel)| { - *fuel_index == index && *existing_fuel >= fuel.remaining - }) - { - return Ok(()); - } - - // Record the replenished fuel separately so that `walk_edge` can restore the - // parent branch by truncating `additional_fuels`. - self.additional_fuels.push((index, fuel.remaining)); - } - } - - // Then use our sequents to add additional facts that we know to be true. - // - // TODO: This is very naive at the moment, partly for expediency, and partly because we - // don't anticipate the sequent maps to be very large. We might consider avoiding the - // brute-force search. - - self.new_assignments.clear(); - self.discover_constraint(db, env, storage, assignment.constraint()); - - for i in 0..self.sequents.len() { - let sequent = self.sequents[i]; - self.check_sequent(db, env, storage, sequent)?; - } - - // If we were able to derive any new assignments from this one, add them to the processing - // queue. - self.assignment_queue.extend(self.new_assignments.drain(..)); - - Ok(()) - } - - fn enqueue_assignment(&mut self, assignment: ConstraintAssignment, new_fuel: AssignmentFuel) { - self.new_assignments - .entry(assignment) - .and_modify(|existing_fuel| { - *existing_fuel = std::cmp::max(*existing_fuel, new_fuel); - }) - .or_insert(new_fuel); - } - - fn check_sequent<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - sequent: Sequent, - ) -> Result<(), PathAssignmentConflict> { - match sequent { - Sequent::SingleTautology { ante } => { - self.check_single_tautology(db, env, storage, ante) - } - Sequent::PairImpossibility { ante1, ante2 } => { - self.check_pair_impossibility(db, env, storage, ante1, ante2) - } - Sequent::PairImplication { ante1, ante2, post } => { - self.check_pair_implication(db, env, storage, ante1, ante2, post); - Ok(()) - } - Sequent::SingleImplication { ante, post } => { - self.check_single_implication(db, env, storage, ante, post); - Ok(()) - } - } - } - - fn check_single_tautology<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ante: ConstraintId, - ) -> Result<(), PathAssignmentConflict> { - if self.assignment_holds(ante.when_false()) { - // The sequent map says (ante1) is always true, and the current path asserts that - // it's false. - tracing::trace!( - target: "ty_python_semantic::types::constraints::PathAssignment", - ante = %ante.display(db, env, storage), - facts = %format_args!( - "[{}]", - self.assignments.iter().map(|(assignment, _)| { - assignment.display(db, env, storage) - }).format(", "), - ), - "found contradiction", - ); - return Err(PathAssignmentConflict); - } - - Ok(()) - } - - fn check_pair_impossibility<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ante1: ConstraintId, - ante2: ConstraintId, - ) -> Result<(), PathAssignmentConflict> { - if self.assignment_holds(ante1.when_true()) && self.assignment_holds(ante2.when_true()) { - // The sequent map says (ante1 ∧ ante2) is an impossible combination, and the - // current path asserts that both are true. - tracing::trace!( - target: "ty_python_semantic::types::constraints::PathAssignment", - ante1 = %ante1.display(db, env, storage), - ante2 = %ante2.display(db, env, storage), - facts = %format_args!( - "[{}]", - self.assignments.iter().map(|(assignment, _)| { - assignment.display(db, env, storage) - }).format(", "), - ), - "found contradiction", - ); - return Err(PathAssignmentConflict); - } - - Ok(()) - } - - fn check_pair_implication<'db>( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ante1: ConstraintId, - ante2: ConstraintId, - post: ConstraintId, - ) { - let Some(ante1_fuel) = self.max_remaining_fuel_for(ante1.when_true()) else { - return; - }; - let Some(ante2_fuel) = self.max_remaining_fuel_for(ante2.when_true()) else { - return; - }; - let available_fuel = ante1_fuel.min(ante2_fuel); - let (ante1_constructor_depth, _) = storage.cached_constraint_bound_depth(db, env, ante1); - let (ante2_constructor_depth, _) = storage.cached_constraint_bound_depth(db, env, ante2); - let antecedent_constructor_depth = ante1_constructor_depth.max(ante2_constructor_depth); - let fuel_cost = storage.sequent_fuel_cost(db, env, post, antecedent_constructor_depth); - if let Some(post_fuel) = available_fuel.checked_sub(fuel_cost) { - self.enqueue_assignment( - post.when_true(), - AssignmentFuel::derived(fuel_cost, post_fuel), - ); - } + _db: &'db dyn Db, + _storage: &mut ConstraintSetStorage<'db>, + _path: &PathAssignments, + ) -> ControlFlow { + ControlFlow::Continue(()) } - fn check_single_implication<'db>( + fn combine<'db>( &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ante: ConstraintId, - post: ConstraintId, - ) { - let Some(available_fuel) = self.max_remaining_fuel_for(ante.when_true()) else { - return; - }; - let ante_data = storage.constraint_data(ante); - let (antecedent_constructor_depth, _) = - storage.cached_constraint_bound_depth(db, env, ante); - let post_data = storage.constraint_data(post); - let fuel_cost = if post_data.is_bound_projection_of(db, ante_data) { - 1 - } else { - storage.sequent_fuel_cost(db, env, post, antecedent_constructor_depth) - }; - if let Some(post_fuel) = available_fuel.checked_sub(fuel_cost) { - self.enqueue_assignment( - post.when_true(), - AssignmentFuel::derived(fuel_cost, post_fuel), - ); - } + _db: &'db dyn Db, + _storage: &mut ConstraintSetStorage<'db>, + _if_true: Self::Result, + _if_uncertain: Self::Result, + _if_false: Self::Result, + ) -> ControlFlow { + ControlFlow::Continue(()) } } -#[derive(Debug)] -struct PathAssignmentConflict; - /// A single clause in the DNF representation of a BDD #[derive(Clone, Debug, Default, Eq, PartialEq)] struct SatisfiedClause { @@ -7173,152 +5636,26 @@ impl SatisfiedClauses { } } -impl<'db> BoundTypeVarInstance<'db> { - /// Returns the valid specializations of a typevar. This is used when checking a constraint set - /// when this typevar is in inferable position, where we only need _some_ specialization to - /// satisfy the constraint set. - fn valid_specializations( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ) -> (NodeId, Option) { - if self.paramspec_attr(db).is_some() { - // P.args and P.kwargs are variadic, and do not have an upper bound or constraints. - return (ALWAYS_TRUE, None); - } - - // For gradual upper bounds and constraints, we are free to choose any materialization that - // makes the check succeed. In inferable positions, it is most helpful to choose a - // materialization that is as permissive as possible, since that maximizes the number of - // valid specializations that might satisfy the check. We therefore take the top - // materialization of the bound or constraints. - // - // Moreover, for a gradual constraint, we don't need to worry that typevar constraints are - // _equality_ comparisons, not _subtyping_ comparisons — since we are only going to check - // that _some_ valid specialization satisfies the constraint set, it's correct for us to - // return the range of valid materializations that we can choose from. - match self.typevar(db).bound_or_constraints(db, env) { - None => (ALWAYS_TRUE, None), - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - let bound = bound.top_materialization(db, env); - // basedpython: a bound range `T: Lower..Upper` also pins the bottom of the - // interval. take its bottom materialization, which is the most permissive choice - let lower = self - .typevar(db) - .lower_bound(db) - .map(|lower| lower.bottom_materialization(db, env)); - Constraint::new_node_with_bounds(db, env, storage, self, lower, Some(bound)) - } - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - let mut specializations = ALWAYS_FALSE; - let mut source_order = None; - for constraint in constraints.elements(db) { - let constraint_lower = constraint.bottom_materialization(db, env); - let constraint_upper = constraint.top_materialization(db, env); - let (constraint, constraint_source_order) = Constraint::new_node_with_bounds( - db, - env, - storage, - self, - Some(constraint_lower), - Some(constraint_upper), - ); - specializations = specializations.or(storage, constraint); - source_order = - storage.ordered_source_order(source_order, constraint_source_order); - } - (specializations, source_order) - } - } - } - - /// Returns the required specializations of a typevar. This is used when checking a constraint - /// set when this typevar is in non-inferable position, where we need _all_ specializations to - /// satisfy the constraint set. - /// - /// That causes complications if this is a constrained typevar, where one of the constraints is - /// gradual. In that case, we need to return the range of valid materializations, but we don't - /// want to require that all of those materializations satisfy the constraint set. - /// - /// To handle this, we return a "primary" result, and an iterator of any gradual constraints. - /// For an unbounded/unconstrained typevar or a bounded typevar, the primary result fully - /// specifies the required specializations, and the iterator will be empty. For a constrained - /// typevar, the primary result will include the fully static constraints, and the iterator - /// will include an entry for each non-fully-static constraint. - #[expect(clippy::type_complexity)] - fn required_specializations( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - storage: &mut ConstraintSetStorage<'db>, - ) -> ( - (NodeId, Option), - Vec<(NodeId, Option)>, - ) { - // For upper bounds and constraints, we are free to choose any materialization that makes - // the check succeed. In non-inferable positions, it is most helpful to choose a - // materialization that is as restrictive as possible, since that minimizes the number of - // valid specializations that must satisfy the check. We therefore take the bottom - // materialization of the bound or constraints. - match self.typevar(db).bound_or_constraints(db, env) { - None => ((ALWAYS_TRUE, None), Vec::new()), - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - let bound = bound.bottom_materialization(db, env); - // basedpython: mirror `possible_specializations`, but take the most restrictive - // choice for the bottom of the interval - let lower = self - .typevar(db) - .lower_bound(db) - .map(|lower| lower.top_materialization(db, env)); - ( - Constraint::new_node_with_bounds(db, env, storage, self, lower, Some(bound)), - Vec::new(), - ) - } - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - let mut non_gradual_constraints = ALWAYS_FALSE; - let mut non_gradual_source_order = None; - let mut gradual_constraints = Vec::new(); - for constraint in constraints.elements(db) { - let constraint_lower = constraint.bottom_materialization(db, env); - let constraint_upper = constraint.top_materialization(db, env); - let constraint = Constraint::new_node_with_bounds( - db, - env, - storage, - self, - Some(constraint_lower), - Some(constraint_upper), - ); - if constraint_lower == constraint_upper { - non_gradual_constraints = non_gradual_constraints.or(storage, constraint.0); - non_gradual_source_order = - storage.ordered_source_order(non_gradual_source_order, constraint.1); - } else { - gradual_constraints.push(constraint); - } - } - ( - (non_gradual_constraints, non_gradual_source_order), - gradual_constraints, - ) - } - } - } -} - #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; use indoc::indoc; use pretty_assertions::assert_eq; use crate::db::tests::{TestDb, setup_db}; + use crate::place::global_symbol; use crate::types::generics::ApplySpecialization; + use crate::types::typevar::{ + TypeVarBoundOrConstraintsEvaluation, TypeVarConstraints, TypeVarDefaultEvaluation, + }; use crate::types::{BoundTypeVarInstance, KnownClass, SubclassOfType, TypeVarVariance}; + use ruff_db::files::system_path_to_file; + use ruff_db::system::DbWithWritableSystem; use ruff_python_ast::name::Name; + use ty_python_core::ProgramFile; fn create_typevar<'db>(db: &'db TestDb, name: &'static str) -> BoundTypeVarInstance<'db> { BoundTypeVarInstance::synthetic( @@ -7344,6 +5681,48 @@ mod tests { class.to_instance(db, &db.program_environment()) } + fn bounded_path_bounds<'db>( + db: &'db TestDb, + set: ConstraintSet<'db, '_>, + inferable: TypeVarSet<'db>, + max_paths: usize, + max_visits: usize, + ) -> Result, ProjectionError> { + PathBounds::compute_bounded( + db, + &db.program_environment(), + &mut set.builder.storage.borrow_mut(), + set.node, + inferable, + set.source_order, + SolutionBudget { + paths: max_paths, + visits: max_visits, + ..SolutionBudget::default() + }, + ) + } + + #[derive(Default)] + struct CountSolutionLimits { + visits: usize, + paths: usize, + } + + impl SolutionLimits for CountSolutionLimits { + type Break = Infallible; + + fn visit_node(&mut self) -> ControlFlow { + self.visits += 1; + ControlFlow::Continue(()) + } + + fn satisfied_path(&mut self) -> ControlFlow { + self.paths += 1; + ControlFlow::Continue(()) + } + } + #[test] fn type_mapping_updates_constraint_bounds() { // (list[U] ≤ T ≤ list[U])[U ↦ int] = (list[int] ≤ T ≤ list[int]) @@ -7374,6 +5753,101 @@ mod tests { ); } + #[test] + fn constraint_support_ignores_typevar_declaration_defaults() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let metadata = create_typevar(db, "Metadata"); + let u = create_typevar(db, "U"); + let declaration = TypeVarInstance::new( + db, + u.typevar(db).identity(db), + None, + None, + Some(TypeVarVariance::Invariant), + Some(TypeVarDefaultEvaluation::Eager(Type::TypeVar(metadata))), + ); + let u = BoundTypeVarInstance::new( + db, + declaration, + u.binding_context(db), + u.paramspec_attr(db), + u.freshness(db), + ); + let actual_bound = KnownClass::List.to_specialized_instance(db, &env, &[Type::TypeVar(u)]); + let mut storage = ConstraintSetStorage::default(); + let support = storage.intern_constraint_typevars( + db, + &env, + Constraint::from_evidence(t, None, Some(actual_bound)), + ); + let mentioned = support + .iter() + .map(|typevar| storage.typevar_data(typevar)) + .collect::>(); + + assert_eq!(mentioned, vec![t, u]); + assert!(support.is_complete()); + + let builder = ConstraintSetBuilder::new(); + let constraint = + ConstraintSet::constrain_typevar_upper_bound(db, &env, &builder, t, actual_bound); + assert!(constraint.mentions_typevar(t)); + assert!(constraint.mentions_typevar(u)); + assert!(!constraint.mentions_typevar(metadata)); + } + + #[test] + fn constraint_support_is_complete_for_lazy_typevar_declaration_metadata() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + for (bound_or_constraints, default) in [ + ( + Some(TypeVarBoundOrConstraintsEvaluation::LazyUpperBound), + None, + ), + ( + Some(TypeVarBoundOrConstraintsEvaluation::LazyConstraints), + None, + ), + (None, Some(TypeVarDefaultEvaluation::Lazy)), + ] { + let declaration = TypeVarInstance::new( + db, + u.typevar(db).identity(db), + bound_or_constraints, + None, + Some(TypeVarVariance::Invariant), + default, + ); + let u = BoundTypeVarInstance::new( + db, + declaration, + u.binding_context(db), + u.paramspec_attr(db), + u.freshness(db), + ); + let mut storage = ConstraintSetStorage::default(); + let support = storage.intern_constraint_typevars( + db, + &env, + Constraint::from_evidence(t, None, Some(Type::TypeVar(u))), + ); + let mentioned = support + .iter() + .map(|typevar| storage.typevar_data(typevar)) + .collect::>(); + + assert_eq!(mentioned, vec![t, u]); + assert!(support.is_complete()); + } + } + #[test] fn type_mapping_evaluates_mapped_subjects() { // ((T = int) ∧ ¬(T = str))[T ↦ int] = true @@ -7431,12 +5905,12 @@ mod tests { let int = known_instance(db, KnownClass::Int); let mut upper = UpperBound::from_clause(int); - upper.add_clause(Type::Never); - assert_eq!(upper.clauses, FxOrderSet::from_iter([Type::Never])); + upper.add_clause(ConstraintBound::Evidence(Type::Never)); + assert_eq!(upper.evidence, FxOrderSet::from_iter([Type::Never])); assert_eq!(upper.materialize_exact(db, &env), Type::Never); - upper.add_clause(int); - assert_eq!(upper.clauses, FxOrderSet::from_iter([Type::Never])); + upper.add_clause(ConstraintBound::Evidence(int)); + assert_eq!(upper.evidence, FxOrderSet::from_iter([Type::Never])); } #[test] @@ -7461,12 +5935,12 @@ mod tests { ([int_or_str, u], u), ([u, int_or_str], u), ] { - let mut upper = UpperBound::none(); + let mut upper = UpperBound::unconstrained(); for clause in clauses { - upper.add_clause(clause); + upper.add_clause(ConstraintBound::Evidence(clause)); } - assert_eq!(upper.clauses.len(), 2); + assert_eq!(upper.evidence.len(), 2); assert_eq!(upper.as_single_bound(db, &env), Some(expected)); } } @@ -7477,11 +5951,13 @@ mod tests { let db = &db; let env = db.program_environment(); - assert_eq!(UpperBound::none().as_single_bound(db, &env), None); - assert_eq!( - UpperBound::from_clause(Type::object()).as_single_bound(db, &env), - Some(Type::object()) - ); + let missing = UpperBound::unconstrained(); + assert!(!missing.has_evidence()); + assert_eq!(missing.as_single_bound(db, &env), Some(Type::object())); + + let explicit = UpperBound::from_clause(Type::object()); + assert!(explicit.has_evidence()); + assert_eq!(explicit.as_single_bound(db, &env), Some(Type::object())); } #[test] @@ -7496,9 +5972,9 @@ mod tests { let int_or_bytes = UnionType::from_two_elements(db, &env, int, bytes); for clauses in [[int_or_str, int_or_bytes], [int_or_bytes, int_or_str]] { - let mut upper = UpperBound::none(); + let mut upper = UpperBound::unconstrained(); for clause in clauses { - upper.add_clause(clause); + upper.add_clause(ConstraintBound::Evidence(clause)); } assert_eq!(upper.materialize_exact(db, &env), int); @@ -7514,7 +5990,7 @@ mod tests { let int = known_instance(db, KnownClass::Int); let u = Type::TypeVar(create_typevar(db, "U")); let mut upper = UpperBound::from_clause(u); - upper.add_clause(int); + upper.add_clause(ConstraintBound::Evidence(int)); assert!( upper @@ -7593,39 +6069,112 @@ mod tests { } #[test] - fn overlapping_lower_bounds_do_not_skip_nonempty_sequent_map() { + fn bounded_path_fast_paths_respect_limits() { let db = setup_db(); let db = &db; let env = db.program_environment(); let builder = ConstraintSetBuilder::new(); let t = create_typevar(db, "T"); - let bool = known_instance(db, KnownClass::Bool); - let u = create_typevar(db, "U") - .map_bound_or_constraints(db, |_| Some(TypeVarBoundOrConstraints::UpperBound(bool))); - let type_of_u = SubclassOfType::from(db, &env, u); - let bool_class = KnownClass::Bool.to_class_literal(db, &env); - let mut storage = builder.storage.borrow_mut(); - let left = ConstraintId::new_with_bounds(db, &env, &mut storage, t, Some(type_of_u), None); - let right = - ConstraintId::new_with_bounds(db, &env, &mut storage, t, Some(bool_class), None); - - for (left, right) in [(left, right), (right, left)] { - let sequents = SequentMap::for_constraint_pair(db, &env, &mut storage, left, right); + let inferable = TypeVarSet::from_typevars(db, [t]); - assert!( - sequents - .sequents - .iter() - .any(|sequent| matches!(sequent, Sequent::SingleImplication { .. })) + for (set, expected) in [ + (ConstraintSet::always(&builder), PathBounds::Unconstrained), + (ConstraintSet::never(&builder), PathBounds::Unsatisfiable), + ] { + assert_eq!( + bounded_path_bounds(db, set, inferable, 0, 0), + Err(ProjectionError::TraversalBudgetExceeded) ); - assert!(!SequentMap::pair_cannot_produce_sequents( - db, - &env, - &mut storage, - left, - right - )); + assert_eq!(bounded_path_bounds(db, set, inferable, 0, 1), Ok(expected)); } + + let set = create_constraint(db, &builder, t, KnownClass::Int); + let expected = PathBounds::compute( + db, + &env, + &mut builder.storage.borrow_mut(), + set.node, + inferable, + set.source_order, + ); + assert_eq!( + bounded_path_bounds(db, set, inferable, 0, 2), + Err(ProjectionError::PathBudgetExceeded) + ); + assert_eq!( + bounded_path_bounds(db, set, inferable, 1, 1), + Err(ProjectionError::TraversalBudgetExceeded) + ); + assert_eq!(bounded_path_bounds(db, set, inferable, 1, 2), Ok(expected)); + } + + #[test] + fn bounded_path_collection_shares_preprocessing_visits() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let builder = ConstraintSetBuilder::new(); + let t = create_typevar(db, "T"); + let hidden = create_typevar(db, "Hidden"); + let visible = create_constraint(db, &builder, t, KnownClass::Int); + let hidden_alternatives = + create_constraint(db, &builder, hidden, KnownClass::Str).or(db, &builder, || { + create_constraint(db, &builder, hidden, KnownClass::Bytes) + }); + let set = visible.and(db, &builder, || hidden_alternatives); + let inferable = TypeVarSet::from_typevars(db, [t]); + let mut storage = builder.storage.borrow_mut(); + let source_orders = storage.calculate_source_orders(set.source_order); + let mut preprocessing = CountSolutionLimits::default(); + let ControlFlow::Continue(fast_path) = PathBounds::compute_simple_bound_conjunction( + db, + &env, + &mut storage, + &source_orders, + set.node, + inferable, + &mut preprocessing, + ); + assert_eq!(fast_path, None); + let ControlFlow::Continue(_) = set.node.remove_noninferable( + db, + &env, + &mut storage, + inferable, + set.source_order, + &mut preprocessing, + ); + + let mut complete = CountSolutionLimits::default(); + let ControlFlow::Continue(expected) = PathBounds::compute_with_limits( + db, + &env, + &mut storage, + set.node, + inferable, + set.source_order, + &mut complete, + ); + assert_eq!(complete.paths, 1); + assert!(complete.visits > preprocessing.visits); + drop(storage); + + assert_eq!( + bounded_path_bounds(db, set, inferable, 1, preprocessing.visits), + Err(ProjectionError::TraversalBudgetExceeded) + ); + assert_eq!( + bounded_path_bounds(db, set, inferable, 1, complete.visits - 1), + Err(ProjectionError::TraversalBudgetExceeded) + ); + assert_eq!( + bounded_path_bounds(db, set, inferable, 1, complete.visits), + Ok(expected) + ); + assert_eq!( + bounded_path_bounds(db, set, inferable, 0, complete.visits), + Err(ProjectionError::PathBudgetExceeded) + ); } #[test] @@ -7651,13 +6200,15 @@ mod tests { ) }; - let solutions = set.solutions(db, &env, &builder, inferable); + let solutions = set.solutions(db, &env, inferable); assert_eq!( solutions, - Solutions::Constrained(vec![vec![TypeVarSolution { - bound_typevar: t, - solution: UnionType::from_elements(db, &env, [int, str]), - }]]) + Ok(Solutions::Constrained(SolutionPaths::Complete(vec![vec![ + TypeVarSolution { + bound_typevar: t, + solution: UnionType::from_elements(db, &env, [int, str]), + } + ]]))) ); let storage = builder.storage.borrow(); @@ -7688,9 +6239,10 @@ mod tests { ) }; - let Solutions::Constrained(solutions) = set.solutions(db, &env, &builder, inferable) else { + let Ok(Solutions::Constrained(solutions)) = set.solutions(db, &env, inferable) else { panic!("expected constrained solutions"); }; + let solutions = solutions.into_vec(); assert_eq!(solutions.len(), 1); assert_eq!(solutions[0].len(), 2); assert!(solutions[0].contains(&TypeVarSolution { @@ -7731,33 +6283,243 @@ mod tests { }; assert_eq!( - set.solutions(db, &env, &builder, inferable), - Solutions::Unsatisfiable + set.solutions(db, &env, inferable), + Ok(Solutions::Unsatisfiable) + ); + + let storage = builder.storage.borrow(); + assert_eq!(storage.single_sequent_cache.len(), single_sequents); + assert_eq!(storage.pair_sequent_cache.len(), pair_sequents); + } + + #[test] + fn default_solve_leaves_unbounded_typevar_unsolved_without_bounds() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let path_bound = PathBound { + bound_typevar: t, + evidence_lower: None, + validity_lower: Type::Never, + upper: UpperBound::unconstrained(), + has_only_gradual_evidence: false, + }; + + assert_eq!( + PathBounds::default_solve(db, &env, &builder, &path_bound), + PathBoundSolution::Unsolved + ); + assert_eq!(PathBoundSolution::Unsolved.as_type(), None); + assert_eq!( + PathBounds::Constrained(Box::new([Box::new([path_bound])])).solve(db, &env, &builder), + Solutions::Constrained(SolutionPaths::Complete(vec![vec![]])) + ); + } + + #[test] + fn default_solve_distinguishes_invalid_bounds_from_never() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let mut bounds = ConstraintBoundsBuilder::default(); + bounds.add_lower( + db, + &env, + ConstraintBound::Evidence(known_instance(db, KnownClass::Int)), + ); + bounds.add_upper( + db, + &env, + ConstraintBound::Evidence(known_instance(db, KnownClass::Str)), + ); + let invalid = bounds.finish(db, &env, t); + + assert_eq!( + PathBounds::preliminary_solve(db, &env, &builder, &invalid), + PathBoundSolution::Unsatisfiable + ); + assert_eq!( + PathBounds::default_solve(db, &env, &builder, &invalid), + PathBoundSolution::Unsatisfiable + ); + assert_eq!(PathBoundSolution::Unsatisfiable.as_type(), None); + assert_eq!( + PathBounds::default_solve(db, &env, &builder, &PathBound::exact(t, Type::Never)), + PathBoundSolution::Solved(Type::Never) + ); + assert_eq!( + PathBoundSolution::Solved(Type::Never).as_type(), + Some(Type::Never) ); - - let storage = builder.storage.borrow(); - assert_eq!(storage.single_sequent_cache.len(), single_sequents); - assert_eq!(storage.pair_sequent_cache.len(), pair_sequents); } #[test] - fn default_solve_leaves_unbounded_typevar_unsolved_without_bounds() { + fn promoting_solutions_preserves_completeness() { let db = setup_db(); let db = &db; let env = db.program_environment(); + let literal = Type::int_literal(1); + let int = known_instance(db, KnownClass::Int); + + for (solution, expected) in [ + ( + PathBoundSolution::Solved(literal), + PathBoundSolution::Solved(int), + ), + ( + PathBoundSolution::BudgetExceeded { + fallback: Some(literal), + }, + PathBoundSolution::BudgetExceeded { + fallback: Some(int), + }, + ), + (PathBoundSolution::Unsolved, PathBoundSolution::Unsolved), + ( + PathBoundSolution::Unsatisfiable, + PathBoundSolution::Unsatisfiable, + ), + ( + PathBoundSolution::BudgetExceeded { fallback: None }, + PathBoundSolution::BudgetExceeded { fallback: None }, + ), + ] { + assert_eq!(solution.map(|ty| ty.promote(db, &env)), expected); + } + } + + #[test] + fn solution_budget_exhaustion_preserves_available_bindings() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... +"#, + )?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let instance = |name| { + global_symbol(db, file, name) + .place + .expect_type() + .to_instance_approximation(db, &env) + .ok_or_else(|| anyhow::anyhow!("expected class {name}")) + }; + // Six non-disjoint intersections exceed the four-term DNF construction budget. + let left = UnionType::from_elements(db, &env, [instance("A")?, instance("B")?]); + let right = + UnionType::from_elements(db, &env, [instance("C")?, instance("D")?, instance("E")?]); + assert!(IntersectionType::bounded_from_elements(db, &env, [left, right]).is_none()); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); let builder = ConstraintSetBuilder::new(); - let path_bound = PathBound { - bound_typevar: t, - lower: None, - upper: UpperBound::none(), - has_only_gradual_evidence: false, + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let binding = |bound_typevar, solution| TypeVarSolution { + bound_typevar, + solution, }; + for lower in [None, Some(Type::any())] { + let mut bounds = ConstraintBoundsBuilder::default(); + bounds.add_lower( + db, + &env, + lower.map_or_else(ConstraintBound::missing_lower, ConstraintBound::Evidence), + ); + bounds.add_upper(db, &env, ConstraintBound::Evidence(left)); + bounds.add_upper(db, &env, ConstraintBound::Evidence(right)); + let exhausted = bounds.finish(db, &env, t); + let expected = PathBoundSolution::BudgetExceeded { fallback: lower }; + assert_eq!( + PathBounds::preliminary_solve(db, &env, &builder, &exhausted), + lower.map_or(expected, PathBoundSolution::Solved) + ); + assert_eq!( + PathBounds::default_solve(db, &env, &builder, &exhausted), + expected + ); + assert_eq!(expected.as_type(), lower); + + for reverse in [false, true] { + let mut paths = vec![ + vec![exhausted.clone(), PathBound::exact(u, str)].into_boxed_slice(), + vec![PathBound::exact(t, int)].into_boxed_slice(), + ]; + let mut recovered = lower + .map(|ty| binding(t, ty)) + .into_iter() + .collect::>(); + recovered.push(binding(u, str)); + let mut expected_paths = vec![recovered, vec![binding(t, int)]]; + if reverse { + paths.reverse(); + expected_paths.reverse(); + } + assert_eq!( + PathBounds::Constrained(paths.into_boxed_slice()).solve(db, &env, &builder), + Solutions::Constrained(SolutionPaths::BudgetExceeded(expected_paths)) + ); + } + + // A later contradiction rejects the entire path, including its exhausted binding. + let mut invalid = ConstraintBoundsBuilder::default(); + invalid.add_lower(db, &env, ConstraintBound::Evidence(int)); + invalid.add_upper(db, &env, ConstraintBound::Evidence(str)); + let invalid = invalid.finish(db, &env, u); + for invalid_first in [false, true] { + let mut rejected = vec![exhausted.clone(), invalid.clone()]; + if invalid_first { + rejected.reverse(); + } + let paths = PathBounds::Constrained(Box::new([ + rejected.into_boxed_slice(), + Box::new([PathBound::exact(t, int)]), + ])); + assert_eq!( + paths.solve(db, &env, &builder), + Solutions::Constrained(SolutionPaths::Complete(vec![vec![binding(t, int)]])) + ); + } + } + + // Gradual upper bounds can admit multiple declared constraints while still exceeding + // the budget needed to construct their intersection. + let constrained = create_typevar(db, "Constrained").map_bound_or_constraints(db, |_| { + Some(TypeVarBoundOrConstraints::Constraints( + TypeVarConstraints::new(db, [int, str].as_slice()), + )) + }); + let gradual_upper = + [left, right].map(|upper| UnionType::from_two_elements(db, &env, upper, Type::any())); + assert!(IntersectionType::bounded_from_elements(db, &env, gradual_upper).is_none()); + let mut bounds = ConstraintBoundsBuilder::default(); + for upper in gradual_upper { + bounds.add_upper(db, &env, ConstraintBound::Evidence(upper)); + } + let exhausted = bounds.finish(db, &env, constrained); + assert!(exhausted.has_only_gradual_evidence); assert_eq!( - PathBounds::default_solve(db, &env, &builder, &path_bound), - Ok(None) + PathBounds::preliminary_solve(db, &env, &builder, &exhausted), + PathBoundSolution::BudgetExceeded { fallback: None } + ); + assert_eq!( + PathBounds::default_solve(db, &env, &builder, &exhausted), + PathBoundSolution::BudgetExceeded { fallback: None } ); + Ok(()) } #[test] @@ -7774,75 +6536,30 @@ mod tests { let int_or_str = UnionType::from_two_elements(db, &env, int, str); let bytes_or_bytearray = UnionType::from_two_elements(db, &env, bytes, bytearray); let mut storage = builder.storage.borrow_mut(); - let left = - ConstraintId::new_with_bounds(db, &env, &mut storage, t, Some(int), Some(int_or_str)); + let left = ConstraintId::new_with_bounds( + db, + &env, + &mut storage, + t, + Some(ConstraintBound::Evidence(int)), + Some(ConstraintBound::Evidence(int_or_str)), + ); let right = ConstraintId::new_with_bounds( db, &env, &mut storage, t, None, - Some(bytes_or_bytearray), + Some(ConstraintBound::Evidence(bytes_or_bytearray)), ); // Check satisfiability against each upper clause before punting on the union-bearing // merged upper bound. The old size heuristic returned `CannotSimplify` here before // discovering that `int` cannot satisfy the second upper clause. - assert!(matches!( + assert_matches!( left.intersect(db, &env, &mut storage, right), IntersectionResult::Disjoint - )); - } - - #[test] - fn constraint_implications_are_cached() { - let db = setup_db(); - let db = &db; - let env = db.program_environment(); - let t = create_typevar(db, "T"); - let builder = ConstraintSetBuilder::new(); - let mut storage = builder.storage.borrow_mut(); - let t_int = ConstraintId::new( - db, - &env, - &mut storage, - t, - Type::Never, - KnownClass::Int.to_instance(db, &env), - ); - let t_bool = ConstraintId::new( - db, - &env, - &mut storage, - t, - Type::Never, - KnownClass::Bool.to_instance(db, &env), - ); - - assert!(storage.cached_constraint_implies(db, &env, t_bool, t_int)); - assert!(storage.cached_constraint_implies(db, &env, t_bool, t_int)); - drop(storage); - - { - let storage = builder.storage.borrow(); - assert_eq!( - storage.constraint_implication_cache.get(&(t_bool, t_int)), - Some(&true) - ); - assert_eq!(storage.constraint_implication_cache.len(), 1); - } - - let mut storage = builder.storage.borrow_mut(); - assert!(!storage.cached_constraint_implies(db, &env, t_int, t_bool)); - assert!(!storage.cached_constraint_implies(db, &env, t_int, t_bool)); - drop(storage); - - let storage = builder.storage.borrow(); - assert_eq!( - storage.constraint_implication_cache.get(&(t_int, t_bool)), - Some(&false) ); - assert_eq!(storage.constraint_implication_cache.len(), 2); } #[test] @@ -8035,8 +6752,8 @@ mod tests { #[derive(Clone, Copy)] struct PermutedConstraint<'db>( BoundTypeVarInstance<'db>, - Option>, - Option>, + ConstraintBound<'db>, + ConstraintBound<'db>, ); impl<'db> PermutedConstraint<'db> { @@ -8047,7 +6764,16 @@ mod tests { storage: &mut ConstraintSetStorage<'db>, ) -> NodeId { let PermutedConstraint(typevar, lower, upper) = self; - Constraint::new_node_with_bounds(db, env, storage, typevar, lower, upper).0 + let constraint = Constraint::new(typevar, Some(lower), Some(upper)); + Constraint::new_node_with_bounds( + db, + env, + storage, + typevar, + constraint.stored_lower_bound(), + constraint.stored_upper_bound(), + ) + .0 } } @@ -8082,10 +6808,7 @@ mod tests { storage.intern_constraint( db, &env, - Constraint { - typevar, - bounds: ConstraintBounds::new(lower, upper), - }, + Constraint::new(typevar, Some(lower), Some(upper)), ); } @@ -8095,10 +6818,7 @@ mod tests { let constraint = storage.intern_constraint( db, &env, - Constraint { - typevar, - bounds: ConstraintBounds::new(lower, upper), - }, + Constraint::new(typevar, Some(lower), Some(upper)), ); let constraint_source_order = storage.constraint_source_order(constraint); storage.ordered_source_order(source_order, Some(constraint_source_order)) @@ -8106,10 +6826,10 @@ mod tests { drop(storage); let set = ConstraintSet::from_node(&builder, node, source_order); - let solutions = set.solutions(db, &env, &builder, inferable); + let solutions = set.solutions(db, &env, inferable); let mut merged = FxHashMap::default(); - if let Solutions::Constrained(paths) = &solutions { - for path in paths { + if let Ok(Solutions::Constrained(paths)) = &solutions { + for path in paths.as_slice() { for binding in path { merged .entry(binding.bound_typevar) @@ -8138,9 +6858,10 @@ mod tests { }) .join(", "); let paths = match &solutions { - Solutions::Unsatisfiable => String::from("unsatisfiable"), - Solutions::Unconstrained => String::from("unconstrained"), - Solutions::Constrained(paths) => paths + Ok(Solutions::Unsatisfiable) => String::from("unsatisfiable"), + Ok(Solutions::Unconstrained) => String::from("unconstrained"), + Ok(Solutions::Constrained(paths)) => paths + .as_slice() .iter() .map(|path| { path.iter() @@ -8154,6 +6875,7 @@ mod tests { .join(", ") }) .join("; "), + Err(error) => format!("error: {error:?}"), }; signatures.insert(format!( "never={} always={} merged=[{merged}] paths=[{paths}]", @@ -8175,8 +6897,16 @@ mod tests { let str = KnownClass::Str.to_instance(db, &env); let int = KnownClass::Int.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, Some(str), None), - PermutedConstraint(t, Some(int), None), + PermutedConstraint( + t, + ConstraintBound::Evidence(str), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + t, + ConstraintBound::Evidence(int), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8213,9 +6943,21 @@ mod tests { let bytes = KnownClass::Bytes.to_instance(db, &env); let int = KnownClass::Int.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, Some(str), None), - PermutedConstraint(u, Some(bytes), None), - PermutedConstraint(t, Some(int), None), + PermutedConstraint( + t, + ConstraintBound::Evidence(str), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + u, + ConstraintBound::Evidence(bytes), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + t, + ConstraintBound::Evidence(int), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8243,9 +6985,21 @@ mod tests { let bytes = KnownClass::Bytes.to_instance(db, &env); let int = KnownClass::Int.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, Some(str), None), - PermutedConstraint(u, Some(bytes), None), - PermutedConstraint(x, Some(int), None), + PermutedConstraint( + t, + ConstraintBound::Evidence(str), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + u, + ConstraintBound::Evidence(bytes), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + x, + ConstraintBound::Evidence(int), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8271,8 +7025,16 @@ mod tests { let str = KnownClass::Str.to_instance(db, &env); let int = KnownClass::Int.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, Some(str), None), - PermutedConstraint(t, Some(int), None), + PermutedConstraint( + t, + ConstraintBound::Evidence(str), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + t, + ConstraintBound::Evidence(int), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8290,7 +7052,7 @@ mod tests { } #[test] - fn constraint_ordering_changes_nested_transitive_solutions() { + fn constraint_ordering_preserves_nested_transitive_solutions() { let db = setup_db(); let db = &db; let env = db.program_environment(); @@ -8302,10 +7064,26 @@ mod tests { let list_u = KnownClass::List.to_specialized_instance(db, &env, &[Type::TypeVar(u)]); let list_int = KnownClass::List.to_specialized_instance(db, &env, &[int]); let atoms = [ - PermutedConstraint(t, None, Some(list_u)), - PermutedConstraint(u, None, Some(int)), - PermutedConstraint(t, Some(list_int), None), - PermutedConstraint(v, Some(bytes), None), + PermutedConstraint( + t, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(list_u), + ), + PermutedConstraint( + u, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(int), + ), + PermutedConstraint( + t, + ConstraintBound::Evidence(list_int), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + v, + ConstraintBound::Evidence(bytes), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8320,19 +7098,15 @@ mod tests { .and(storage, list_int_t) .or(storage, bytes_v) }, - // TODO: All permutations should produce the first result. TDD traversal currently - // leaks irrelevant positive constraints onto the `V = bytes` alternative. + // The unrelated `V = bytes` alternative must not pick up bindings for `T` or `U`. [ "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; V=bytes]", - "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; T=list[int], V=bytes; V=bytes]", - "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; U=int, V=bytes; V=bytes]", - "never=false always=false merged=[T=list[int] | list[U], U=int, V=bytes] paths=[T=list[int], U=int; T=list[U], V=bytes; V=bytes]", ], ); } #[test] - fn constraint_ordering_changes_negated_alternative_solutions() { + fn constraint_ordering_preserves_negated_alternative_solutions() { let db = setup_db(); let db = &db; let env = db.program_environment(); @@ -8342,9 +7116,21 @@ mod tests { let str = KnownClass::Str.to_instance(db, &env); let bytes = KnownClass::Bytes.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, None, Some(int)), - PermutedConstraint(t, None, Some(str)), - PermutedConstraint(u, Some(bytes), None), + PermutedConstraint( + t, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(int), + ), + PermutedConstraint( + t, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(str), + ), + PermutedConstraint( + u, + ConstraintBound::Evidence(bytes), + ConstraintBound::missing_upper(), + ), ]; check_solutions_for_constraint_orderings( @@ -8358,18 +7144,14 @@ mod tests { .negate(storage) .or(storage, bytes_u) }, - // TODO: All permutations should produce the first result. A satisfied alternative - // should not infer `T` from unrelated positive decisions made earlier in a BDD path. - [ - "never=false always=false merged=[U=bytes] paths=[; U=bytes]", - "never=false always=false merged=[T=str, U=bytes] paths=[; T=str, U=bytes; U=bytes]", - "never=false always=false merged=[T=int, U=bytes] paths=[; T=int, U=bytes; U=bytes]", - ], + // A satisfied alternative must not infer `T` from unrelated positive decisions + // made earlier in a TDD path. + ["never=false always=false merged=[U=bytes] paths=[; U=bytes]"], ); } #[test] - fn constraint_ordering_changes_derived_upper_bound_display() { + fn constraint_ordering_preserves_independent_concrete_solutions() { let db = setup_db(); let db = &db; let env = db.program_environment(); @@ -8378,10 +7160,26 @@ mod tests { let int = KnownClass::Int.to_instance(db, &env); let str = KnownClass::Str.to_instance(db, &env); let atoms = [ - PermutedConstraint(t, None, Some(int)), - PermutedConstraint(t, None, Some(str)), - PermutedConstraint(t, Some(int), None), - PermutedConstraint(u, None, Some(int)), + PermutedConstraint( + t, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(int), + ), + PermutedConstraint( + t, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(str), + ), + PermutedConstraint( + t, + ConstraintBound::Evidence(int), + ConstraintBound::missing_upper(), + ), + PermutedConstraint( + u, + ConstraintBound::missing_lower(), + ConstraintBound::Evidence(int), + ), ]; check_solutions_for_constraint_orderings( @@ -8395,12 +7193,7 @@ mod tests { .and(storage, int_t) .and(storage, u_int) }, - // TODO: Constraint-ID permutations can still change which equivalent upper-bound - // intersection is constructed first. - [ - "never=false always=false merged=[T=int | U, U=T & int] paths=[T=int | U, U=T & int]", - "never=false always=false merged=[T=int | U, U=int & T] paths=[T=int | U, U=int & T]", - ], + ["never=false always=false merged=[T=int, U=int] paths=[T=int, U=int]"], ); } @@ -8547,345 +7340,130 @@ mod tests { │ └─₀ never └─₀ never "#}, - ); - } - - /// Negation always produces flat TDDs (all uncertain branches are `ALWAYS_FALSE`). - #[test] - fn tdd_negation_produces_flat_tdd() { - let db = setup_db(); - let db = &db; - let t = create_typevar(db, "T"); - let u = create_typevar(db, "U"); - let builder = ConstraintSetBuilder::new(); - let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let u_str = create_constraint(db, &builder, u, KnownClass::Str); - let union = t_int.or(db, &builder, || u_str); - let negated = union.negate(db, &builder); - check_display_graph( - db, - &builder, - negated, - indoc! {r#" - <0> (U = str) - ┡━₁ never - ├─? never - └─₀ <1> (T = int) - ┡━₁ never - ├─? never - └─₀ always - "#}, - ); - } - - #[test] - fn tdd_negation_correctness() { - let db = setup_db(); - let db = &db; - let env = db.program_environment(); - let t = create_typevar(db, "T"); - let u = create_typevar(db, "U"); - let builder = ConstraintSetBuilder::new(); - - let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let u_str = create_constraint(db, &builder, u, KnownClass::Str); - let tdd = t_int.or(db, &builder, || u_str); - let negated = tdd.negate(db, &builder); - - // T ∧ ¬T == false - assert!( - tdd.and(db, &builder, || negated) - .is_never_satisfied(db, &env) - ); - - // T ∨ ¬T == true - assert!( - tdd.or(db, &builder, || negated) - .is_always_satisfied(db, &env) - ); - } - - #[test] - fn eager_and_lazy_negation_are_equivalent() { - let db = setup_db(); - let db = &db; - let env = db.program_environment(); - let t = create_typevar(db, "T"); - let u = create_typevar(db, "U"); - let builder = ConstraintSetBuilder::new(); - - let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let t_bool = create_constraint(db, &builder, t, KnownClass::Bool); - let u_str = create_constraint(db, &builder, u, KnownClass::Str); - let u_int = create_constraint(db, &builder, u, KnownClass::Int); - - let lhs = t_int.or(db, &builder, || u_str); - let rhs = t_bool.or(db, &builder, || u_int); - let intersection = lhs.and(db, &builder, || rhs); - let tautology = lhs.or(db, &builder, || lhs.negate(db, &builder)); - - let t_bool_upper = ConstraintSet::constrain_typevar_upper_bound( - db, - &env, - &builder, - t, - KnownClass::Bool.to_instance(db, &env), - ); - let t_int_upper = ConstraintSet::constrain_typevar_upper_bound( - db, - &env, - &builder, - t, - KnownClass::Int.to_instance(db, &env), - ); - let implication = t_bool_upper - .negate(db, &builder) - .or(db, &builder, || t_int_upper); - - for set in [lhs, rhs, intersection, tautology, implication] { - assert_eq!( - set.is_always_satisfied(db, &env), - set.negate(db, &builder).is_never_satisfied(db, &env) - ); - } - } - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - enum PathFoldBreak { - Satisfied, - Unsatisfied, - Impossible, - Combine, - } - - /// A path fold that reconstructs a constraint set from its satisfied paths and can abort at - /// a specified callback. - struct ReconstructPathFold { - break_at: Option, - } - - impl ReconstructPathFold { - fn result( - &self, - at: PathFoldBreak, - result: (NodeId, Option), - ) -> ControlFlow)> { - if self.break_at == Some(at) { - ControlFlow::Break(at) - } else { - ControlFlow::Continue(result) - } - } + ); } - impl PathFold for ReconstructPathFold { - type Result = (NodeId, Option); - type Break = PathFoldBreak; - - fn satisfied<'db>( - &mut self, - _db: &'db dyn Db, - storage: &mut ConstraintSetStorage<'db>, - path: &PathAssignments, - ) -> ControlFlow { - let result = - path.assignments - .iter() - .fold((ALWAYS_TRUE, None), |result, (assignment, _)| { - let (node, source_order) = result; - let (assignment, assignment_source_order) = - Node::new_satisfied_constraint(storage, *assignment); - ( - node.and(storage, assignment), - storage.ordered_source_order(source_order, assignment_source_order), - ) - }); - self.result(PathFoldBreak::Satisfied, result) - } - - fn unsatisfied<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _path: &PathAssignments, - ) -> ControlFlow { - self.result(PathFoldBreak::Unsatisfied, (ALWAYS_FALSE, None)) - } - - fn impossible<'db>( - &mut self, - _db: &'db dyn Db, - _storage: &mut ConstraintSetStorage<'db>, - _path: &PathAssignments, - ) -> ControlFlow { - self.result(PathFoldBreak::Impossible, (ALWAYS_FALSE, None)) - } + #[test] + fn tdd_uncertain_branch_absorbs_stronger_paths() { + let db = setup_db(); + let db = &db; + let builder = ConstraintSetBuilder::new(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let v = create_typevar(db, "V"); + let last = create_constraint(db, &builder, t, KnownClass::Int); + let middle = create_constraint(db, &builder, u, KnownClass::Str); + let first = create_constraint(db, &builder, v, KnownClass::Bytes); - fn combine<'db>( - &mut self, - _db: &'db dyn Db, - storage: &mut ConstraintSetStorage<'db>, - if_true: Self::Result, - if_uncertain: Self::Result, - if_false: Self::Result, - ) -> ControlFlow { - let (if_true, if_true_source_order) = if_true; - let (if_uncertain, if_uncertain_source_order) = if_uncertain; - let (if_false, if_false_source_order) = if_false; - let node = if_true.or(storage, if_uncertain).or(storage, if_false); - let source_order = - storage.ordered_source_order(if_true_source_order, if_uncertain_source_order); - let source_order = storage.ordered_source_order(source_order, if_false_source_order); - self.result(PathFoldBreak::Combine, (node, source_order)) - } - } - - fn path_assignments_for( - builder: &ConstraintSetBuilder<'_>, - node: NodeId, - source_order: Option, - ) -> PathAssignments { - match node.node() { - Node::AlwaysTrue | Node::AlwaysFalse => PathAssignments::new([]), - Node::Interior(interior) => { - let mut storage = builder.storage.borrow_mut(); - interior.path_assignments(&mut storage, source_order) - } + // The uncertain branch already accepts every assignment of the stronger guarded path, + // whether that path requires or excludes the first constraint. + for guard in [first, first.negate(db, &builder)] { + let stronger = guard + .and(db, &builder, || middle) + .and(db, &builder, || last); + let absorbed = stronger.or(db, &builder, || middle); + assert_eq!(absorbed.node, middle.node); } } #[test] - fn path_assignments_follow_constraint_source_order() { + fn disjunction_of_independent_conjunctions_stays_compact() { let db = setup_db(); let db = &db; - let t = create_typevar(db, "T"); - let u = create_typevar(db, "U"); + let env = db.program_environment(); let builder = ConstraintSetBuilder::new(); - let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let u_str = create_constraint(db, &builder, u, KnownClass::Str); - - // Construct the set in the opposite order from constraint creation. This ensures the - // initializer follows the sidecar rather than either TDD traversal or constraint IDs. - let set = u_str.and(db, &builder, || t_int); - let path = path_assignments_for(&builder, set.node, set.source_order); - let storage = builder.storage.borrow(); - let expected = - [u_str.node, t_int.node].map(|node| storage.interior_node_data(node).constraint); - let actual: Vec<_> = path.discovered.keys().copied().collect(); - - assert_eq!(actual, expected); + let count = 12; + let atoms = |prefix| { + (0..count) + .rev() + .map(|index| { + let typevar = BoundTypeVarInstance::synthetic( + db, + &env, + Name::new(format!("{prefix}{index}")), + TypeVarVariance::Invariant, + ); + create_constraint(db, &builder, typevar, KnownClass::Int) + }) + .collect::>() + }; + // Place all X conditions before all Y conditions in the TDD ordering. The disjunction + // (X0 ∧ Y0) ∨ … ∨ (Xn ∧ Yn) has a small diagram without distributing its alternatives. + let y = atoms("Y"); + let x = atoms("X"); + let mut groups: Vec<_> = x + .into_iter() + .zip(y) + .rev() + .map(|(x, y)| x.and(db, &builder, || y)) + .collect(); + while groups.len() > 1 { + groups = groups + .chunks(2) + .map(|pair| { + let left = pair[0]; + pair.get(1) + .map_or(left, |right| left.or(db, &builder, || *right)) + }) + .collect(); + } + let nodes = builder.storage.borrow().nodes.len(); + assert!(nodes < 4 * count * count, "allocated {nodes} nodes"); } + /// Negation always produces flat TDDs (all uncertain branches are `ALWAYS_FALSE`). #[test] - fn path_fold_reconstructs_constraint_sets() { + fn tdd_negation_produces_flat_tdd() { let db = setup_db(); let db = &db; - let env = db.program_environment(); let t = create_typevar(db, "T"); let u = create_typevar(db, "U"); - let v = create_typevar(db, "V"); let builder = ConstraintSetBuilder::new(); - let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let t_str = create_constraint(db, &builder, t, KnownClass::Str); - let u_int = create_constraint(db, &builder, u, KnownClass::Int); - let v_bytes = create_constraint(db, &builder, v, KnownClass::Bytes); - let union = t_int.or(db, &builder, || u_int); - let intersection = union.and(db, &builder, || t_str.or(db, &builder, || v_bytes)); - let contradiction = t_int.and(db, &builder, || t_str); - let tautology = union.or(db, &builder, || union.negate(db, &builder)); - - let t_u = - ConstraintSet::constrain_typevar_upper_bound(db, &env, &builder, t, Type::TypeVar(u)); - let u_int_upper = ConstraintSet::constrain_typevar_upper_bound( - db, - &env, - &builder, - u, - KnownClass::Int.to_instance(db, &env), - ); - let int_t = ConstraintSet::constrain_typevar_lower_bound( + let u_str = create_constraint(db, &builder, u, KnownClass::Str); + let union = t_int.or(db, &builder, || u_str); + let negated = union.negate(db, &builder); + check_display_graph( db, - &env, &builder, - t, - KnownClass::Int.to_instance(db, &env), + negated, + indoc! {r#" + <0> (U = str) + ┡━₁ never + ├─? never + └─₀ <1> (T = int) + ┡━₁ never + ├─? never + └─₀ always + "#}, ); - let transitive = t_u - .and(db, &builder, || u_int_upper) - .and(db, &builder, || int_t) - .or(db, &builder, || v_bytes); - - for set in [ - ConstraintSet::always(&builder), - ConstraintSet::never(&builder), - union, - intersection, - contradiction, - tautology, - transitive, - ] { - let mut path = path_assignments_for(&builder, set.node, set.source_order); - let mut fold = ReconstructPathFold { break_at: None }; - let mut storage = builder.storage.borrow_mut(); - let ControlFlow::Continue((reconstructed, reconstructed_source_order)) = - path.visit(db, &env, &mut storage, set.node, &mut fold) - else { - panic!("reconstruction unexpectedly aborted"); - }; - drop(storage); - let reconstructed = - ConstraintSet::from_node(&builder, reconstructed, reconstructed_source_order); - assert!( - set.iff(db, &builder, reconstructed) - .is_always_satisfied(db, &env) - ); - } } #[test] - fn path_fold_break_restores_path_assignments() { + fn tdd_negation_correctness() { let db = setup_db(); let db = &db; let env = db.program_environment(); let t = create_typevar(db, "T"); let u = create_typevar(db, "U"); let builder = ConstraintSetBuilder::new(); + let t_int = create_constraint(db, &builder, t, KnownClass::Int); - let t_str = create_constraint(db, &builder, t, KnownClass::Str); - let u_int = create_constraint(db, &builder, u, KnownClass::Int); - let set = t_int.and(db, &builder, || t_str).or(db, &builder, || u_int); + let u_str = create_constraint(db, &builder, u, KnownClass::Str); + let tdd = t_int.or(db, &builder, || u_str); + let negated = tdd.negate(db, &builder); - for break_at in [ - PathFoldBreak::Satisfied, - PathFoldBreak::Unsatisfied, - PathFoldBreak::Impossible, - PathFoldBreak::Combine, - ] { - let mut path = path_assignments_for(&builder, set.node, set.source_order); - let mut aborting_fold = ReconstructPathFold { - break_at: Some(break_at), - }; - let mut storage = builder.storage.borrow_mut(); - assert_eq!( - path.visit(db, &env, &mut storage, set.node, &mut aborting_fold), - ControlFlow::Break(break_at) - ); + // T ∧ ¬T == false + assert!( + tdd.and(db, &builder, || negated) + .is_never_satisfied(db, &env) + ); - let mut completing_fold = ReconstructPathFold { break_at: None }; - let ControlFlow::Continue((reconstructed, reconstructed_source_order)) = - path.visit(db, &env, &mut storage, set.node, &mut completing_fold) - else { - panic!("reconstruction unexpectedly aborted after {break_at:?}"); - }; - drop(storage); - let reconstructed = - ConstraintSet::from_node(&builder, reconstructed, reconstructed_source_order); - assert!( - set.iff(db, &builder, reconstructed) - .is_always_satisfied(db, &env) - ); - } + // T ∨ ¬T == true + assert!( + tdd.or(db, &builder, || negated) + .is_always_satisfied(db, &env) + ); } /// Double negation of a TDD with uncertain branches is semantically equivalent to the @@ -8939,7 +7517,13 @@ mod tests { let u_str = create_constraint(db, &builder, u, KnownClass::Str); let combined = t_int.and(db, &builder, || u_str); - for original in [t_int, combined] { + let t_bool = create_constraint(db, &builder, t, KnownClass::Bool); + let u_int = create_constraint(db, &builder, u, KnownClass::Int); + let alternatives = t_int + .and(db, &builder, || u_int) + .or(db, &builder, || u_str.and(db, &builder, || t_bool)); + + for original in [t_int, combined, alternatives] { let storage = builder.storage.borrow(); let original_source_order_count = storage.source_orders.len(); drop(storage); @@ -8955,6 +7539,155 @@ mod tests { } } + #[test] + fn shared_source_order_subtrees_are_visited_once() { + let db = setup_db(); + let db = &db; + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let mut left = create_constraint(db, &builder, t, KnownClass::Int); + let mut right = create_constraint(db, &builder, t, KnownClass::Str); + let expected = { + let storage = builder.storage.borrow(); + [left.node, right.node].map(|node| storage.interior_node_data(node).constraint) + }; + let original = left.or(db, &builder, || right); + + // The TDD stops growing, but each sidecar shares both of its predecessors. Walking the + // sidecar as a tree would take exponentially many steps. + for _ in 0..63 { + let next = left.or(db, &builder, || right); + left = right; + right = next; + } + assert_eq!(right.node, original.node); + assert_eq!( + builder + .storage + .borrow() + .calculate_source_orders(right.source_order) + .into_iter() + .collect::>(), + expected + ); + } + + #[test] + fn deeply_nested_source_order_preserves_first_occurrences() { + let mut storage = ConstraintSetStorage::default(); + let first = ConstraintId::from_usize(0); + let second = ConstraintId::from_usize(1); + let first_order = storage.constraint_source_order(first); + let second_order = storage.constraint_source_order(second); + let mut source_order = storage.ordered_source_order(Some(second_order), Some(first_order)); + + // Appending a repeated leaf creates a deep left spine without changing the order. The + // first occurrence of `second` is in the left subtree, not the right leaf at the root. + for _ in 0..32_768 { + source_order = storage.ordered_source_order(source_order, Some(second_order)); + } + assert_eq!( + storage + .calculate_source_orders(source_order) + .into_iter() + .collect::>(), + [second, first] + ); + } + + #[test] + fn owned_constraint_set_typevar_order_survives_round_trip() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = Type::TypeVar(create_typevar(db, "U")); + + for (lower, upper) in [ + (Some(ConstraintBound::Evidence(u)), None), + (None, Some(ConstraintBound::Evidence(u))), + ( + Some(ConstraintBound::Evidence(u)), + Some(ConstraintBound::Evidence(u)), + ), + ] { + let original = ConstraintSetBuilder::new().into_owned(|builder| { + ConstraintSet::constrain_typevar_with_bounds(db, &env, builder, t, lower, upper) + }); + let mut reloaded = original.clone(); + + for _ in 0..3 { + reloaded = ConstraintSetBuilder::new() + .into_owned(|builder| builder.load(db, &env, &reloaded)); + assert_eq!(original, reloaded); + } + } + } + + #[test] + fn owned_constraint_set_load_discards_unreferenced_typevars() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let unused = create_typevar(db, "Unused"); + let u = create_typevar(db, "U"); + + let original = ConstraintSetBuilder::new().into_owned(|builder| { + let _unused_t_int = create_constraint(db, builder, t, KnownClass::Int); + let _unused_str = create_constraint(db, builder, unused, KnownClass::Str); + ConstraintSet::constrain_typevar_upper_bound(db, &env, builder, t, Type::TypeVar(u)) + }); + let reloaded = + ConstraintSetBuilder::new().into_owned(|builder| builder.load(db, &env, &original)); + + assert_eq!( + reloaded + .inner + .as_ref() + .map(|inner| inner.typevars.iter().copied().collect::>()), + Some(vec![t, u]), + ); + let reloaded_again = + ConstraintSetBuilder::new().into_owned(|builder| builder.load(db, &env, &reloaded)); + assert_eq!(reloaded, reloaded_again); + } + + #[test] + fn owned_constraint_set_load_preserves_overlay_typevar_ids() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let source = ConstraintSetBuilder::new().into_owned(|builder| { + ConstraintSet::constrain_typevar_upper_bound(db, &env, builder, t, Type::TypeVar(u)) + }); + let destination = ConstraintSetBuilder::new() + .into_owned(|builder| create_constraint(db, builder, u, KnownClass::Int)); + + destination.query(|builder, _| { + let original_u_id = builder.storage.borrow_mut().typevar_id(db, u); + let loaded = builder.load(db, &env, &source); + let direct = ConstraintSet::constrain_typevar_upper_bound( + db, + &env, + builder, + t, + Type::TypeVar(u), + ); + assert!( + loaded + .iff(db, builder, direct) + .is_always_satisfied(db, &env) + ); + + let mut storage = builder.storage.borrow_mut(); + assert_eq!(storage.typevar_id(db, u), original_u_id); + assert_eq!(storage.typevar_id(db, t).index(), 1); + }); + } + fn create_compacted_owned_set(db: &TestDb) -> OwnedConstraintSet<'_> { let t = create_typevar(db, "T"); let u = create_typevar(db, "U"); @@ -8993,7 +7726,7 @@ mod tests { } #[test] - fn owned_constraint_set_type_walk_excludes_quantified_constraints() { + fn owned_constraint_set_discards_unrelated_quantified_constraints() { let db = setup_db(); let db = &db; let env = db.program_environment(); @@ -9020,10 +7753,136 @@ mod tests { ); assert_eq!( owned.inner.as_ref().map(|inner| inner.source_orders.len()), - Some(3), + Some(1), ); } + #[test] + fn owned_constraint_set_preserves_projected_solution_order() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let inferable = TypeVarSet::from_typevars(db, [u]); + let expected = Ok(Solutions::Constrained(SolutionPaths::Complete(vec![ + vec![TypeVarSolution { + bound_typevar: u, + solution: known_instance(db, KnownClass::Int), + }], + vec![TypeVarSolution { + bound_typevar: u, + solution: known_instance(db, KnownClass::Str), + }], + ]))); + + let owned = ConstraintSetBuilder::new().into_owned(|builder| { + let u_t = ConstraintSet::constrain_typevar( + db, + &env, + builder, + u, + Type::TypeVar(t), + Type::TypeVar(t), + ); + let t_str = create_constraint(db, builder, t, KnownClass::Str); + let u_int = create_constraint(db, builder, u, KnownClass::Int); + + // Eliminating T leaves a derived U = str alternative alongside the direct U = int. + let projected = u_t + .and(db, builder, || t_str) + .or(db, builder, || u_int) + .reduce_inferable(db, &env, builder, TypeVarSet::from_typevars(db, [t])); + assert_eq!(projected.solutions(db, &env, inferable), expected); + projected + }); + + let reloaded = + ConstraintSetBuilder::new().into_owned(|builder| builder.load(db, &env, &owned)); + for constraints in [&owned, &reloaded] { + constraints.query(|_builder, constraints| { + assert_eq!(constraints.solutions(db, &env, inferable), expected); + }); + } + + let reloaded_again = + ConstraintSetBuilder::new().into_owned(|builder| builder.load(db, &env, &reloaded)); + assert_eq!(reloaded, reloaded_again); + } + + #[test] + fn projected_constraint_source_order_is_independent_of_allocation_order() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let a = create_typevar(db, "A"); + let r = create_typevar(db, "R"); + let fresh_a = create_typevar(db, "FreshA"); + let fresh_r = create_typevar(db, "FreshR"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let atoms = [ + (fresh_r, Some(int), None), + (fresh_a, None, Some(int)), + (fresh_r, Some(str), None), + (fresh_a, None, Some(str)), + (r, Some(Type::TypeVar(fresh_r)), None), + (a, None, Some(Type::TypeVar(fresh_a))), + ]; + + let project = |allocation_order: &[usize]| { + let builder = ConstraintSetBuilder::new(); + // Keep typevar orientation fixed while changing only the TDD variable order. + for typevar in [fresh_r, fresh_a, a, r] { + builder.storage.borrow_mut().intern_typevar(db, typevar); + } + let atom = |index: usize| { + let (typevar, lower, upper) = atoms[index]; + ConstraintSet::constrain_typevar_with_bounds( + db, + &env, + &builder, + typevar, + lower.map(ConstraintBound::Evidence), + upper.map(ConstraintBound::Evidence), + ) + }; + for &index in allocation_order { + let _ = atom(index); + } + let [int_r, a_int, str_r, a_str, r_bound, a_bound] = [0, 1, 2, 3, 4, 5].map(atom); + + // Eliminating FreshA and FreshR leaves both bounds of the int and str alternatives + // on A and R. All original constraints disappear, but their source order survives. + let projected = int_r + .and(db, &builder, || a_int) + .or(db, &builder, || str_r.and(db, &builder, || a_str)) + .and(db, &builder, || r_bound) + .and(db, &builder, || a_bound) + .reduce_inferable( + db, + &env, + &builder, + TypeVarSet::from_typevars(db, [fresh_a, fresh_r]), + ); + let storage = builder.storage.borrow(); + storage + .calculate_source_orders(projected.source_order) + .into_iter() + .map(|constraint| storage.constraint_data(constraint)) + .collect::>() + }; + + let expected = project(&[0, 1, 2, 3, 4, 5]); + for allocation_order in (0..6).permutations(6) { + assert_eq!( + project(&allocation_order), + expected, + "allocation order {allocation_order:?}" + ); + } + } + #[test] fn owned_constraint_set_source_order_ignores_construction_history() { let db = setup_db(); @@ -9054,6 +7913,55 @@ mod tests { assert_eq!(build(false), build(true)); } + #[test] + fn owned_constraint_set_preserves_order_when_reintroducing_constraints() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let inferable = TypeVarSet::from_typevars(db, [t]); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + + // Absorption can remove a constraint without eliminating its typevar. Its source order + // still matters if it is reintroduced, including when gradual bounds affect the solutions. + for (lower, upper) in [(Some(str), Some(str)), (None, Some(Type::any()))] { + let mut expected = None; + let owned = ConstraintSetBuilder::new().into_owned(|builder| { + let earlier = + ConstraintSet::constrain_typevar_lower_bound(db, &env, builder, t, int); + let later = ConstraintSet::constrain_typevar_with_bounds( + db, + &env, + builder, + t, + lower.map(ConstraintBound::Evidence), + upper.map(ConstraintBound::Evidence), + ); + let absorbed = earlier.or(db, builder, || later).and(db, builder, || later); + expected = Some( + absorbed + .or(db, builder, || earlier) + .solutions(db, &env, inferable), + ); + absorbed + }); + assert_matches!(&expected, Some(Ok(Solutions::Constrained(_)))); + + let builder = ConstraintSetBuilder::new(); + let reloaded = builder.load(db, &env, &owned); + let earlier = ConstraintSet::constrain_typevar_lower_bound(db, &env, &builder, t, int); + assert_eq!( + Some( + reloaded + .or(db, &builder, || earlier) + .solutions(db, &env, inferable), + ), + expected, + ); + } + } + #[test] fn owned_constraint_set_query_reads_compacted_overlay() { let db = setup_db(); diff --git a/crates/ty_python_semantic/src/types/constraints/paths.rs b/crates/ty_python_semantic/src/types/constraints/paths.rs new file mode 100644 index 0000000000..220391cda7 --- /dev/null +++ b/crates/ty_python_semantic/src/types/constraints/paths.rs @@ -0,0 +1,1202 @@ +//! [`PathAssignments`] and friends + +use std::cmp::Ordering; +use std::collections::VecDeque; +use std::fmt::Debug; +use std::ops::{ControlFlow, Range}; + +use indexmap::map::Entry; +use itertools::Itertools; +use rustc_hash::FxHashSet; + +use crate::types::constraints::sequents::{Sequent, SequentMap}; +use crate::types::constraints::{ + ConstraintAssignment, ConstraintId, ConstraintSetStorage, Node, NodeId, PathVisitor, + SourceOrderId, TypeVarId, +}; +use crate::{Db, FxIndexMap, ProgramEnvironment}; + +/// The collection of constraints that we know to be true or false at a certain point when +/// traversing a BDD. +/// +/// An important part of this traversal is that not all of those constraints come directly from the +/// BDD, since constraints are not independent. In particular, there can be "implications", which +/// record e.g. when two constraints both being true imply another: +/// `A ≤ list[B] ∧ B ≤ int → A ≤ list[int]`. If we see `A ≤ list[B]` and `B ≤ int` in a BDD path, +/// we can _assume_ that `A ≤ list[int]` also holds, even if it doesn't actually appear in the BDD. +/// +/// Unfortunately, there are certain implications that are technically true, but not helpful; +/// for instance, because they cause us to endlessly expand a constraint by substituting a bound +/// into itself. +/// +/// We use a "fuel" mechanism to prevent these kinds of situations, without having to play +/// whack-a-mole to implement detection patterns for all of the pathological patterns. Each +/// derived constraint costs at least one unit of fuel. Nested typevars increase that cost according +/// to their depth, as does any constructor depth introduced relative to the antecedents. Measuring +/// structural growth instead of absolute depth ensures that propagating an existing complex +/// concrete bound remains cheap, while repeatedly wrapping that bound continues to consume path +/// fuel after no nested typevars remain. +/// +/// We track this fuel in two ways: First, there is a global limit on the total amount of work we +/// are willing to do for a particular BDD path traversal. Second, there is a more focused +/// "per-path" limit, which records how far removed a derived constraint is from a constraint that +/// actually appears in the BDD. If either of those limits are exceeded, we ignore the derived +/// constraint that we are currently considering. +#[derive(Debug)] +pub(crate) struct PathAssignments { + /// All of the rules that we know for inferring derived constraints on the current path. + sequents: Vec, + /// Each assignment's source constraint and the first per-path fuel value with which it was + /// derived. + pub(super) assignments: FxIndexMap, + /// Additional per-path fuel values that can derive an assignment, keyed by its index in + /// `assignments`. These are stored separately so that branch-local additions can be rolled + /// back by truncating the set. Only the greatest fuel value participates in further + /// derivation. + additional_fuels: Vec<(usize, u16)>, + /// The amount of global fuel that remains across all assignments and paths. + remaining_overall_fuel: u16, + /// Constraints that we have discovered, mapped to whether we have processed them yet. (This + /// ensures a stable order for all of the derived constraints that we create, while still + /// letting us create them lazily.) + discovered: FxIndexMap, + /// Constraint pairs that we have already checked and added to `sequents`. + elaborated_pairs: FxHashSet<(ConstraintId, ConstraintId)>, + + /// Type variables that only involve concrete constraints and so do not participate in sequent + /// discovery. + independent_typevars: FxHashSet, + + /// Derived assignments that have been queued up to be added to the current path. + assignment_queue: VecDeque<(ConstraintAssignment, AssignmentFuel)>, + + /// The next chunk of derived assignments that have been queued up to add to the current path. + /// If we derive the same assignment multiple times, we keep the derivation that lets us make + /// the most additional progress (more remaining fuel for this derivation chain, less overall + /// fuel consumed). + new_assignments: FxIndexMap, +} + +/// The total amount of fuel that we are willing to spend for this path traversal. This was +/// chosen empirically, to balance performance with accurate ecosystem diagnostics. +const OVERALL_FUEL_BUDGET: u16 = 256; + +/// The maximum number of "trips through the sequent map" that we are willing to take for a +/// derived constraint. This records how far removed we are from a constraint that comes +/// directly from the BDD. +const PATH_FUEL_BUDGET: u16 = 8; + +/// The fuel cost of deriving a particular assignment during BDD path walking. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AssignmentFuel { + /// The amount of fuel consumed when deriving the assignment, or None if this assignment came + /// directly from the BDD + consumed: Option, + /// The amount of fuel remaining on the derivation path after deriving this assignment + remaining: u16, +} + +impl AssignmentFuel { + fn origin() -> AssignmentFuel { + AssignmentFuel { + consumed: None, + remaining: PATH_FUEL_BUDGET, + } + } + + fn derived(consumed: u16, remaining: u16) -> AssignmentFuel { + AssignmentFuel { + consumed: Some(consumed), + remaining, + } + } + + fn is_derived(self) -> bool { + self.consumed.is_some() + } +} + +impl PartialOrd for AssignmentFuel { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for AssignmentFuel { + fn cmp(&self, other: &Self) -> Ordering { + let self_key = (self.remaining, std::cmp::Reverse(self.consumed)); + let other_key = (other.remaining, std::cmp::Reverse(other.consumed)); + self_key.cmp(&other_key) + } +} + +impl PathAssignments { + /// Orders projected facts by replaying the rules already discovered during this walk. + /// + /// Projection emits derived facts in TDD branch order. Retaining that order can prevent + /// recursive relations from converging when an equivalent diagram is rebuilt in a different + /// arena. Start with the original source order and visit each rule's consequences in order, + /// including intermediate facts that are themselves projected away. This only replays cached + /// rules; it does not derive more facts or change the walk's assignments and fuel. + pub(super) fn projection_source_order( + &self, + storage: &mut ConstraintSetStorage<'_>, + original_source_order: Option, + derived_source_order: Option, + ) -> Option { + let emitted = storage.calculate_source_orders(derived_source_order); + if emitted.is_empty() { + return None; + } + let mut ordered = storage.calculate_source_orders(original_source_order); + ordered.retain(|constraint| self.discovered.contains_key(constraint)); + let mut index = 0; + // Once all emitted facts have positions, later appends cannot change their relative order. + while !emitted.is_subset(&ordered) + && let Some(constraint) = ordered.get_index(index).copied() + { + if self.discovered.get(&constraint) == Some(&true) + && let Some(map) = storage.single_sequent_cache.get(&constraint) + { + ordered.extend( + map.consequents() + .filter(|constraint| self.discovered.contains_key(constraint)), + ); + } + for earlier_index in 0..index { + let earlier = ordered[earlier_index]; + // Pair rules are not commutative. Replay the orientation used by this walk, + // which can differ from the order in which the replay reaches its inputs. + let pair = [(earlier, constraint), (constraint, earlier)] + .into_iter() + .find(|pair| self.elaborated_pairs.contains(pair)); + if let Some(map) = pair.and_then(|pair| storage.pair_sequent_cache.get(&pair)) { + ordered.extend( + map.consequents() + .filter(|constraint| self.discovered.contains_key(constraint)), + ); + } + } + index += 1; + } + debug_assert!(emitted.is_subset(&ordered)); + ordered + .into_iter() + .filter(|constraint| emitted.contains(constraint)) + .fold(None, |source_order, constraint| { + let next = storage.constraint_source_order(constraint); + storage.ordered_source_order(source_order, Some(next)) + }) + } + + pub(super) fn new( + constraints: impl IntoIterator, + independent_typevars: FxHashSet, + ) -> Self { + let discovered = constraints + .into_iter() + .map(|constraint| (constraint, false)) + .collect(); + Self { + sequents: Vec::default(), + assignments: FxIndexMap::default(), + additional_fuels: Vec::default(), + discovered, + elaborated_pairs: FxHashSet::default(), + independent_typevars, + remaining_overall_fuel: OVERALL_FUEL_BUDGET, + assignment_queue: VecDeque::default(), + new_assignments: FxIndexMap::default(), + } + } + + pub(super) fn visit<'db, V>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + node: NodeId, + visitor: &mut V, + ) -> ControlFlow + where + V: PathVisitor, + { + self.visit_inner(db, env, storage, node, visitor, false) + } + + /// Visits the paths of the negation of `node`, without constructing that negation eagerly. + pub(super) fn visit_negated<'db, V>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + node: NodeId, + visitor: &mut V, + ) -> ControlFlow + where + V: PathVisitor, + { + self.visit_inner(db, env, storage, node, visitor, true) + } + + fn visit_inner<'db, V>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + node: NodeId, + visitor: &mut V, + negated: bool, + ) -> ControlFlow + where + V: PathVisitor, + { + visitor.visit_node()?; + match node.node() { + Node::AlwaysTrue if negated => visitor.visit_unsatisfied(db, storage, self), + Node::AlwaysTrue => visitor.visit_satisfied(db, storage, self), + + Node::AlwaysFalse if negated => visitor.visit_satisfied(db, storage, self), + Node::AlwaysFalse => visitor.visit_unsatisfied(db, storage, self), + + Node::Interior(interior) => { + let interior_value = visitor.enter_interior(db, storage, interior)?; + let interior = storage.interior_node_data(node); + + let true_subtree = if negated { + interior.if_true.or(storage, interior.if_uncertain) + } else { + interior.if_true + }; + let if_true = self.walk_edge( + db, + env, + storage, + interior.constraint.when_true(), + |storage, path, new_range, found_conflict| { + let subtree = if found_conflict { + visitor.visit_impossible(db, storage, path) + } else { + path.visit_inner(db, env, storage, true_subtree, visitor, negated) + }; + match subtree { + ControlFlow::Continue(subtree) => visitor.visit_edge( + db, + storage, + &interior_value, + subtree, + path, + new_range, + ), + ControlFlow::Break(b) => ControlFlow::Break(b), + } + }, + )?; + + let if_uncertain = if negated { + let subtree = visitor.visit_impossible(db, storage, self)?; + visitor.visit_edge(db, storage, &interior_value, subtree, self, 0..0)? + } else { + self.walk_edge( + db, + env, + storage, + interior.constraint.when_unconstrained(), + |storage, path, new_range, found_conflict| { + let subtree = if found_conflict { + visitor.visit_impossible(db, storage, path) + } else { + path.visit_inner( + db, + env, + storage, + interior.if_uncertain, + visitor, + false, + ) + }; + match subtree { + ControlFlow::Continue(subtree) => visitor.visit_edge( + db, + storage, + &interior_value, + subtree, + path, + new_range, + ), + ControlFlow::Break(b) => ControlFlow::Break(b), + } + }, + )? + }; + + let false_subtree = if negated { + interior.if_false.or(storage, interior.if_uncertain) + } else { + interior.if_false + }; + let if_false = self.walk_edge( + db, + env, + storage, + interior.constraint.when_false(), + |storage, path, new_range, found_conflict| { + let subtree = if found_conflict { + visitor.visit_impossible(db, storage, path) + } else { + path.visit_inner(db, env, storage, false_subtree, visitor, negated) + }; + match subtree { + ControlFlow::Continue(subtree) => visitor.visit_edge( + db, + storage, + &interior_value, + subtree, + path, + new_range, + ), + ControlFlow::Break(b) => ControlFlow::Break(b), + } + }, + )?; + + visitor.leave_interior( + db, + storage, + &interior_value, + if_true, + if_uncertain, + if_false, + ) + } + } + } + + /// Walks one of the outgoing edges of an internal BDD node. `assignment` describes the + /// constraint that the BDD node checks, and whether we are following the `if_true` or + /// `if_false` edge. + /// + /// This new assignment might cause this path to become impossible — for instance, if we were + /// already assuming (from an earlier edge in the path) a constraint that is disjoint with this + /// one. We might also be able to infer _other_ assignments that do not appear in the BDD + /// directly, but which are implied from a combination of constraints that we _have_ seen. + /// + /// To handle all of this, you provide a callback. If the path has become impossible, we will + /// return `None` _without invoking the callback_. If the path does not contain any + /// contradictions, we will invoke the callback and return its result (wrapped in `Some`). + /// + /// Your callback will also be provided a slice of all of the constraints that we were able to + /// infer from `assignment` combined with the information we already knew. (For borrow-check + /// reasons, we provide this as a [`Range`]; use that range to index into `self.assignments` to + /// get the list of all of the assignments that we learned from this edge.) + /// + /// You will presumably end up making a recursive call of some kind to keep progressing through + /// the BDD. You should make this call from inside of your callback, so that as you get further + /// down into the BDD structure, we remember all of the information that we have learned from + /// the path we're on. + pub(super) fn walk_edge<'db, R>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + assignment: ConstraintAssignment, + f: impl FnOnce(&mut ConstraintSetStorage<'db>, &mut Self, Range, bool) -> R, + ) -> R { + // Record a snapshot of the assignments that we already knew held — both so that we can + // pass along the range of which assignments are new, and so that we can reset back to this + // point before returning. + let start = self.assignments.len(); + let additional_fuels_start = self.additional_fuels.len(); + let previous_remaining_overall_fuel = self.remaining_overall_fuel; + + // Add the new assignment and anything we can derive from it. + tracing::trace!( + target: "ty_python_semantic::types::constraints::PathAssignment", + before = %format_args!( + "[{}]", + self.assignments[..start].iter().map(|(assignment, _)| { + assignment.display(db, env, storage) + }).format(", "), + ), + edge = %assignment.display(db, env, storage), + "walk edge", + ); + debug_assert!(self.assignment_queue.is_empty()); + self.assignment_queue + .push_back((assignment, AssignmentFuel::origin())); + let source_constraint = assignment.constraint(); + let found_conflict = self + .drain_assignment_queue(db, env, storage, source_constraint) + .is_err(); + if !found_conflict { + tracing::trace!( + target: "ty_python_semantic::types::constraints::PathAssignment", + new = %format_args!( + "[{}]", + self.assignments[start..].iter().map(|(assignment, _)| { + assignment.display(db, env, storage) + }).format(", "), + ), + "new assignments", + ); + } + // Otherwise invoke the callback to keep traversing the BDD. The callback will likely + // traverse additional edges, which might add more to our `assignments` set. But even + // if that happens, `start..end` will mark the assignments that were added by the + // `add_assignment` call above — that is, the new assignment for this edge along with + // the derived information we inferred from it. + let end = self.assignments.len(); + let result = f(storage, self, start..end, found_conflict); + + // Reset back to where we were before following this edge, so that the caller can reuse a + // single instance for the entire BDD traversal. + self.assignment_queue.clear(); + self.assignments.truncate(start); + self.additional_fuels.truncate(additional_fuels_start); + self.remaining_overall_fuel = previous_remaining_overall_fuel; + result + } + + pub(super) fn positive_constraints( + &self, + ) -> impl Iterator + '_ { + self.assignments.iter().filter_map( + |(assignment, (source_constraint, _))| match assignment { + ConstraintAssignment::Positive(constraint) => { + Some((*constraint, *source_constraint)) + } + ConstraintAssignment::Negative(_) | ConstraintAssignment::Unconstrained(_) => None, + }, + ) + } + + fn assignment_holds(&self, assignment: ConstraintAssignment) -> bool { + self.assignments.contains_key(&assignment) + } + + fn contains_constraint(&self, constraint: ConstraintId) -> bool { + self.assignment_holds(constraint.when_true()) + || self.assignment_holds(constraint.when_false()) + || self.assignment_holds(constraint.when_unconstrained()) + } + + /// Returns the greatest remaining fuel for any derivation of `assignment` on this path. + fn max_remaining_fuel_for(&self, assignment: ConstraintAssignment) -> Option { + let (index, _, (_, first_fuel)) = self.assignments.get_full(&assignment)?; + let max_fuel = self + .additional_fuels + .iter() + .filter(|(fuel_index, _)| *fuel_index == index) + .map(|(_, fuel)| *fuel) + .fold(*first_fuel, u16::max); + Some(max_fuel) + } + + /// Update our sequent map to ensure that it holds all of the sequents that involve the given + /// constraint. We do not calculate the new sequents directly. Instead, we call + /// [`SequentMap::for_constraint`] and [`for_constraint_pair`][SequentMap::for_constraint_pair] + /// to calculate _and cache_ the constraints, so that if we walk another constraint set + /// containing this constraint, we reuse the work to calculate its sequents. + fn discover_constraint<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + constraint: ConstraintId, + ) { + // If we've already processed this constraint, we can skip it. + let (constraint_index, existing) = self.discovered.insert_full(constraint, true); + let already_processed = existing.is_some_and(|existing| existing); + if already_processed { + return; + } + + let single_map = SequentMap::for_constraint(db, env, storage, constraint); + self.sequents.extend_from_slice(&single_map.sequents); + + for (existing_index, (existing, _)) in self.discovered.iter().enumerate() { + if *existing == constraint { + continue; + } + + let existing_support = storage.constraint_support(*existing); + let constraint_support = storage.constraint_support(constraint); + + // Independent typevars must be checked for disjoint or invalid constraints, but are + // otherwise already constrained and do not participate in sequent discovery. + if !existing_support.overlaps_with(constraint_support) + && existing_support + .iter() + .chain(constraint_support.iter()) + .any(|typevar| self.independent_typevars.contains(&typevar)) + && existing_support.is_complete() + && constraint_support.is_complete() + { + continue; + } + + if SequentMap::pair_cannot_produce_sequents(db, env, storage, *existing, constraint) { + continue; + } + + let (a, b) = if existing_index < constraint_index { + (*existing, constraint) + } else { + (constraint, *existing) + }; + if !self.elaborated_pairs.insert((a, b)) { + // We've already elaborated this pair of constraints. + continue; + } + + let pair_map = SequentMap::for_constraint_pair(db, env, storage, a, b); + self.sequents.extend_from_slice(&pair_map.sequents); + } + } + + fn drain_assignment_queue<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + source_constraint: ConstraintId, + ) -> Result<(), PathAssignmentConflict> { + while let Some((assignment, fuel)) = self.assignment_queue.pop_front() { + self.add_assignment(db, env, storage, assignment, source_constraint, fuel)?; + } + Ok(()) + } + + /// Adds a new assignment, along with any derived information that we can infer from the new + /// assignment combined with the assignments we've already seen. If any of this causes the path + /// to become invalid, due to a contradiction, returns a [`PathAssignmentConflict`] error. + fn add_assignment<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + assignment: ConstraintAssignment, + source_constraint: ConstraintId, + fuel: AssignmentFuel, + ) -> Result<(), PathAssignmentConflict> { + if matches!(assignment, ConstraintAssignment::Unconstrained(_)) { + // An `Unconstrained` assignment means "this constraint can go either way". If there is + // already any assignment for this constraint (positive, negative, or unconstrained), + // the existing assignment is at least as informative, and we skip. + if self.contains_constraint(assignment.constraint()) { + return Ok(()); + } + + // Since we don't know whether the assignment's constraint holds or not, we cannot + // derive any additional information from the sequent map. We still want to record the + // assignment, but as an optimization we can return early without actually querying the + // sequent map. + self.assignments + .insert(assignment, (source_constraint, fuel.remaining)); + return Ok(()); + } + + // First add this assignment. If it causes a conflict, return that as an error. + if self.assignments.contains_key(&assignment.negated()) { + tracing::trace!( + target: "ty_python_semantic::types::constraints::PathAssignment", + assignment = %assignment.display(db, env, storage), + facts = %format_args!( + "[{}]", + self.assignments.iter().map(|(assignment, _)| { + assignment.display(db, env, storage) + }).format(", "), + ), + "found contradiction", + ); + return Err(PathAssignmentConflict); + } + + match self.assignments.entry(assignment) { + Entry::Vacant(entry) => { + if let Some(fuel_cost) = fuel.consumed { + self.remaining_overall_fuel = + match self.remaining_overall_fuel.checked_sub(fuel_cost) { + Some(updated_fuel) => updated_fuel, + None => return Ok(()), + }; + } + entry.insert((source_constraint, fuel.remaining)); + } + + Entry::Occupied(mut entry) => { + let index = entry.index(); + let (existing_source_constraint, existing_fuel) = entry.get_mut(); + + // If a constraint appears both as an "origin" constraint (it actually appears in + // the BDD structure) and as a "derived" constraint (we infer it from other + // constraints), we should prefer the origin source constraint, regardless of which + // order we encounter the various constraints in the BDD. + if !fuel.is_derived() { + *existing_source_constraint = source_constraint; + } + + // We've already seen this assignment, and in theory have already queried the + // sequent map for its consequents, which should let us return early. + // + // However, a new derivation chain can replenish the fuel for this assignment, + // giving it more chances to participate in multi-step sequent chains. That means + // there might be some consequents that were skipped previously due to a lack of + // fuel, that can be added now because of the replinished fuel budget. + + // There is another derivation of this assignment that already provides at least as + // much fuel as this constraint. That means replenishing the fuel won't have any + // effect. + if *existing_fuel >= fuel.remaining + || self + .additional_fuels + .iter() + .any(|(fuel_index, existing_fuel)| { + *fuel_index == index && *existing_fuel >= fuel.remaining + }) + { + return Ok(()); + } + + // Record the replenished fuel separately so that `walk_edge` can restore the + // parent branch by truncating `additional_fuels`. + self.additional_fuels.push((index, fuel.remaining)); + } + } + + // Then use our sequents to add additional facts that we know to be true. + // + // TODO: This is very naive at the moment, partly for expediency, and partly because we + // don't anticipate the sequent maps to be very large. We might consider avoiding the + // brute-force search. + + self.new_assignments.clear(); + self.discover_constraint(db, env, storage, assignment.constraint()); + + for i in 0..self.sequents.len() { + let sequent = self.sequents[i]; + self.check_sequent(db, env, storage, sequent)?; + } + + // If we were able to derive any new assignments from this one, add them to the processing + // queue. + self.assignment_queue.extend(self.new_assignments.drain(..)); + + Ok(()) + } + + fn enqueue_assignment(&mut self, assignment: ConstraintAssignment, new_fuel: AssignmentFuel) { + self.new_assignments + .entry(assignment) + .and_modify(|existing_fuel| { + *existing_fuel = std::cmp::max(*existing_fuel, new_fuel); + }) + .or_insert(new_fuel); + } + + fn check_sequent<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + sequent: Sequent, + ) -> Result<(), PathAssignmentConflict> { + match sequent { + Sequent::SingleTautology { ante } => { + self.check_single_tautology(db, env, storage, ante) + } + Sequent::PairImpossibility { ante1, ante2 } => { + self.check_pair_impossibility(db, env, storage, ante1, ante2) + } + Sequent::PairImplication { ante1, ante2, post } => { + self.check_pair_implication(db, env, storage, ante1, ante2, post); + Ok(()) + } + Sequent::SingleImplication { ante, post } => { + self.check_single_implication(db, env, storage, ante, post); + Ok(()) + } + } + } + + fn check_single_tautology<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ante: ConstraintId, + ) -> Result<(), PathAssignmentConflict> { + if self.assignment_holds(ante.when_false()) { + // The sequent map says (ante1) is always true, and the current path asserts that + // it's false. + tracing::trace!( + target: "ty_python_semantic::types::constraints::PathAssignment", + ante = %ante.display(db, env, storage), + facts = %format_args!( + "[{}]", + self.assignments.iter().map(|(assignment, _)| { + assignment.display(db, env, storage) + }).format(", "), + ), + "found contradiction", + ); + return Err(PathAssignmentConflict); + } + + Ok(()) + } + + fn check_pair_impossibility<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ante1: ConstraintId, + ante2: ConstraintId, + ) -> Result<(), PathAssignmentConflict> { + if self.assignment_holds(ante1.when_true()) && self.assignment_holds(ante2.when_true()) { + // The sequent map says (ante1 ∧ ante2) is an impossible combination, and the + // current path asserts that both are true. + tracing::trace!( + target: "ty_python_semantic::types::constraints::PathAssignment", + ante1 = %ante1.display(db, env, storage), + ante2 = %ante2.display(db, env, storage), + facts = %format_args!( + "[{}]", + self.assignments.iter().map(|(assignment, _)| { + assignment.display(db, env, storage) + }).format(", "), + ), + "found contradiction", + ); + return Err(PathAssignmentConflict); + } + + Ok(()) + } + + fn check_pair_implication<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ante1: ConstraintId, + ante2: ConstraintId, + post: ConstraintId, + ) { + let Some(ante1_fuel) = self.max_remaining_fuel_for(ante1.when_true()) else { + return; + }; + let Some(ante2_fuel) = self.max_remaining_fuel_for(ante2.when_true()) else { + return; + }; + let available_fuel = ante1_fuel.min(ante2_fuel); + let (ante1_constructor_depth, _) = storage.cached_constraint_bound_depth(db, env, ante1); + let (ante2_constructor_depth, _) = storage.cached_constraint_bound_depth(db, env, ante2); + let antecedent_constructor_depth = ante1_constructor_depth.max(ante2_constructor_depth); + let fuel_cost = storage.sequent_fuel_cost(db, env, post, antecedent_constructor_depth); + if let Some(post_fuel) = available_fuel.checked_sub(fuel_cost) { + self.enqueue_assignment( + post.when_true(), + AssignmentFuel::derived(fuel_cost, post_fuel), + ); + } + } + + fn check_single_implication<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ante: ConstraintId, + post: ConstraintId, + ) { + let Some(available_fuel) = self.max_remaining_fuel_for(ante.when_true()) else { + return; + }; + let ante_data = storage.constraint_data(ante); + let (antecedent_constructor_depth, _) = + storage.cached_constraint_bound_depth(db, env, ante); + let post_data = storage.constraint_data(post); + let fuel_cost = if post_data.is_bound_projection_of(db, ante_data) { + 1 + } else { + storage.sequent_fuel_cost(db, env, post, antecedent_constructor_depth) + }; + if let Some(post_fuel) = available_fuel.checked_sub(fuel_cost) { + self.enqueue_assignment( + post.when_true(), + AssignmentFuel::derived(fuel_cost, post_fuel), + ); + } + } +} + +#[derive(Debug)] +struct PathAssignmentConflict; + +#[cfg(test)] +mod tests { + use super::super::solutions::SolutionWalker; + use super::super::*; + + use crate::db::tests::{TestDb, setup_db}; + use crate::types::{BoundTypeVarInstance, KnownClass, TypeVarVariance}; + use ruff_python_ast::name::Name; + + fn create_typevar<'db>(db: &'db TestDb, name: &'static str) -> BoundTypeVarInstance<'db> { + BoundTypeVarInstance::synthetic( + db, + &db.program_environment(), + Name::new_static(name), + TypeVarVariance::Invariant, + ) + } + + fn create_constraint<'db, 'c>( + db: &'db TestDb, + builder: &'c ConstraintSetBuilder<'db>, + bound_typevar: BoundTypeVarInstance<'db>, + bound: KnownClass, + ) -> ConstraintSet<'db, 'c> { + let env = db.program_environment(); + let ty = bound.to_instance(db, &env); + ConstraintSet::constrain_typevar(db, &env, builder, bound_typevar, ty, ty) + } + + #[test] + fn eager_and_lazy_negation_are_equivalent() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let builder = ConstraintSetBuilder::new(); + + let t_int = create_constraint(db, &builder, t, KnownClass::Int); + let t_bool = create_constraint(db, &builder, t, KnownClass::Bool); + let u_str = create_constraint(db, &builder, u, KnownClass::Str); + let u_int = create_constraint(db, &builder, u, KnownClass::Int); + + let lhs = t_int.or(db, &builder, || u_str); + let rhs = t_bool.or(db, &builder, || u_int); + let intersection = lhs.and(db, &builder, || rhs); + let tautology = lhs.or(db, &builder, || lhs.negate(db, &builder)); + + let t_bool_upper = ConstraintSet::constrain_typevar_upper_bound( + db, + &env, + &builder, + t, + KnownClass::Bool.to_instance(db, &env), + ); + let t_int_upper = ConstraintSet::constrain_typevar_upper_bound( + db, + &env, + &builder, + t, + KnownClass::Int.to_instance(db, &env), + ); + let implication = t_bool_upper + .negate(db, &builder) + .or(db, &builder, || t_int_upper); + + for set in [lhs, rhs, intersection, tautology, implication] { + assert_eq!( + set.is_always_satisfied(db, &env), + set.negate(db, &builder).is_never_satisfied(db, &env) + ); + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum PathFoldBreak { + Satisfied, + Unsatisfied, + Impossible, + Combine, + } + + /// A path fold that reconstructs a constraint set from its satisfied paths and can abort at + /// a specified callback. + struct ReconstructPathFold { + break_at: Option, + } + + impl ReconstructPathFold { + fn result( + &self, + at: PathFoldBreak, + result: (NodeId, Option), + ) -> ControlFlow)> { + if self.break_at == Some(at) { + ControlFlow::Break(at) + } else { + ControlFlow::Continue(result) + } + } + } + + impl PathFold for ReconstructPathFold { + type Result = (NodeId, Option); + type Break = PathFoldBreak; + + fn satisfied<'db>( + &mut self, + _db: &'db dyn Db, + storage: &mut ConstraintSetStorage<'db>, + path: &PathAssignments, + ) -> ControlFlow { + let result = + path.assignments + .iter() + .fold((ALWAYS_TRUE, None), |result, (assignment, _)| { + let (node, source_order) = result; + let (assignment, assignment_source_order) = + Node::new_satisfied_constraint(storage, *assignment); + ( + node.and(storage, assignment), + storage.ordered_source_order(source_order, assignment_source_order), + ) + }); + self.result(PathFoldBreak::Satisfied, result) + } + + fn unsatisfied<'db>( + &mut self, + _db: &'db dyn Db, + _storage: &mut ConstraintSetStorage<'db>, + _path: &PathAssignments, + ) -> ControlFlow { + self.result(PathFoldBreak::Unsatisfied, (ALWAYS_FALSE, None)) + } + + fn impossible<'db>( + &mut self, + _db: &'db dyn Db, + _storage: &mut ConstraintSetStorage<'db>, + _path: &PathAssignments, + ) -> ControlFlow { + self.result(PathFoldBreak::Impossible, (ALWAYS_FALSE, None)) + } + + fn combine<'db>( + &mut self, + _db: &'db dyn Db, + storage: &mut ConstraintSetStorage<'db>, + if_true: Self::Result, + if_uncertain: Self::Result, + if_false: Self::Result, + ) -> ControlFlow { + let (if_true, if_true_source_order) = if_true; + let (if_uncertain, if_uncertain_source_order) = if_uncertain; + let (if_false, if_false_source_order) = if_false; + let node = if_true.or(storage, if_uncertain).or(storage, if_false); + let source_order = + storage.ordered_source_order(if_true_source_order, if_uncertain_source_order); + let source_order = storage.ordered_source_order(source_order, if_false_source_order); + self.result(PathFoldBreak::Combine, (node, source_order)) + } + } + + fn path_assignments_for<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + builder: &ConstraintSetBuilder<'db>, + node: NodeId, + source_order: Option, + ) -> PathAssignments { + let mut storage = builder.storage.borrow_mut(); + match node.node() { + Node::AlwaysTrue | Node::AlwaysFalse => PathAssignments::new([], FxHashSet::default()), + Node::Interior(interior) => { + interior.path_assignments(db, env, &mut storage, source_order) + } + } + } + + #[test] + fn path_assignments_follow_constraint_source_order() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let builder = ConstraintSetBuilder::new(); + let t_int = create_constraint(db, &builder, t, KnownClass::Int); + let u_str = create_constraint(db, &builder, u, KnownClass::Str); + + // Construct the set in the opposite order from constraint creation. This ensures the + // initializer follows the sidecar rather than either TDD traversal or constraint IDs. + let set = u_str.and(db, &builder, || t_int); + let path = path_assignments_for(db, &env, &builder, set.node, set.source_order); + let storage = builder.storage.borrow(); + let expected = + [u_str.node, t_int.node].map(|node| storage.interior_node_data(node).constraint); + let actual: Vec<_> = path.discovered.keys().copied().collect(); + + assert_eq!(actual, expected); + } + + #[test] + fn path_fold_reconstructs_constraint_sets() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let v = create_typevar(db, "V"); + let builder = ConstraintSetBuilder::new(); + + let t_int = create_constraint(db, &builder, t, KnownClass::Int); + let t_str = create_constraint(db, &builder, t, KnownClass::Str); + let u_int = create_constraint(db, &builder, u, KnownClass::Int); + let v_bytes = create_constraint(db, &builder, v, KnownClass::Bytes); + let union = t_int.or(db, &builder, || u_int); + let intersection = union.and(db, &builder, || t_str.or(db, &builder, || v_bytes)); + let contradiction = t_int.and(db, &builder, || t_str); + let tautology = union.or(db, &builder, || union.negate(db, &builder)); + + let t_u = + ConstraintSet::constrain_typevar_upper_bound(db, &env, &builder, t, Type::TypeVar(u)); + let u_int_upper = ConstraintSet::constrain_typevar_upper_bound( + db, + &env, + &builder, + u, + KnownClass::Int.to_instance(db, &env), + ); + let int_t = ConstraintSet::constrain_typevar_lower_bound( + db, + &env, + &builder, + t, + KnownClass::Int.to_instance(db, &env), + ); + let transitive = t_u + .and(db, &builder, || u_int_upper) + .and(db, &builder, || int_t) + .or(db, &builder, || v_bytes); + + for set in [ + ConstraintSet::always(&builder), + ConstraintSet::never(&builder), + union, + intersection, + contradiction, + tautology, + transitive, + ] { + let mut path = path_assignments_for(db, &env, &builder, set.node, set.source_order); + let mut fold = ReconstructPathFold { break_at: None }; + let mut storage = builder.storage.borrow_mut(); + let ControlFlow::Continue((reconstructed, reconstructed_source_order)) = + path.visit(db, &env, &mut storage, set.node, &mut fold) + else { + panic!("reconstruction unexpectedly aborted"); + }; + drop(storage); + let reconstructed = + ConstraintSet::from_node(&builder, reconstructed, reconstructed_source_order); + assert!( + set.iff(db, &builder, reconstructed) + .is_always_satisfied(db, &env) + ); + } + } + + #[test] + fn path_fold_break_restores_path_assignments() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let builder = ConstraintSetBuilder::new(); + let t_int = create_constraint(db, &builder, t, KnownClass::Int); + let t_str = create_constraint(db, &builder, t, KnownClass::Str); + let u_int = create_constraint(db, &builder, u, KnownClass::Int); + let set = t_int.and(db, &builder, || t_str).or(db, &builder, || u_int); + + for break_at in [ + PathFoldBreak::Satisfied, + PathFoldBreak::Unsatisfied, + PathFoldBreak::Impossible, + PathFoldBreak::Combine, + ] { + let mut path = path_assignments_for(db, &env, &builder, set.node, set.source_order); + let mut aborting_fold = ReconstructPathFold { + break_at: Some(break_at), + }; + let mut storage = builder.storage.borrow_mut(); + assert_eq!( + path.visit(db, &env, &mut storage, set.node, &mut aborting_fold), + ControlFlow::Break(break_at) + ); + + let mut completing_fold = ReconstructPathFold { break_at: None }; + let ControlFlow::Continue((reconstructed, reconstructed_source_order)) = + path.visit(db, &env, &mut storage, set.node, &mut completing_fold) + else { + panic!("reconstruction unexpectedly aborted after {break_at:?}"); + }; + drop(storage); + let reconstructed = + ConstraintSet::from_node(&builder, reconstructed, reconstructed_source_order); + assert!( + set.iff(db, &builder, reconstructed) + .is_always_satisfied(db, &env) + ); + } + } + + #[test] + fn solution_walker_break_restores_path_assignments() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let t_int = create_constraint(db, &builder, t, KnownClass::Int); + let t_str = create_constraint(db, &builder, t, KnownClass::Str); + let set = t_int.or(db, &builder, || t_str); + let source_orders = builder + .storage + .borrow() + .calculate_source_orders(set.source_order); + let expected = PathBounds::compute( + db, + &env, + &mut builder.storage.borrow_mut(), + set.node, + TypeVarSet::from_typevars(db, [t]), + set.source_order, + ); + + // Both limits interrupt an edge with path-local assignments: the visit limit stops + // below the root, and the path limit stops after collecting the first alternative. + for (remaining_paths, remaining_visits, error) in [ + (usize::MAX, 1, ProjectionError::TraversalBudgetExceeded), + (1, usize::MAX, ProjectionError::PathBudgetExceeded), + ] { + let mut path = path_assignments_for(db, &env, &builder, set.node, set.source_order); + let mut storage = builder.storage.borrow_mut(); + let mut limits = BoundedSolutionLimits { + remaining_paths, + remaining_visits, + }; + let mut walker = SolutionWalker::new(source_orders.clone()); + assert_eq!( + walker.visit_node(db, &env, &mut storage, &mut path, set.node, &mut limits), + ControlFlow::Break(error) + ); + drop(walker); + + let mut limits = UnboundedSolutionLimits; + let mut walker = SolutionWalker::new(source_orders.clone()); + let ControlFlow::Continue(()) = + walker.visit_node(db, &env, &mut storage, &mut path, set.node, &mut limits); + assert_eq!(walker.finish(db, &env, &mut storage), expected); + } + } +} diff --git a/crates/ty_python_semantic/src/types/constraints/projection.rs b/crates/ty_python_semantic/src/types/constraints/projection.rs new file mode 100644 index 0000000000..86ad764a68 --- /dev/null +++ b/crates/ty_python_semantic/src/types/constraints/projection.rs @@ -0,0 +1,252 @@ +//! Bounded projections of correlated constraint solutions. + +use rustc_hash::FxHashSet; + +use super::{ConstraintSet, PathBound, PathBoundSolution, PathBounds, Solutions, TypeVarSolution}; +use crate::types::typevar::TypeVarSet; +use crate::types::{Type, TypeVarVariance}; +use crate::{Db, ProgramEnvironment}; + +/// Limits for one projection, including preprocessing, path collection, and its result. +#[derive(Clone, Copy, Debug)] +pub(crate) struct SolutionBudget { + /// Satisfied paths collected before per-variable solution selection can reject them. + pub(crate) paths: usize, + /// Interior and terminal visits, shared by preprocessing and path collection. + pub(crate) visits: usize, + /// Set-theoretic terms contributed to the result, including terms exposed by aliases. + pub(crate) type_terms: usize, +} + +impl Default for SolutionBudget { + fn default() -> Self { + // Allow long, simple conjunctions and sizable existing unions without allowing their + // alternatives to expand into an equally large family of specializations. + Self { + paths: 4_096, + visits: 32_768, + type_terms: 8_192, + } + } +} + +/// Why an exact projection could not be completed. +/// +/// None of these outcomes proves that the constraint set is unsatisfiable. In particular, a +/// caller must not use the prefix visited before a limit was reached as the complete answer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectionError { + PathBudgetExceeded, + TraversalBudgetExceeded, + TypeBudgetExceeded, + IncompleteSolution, +} + +/// An exact projection of all retained solution paths. +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum SolutionProjection { + Unsatisfiable, + Unconstrained, + Constrained(T), +} + +/// A shared limit on the type terms consumed while constructing a projection. +/// +/// Union projections charge each contribution before adding it. An intersection projection must +/// additionally use `IntersectionType::bounded_from_elements`, since distributing intersections +/// over unions can multiply, rather than add, the number of terms. +pub(crate) struct ProjectionTypeBudget { + remaining: usize, +} + +impl ProjectionTypeBudget { + fn new(remaining: usize) -> Self { + Self { remaining } + } + + /// Charges the set-theoretic terms that a type constructor may flatten or inspect. Aliases + /// are included so a large union cannot evade the limit by being hidden behind a name. + pub(crate) fn charge_type<'db>( + &mut self, + db: &'db dyn Db, + ty: Type<'db>, + ) -> Result<(), ProjectionError> { + self.charge_type_inner(db, ty, &mut FxHashSet::default()) + } + + fn charge_type_inner<'db>( + &mut self, + db: &'db dyn Db, + ty: Type<'db>, + seen_aliases: &mut FxHashSet>, + ) -> Result<(), ProjectionError> { + self.remaining = self + .remaining + .checked_sub(1) + .ok_or(ProjectionError::TypeBudgetExceeded)?; + match ty { + Type::Union(union) => { + for element in union.elements(db) { + self.charge_type_inner(db, *element, seen_aliases)?; + } + } + Type::Intersection(intersection) => { + for element in intersection + .iter_positive(db) + .chain(intersection.iter_negative(db)) + { + self.charge_type_inner(db, element, seen_aliases)?; + } + } + Type::TypeAlias(alias) if seen_aliases.insert(ty) => { + self.charge_type_inner(db, alias.value_type(db), seen_aliases)?; + } + _ => {} + } + Ok(()) + } +} + +impl<'db> ConstraintSet<'db, '_> { + /// Computes default solutions for each BDD path within the default projection budget. + pub(crate) fn solutions( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + inferable: TypeVarSet<'db>, + ) -> Result, ProjectionError> { + let builder = self.builder; + self.solutions_with( + db, + env, + inferable, + SolutionBudget::default(), + |_variance, path_bound| PathBounds::default_solve(db, env, builder, path_bound), + ) + } + + fn bounded_path_bounds( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + inferable: TypeVarSet<'db>, + budget: SolutionBudget, + ) -> Result, ProjectionError> { + PathBounds::compute_bounded( + db, + env, + &mut self.builder.storage.borrow_mut(), + self.node, + inferable, + self.source_order, + budget, + ) + } + + /// Computes solutions using a caller-provided selector within the given projection budget. + /// + /// The selector receives the typevar's variance and explicit lower and upper bounds. Its + /// outcome distinguishes missing evidence, invalid paths, and exhausted solution budgets. + /// The caller is responsible for combining the resulting paths (typically via union). + /// + /// Per-variable budget exhaustion preserves available fallback bindings and marks the path + /// family as [`SolutionPaths::BudgetExceeded`](super::SolutionPaths::BudgetExceeded). + /// Exhausting a limit in the supplied [`SolutionBudget`] instead returns an error without a + /// partial path family. + pub(crate) fn solutions_with( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + inferable: TypeVarSet<'db>, + budget: SolutionBudget, + choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, + ) -> Result, ProjectionError> { + let path_bounds = self.bounded_path_bounds(db, env, inferable, budget)?; + let mut type_budget = ProjectionTypeBudget::new(budget.type_terms); + path_bounds.try_solve_with(choose, |solution| { + for binding in solution { + type_budget.charge_type(db, binding.solution)?; + } + Ok(()) + }) + } + + /// Folds complete, correlated solutions without first allocating every solved path. + /// + /// Raw paths are collected within the traversal limits and sorted in the same source order + /// as [`Self::solutions_with`]. The storage borrow is released before invoking either + /// callback, so they can safely use the constraint builder. Each call to `fold` receives the + /// complete bindings for one retained path, including an empty slice for a valid path on + /// which no variable was solved. + /// + /// The accumulator is returned only if the entire projection succeeds. `fold` must charge + /// newly accumulated types to its supplied budget and use bounded constructors for operations + /// that can expand them. It should combine alternatives commutatively when their order is not + /// meaningful to its consumer. Existing limitations in solution extraction still apply; this + /// API does not make an order-sensitive selector or fold order-independent. + #[expect(clippy::too_many_arguments)] + pub(crate) fn try_fold_solutions( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + inferable: TypeVarSet<'db>, + budget: SolutionBudget, + choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, + initial: T, + fold: impl FnMut( + T, + &[TypeVarSolution<'db>], + &mut ProjectionTypeBudget, + ) -> Result, + ) -> Result, ProjectionError> { + let path_bounds = self.bounded_path_bounds(db, env, inferable, budget)?; + + path_bounds.try_fold_with( + choose, + initial, + &mut ProjectionTypeBudget::new(budget.type_terms), + fold, + ) + } +} + +impl<'db> PathBounds<'db> { + fn try_fold_with( + &self, + mut choose: impl FnMut(TypeVarVariance, &PathBound<'db>) -> PathBoundSolution<'db>, + mut accumulated: T, + budget: &mut ProjectionTypeBudget, + mut fold: impl FnMut( + T, + &[TypeVarSolution<'db>], + &mut ProjectionTypeBudget, + ) -> Result, + ) -> Result, ProjectionError> { + let paths = match self { + Self::Unsatisfiable => return Ok(SolutionProjection::Unsatisfiable), + Self::Unconstrained => return Ok(SolutionProjection::Unconstrained), + Self::Constrained(paths) => paths, + }; + + let mut retained = false; + for path in paths { + let Some((solution, incomplete)) = Self::solve_path_with(path, &mut choose) else { + continue; + }; + if incomplete { + return Err(ProjectionError::IncompleteSolution); + } + accumulated = fold(accumulated, &solution, budget)?; + retained = true; + } + + Ok(if retained { + SolutionProjection::Constrained(accumulated) + } else { + SolutionProjection::Unsatisfiable + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ty_python_semantic/src/types/constraints/projection/tests.rs b/crates/ty_python_semantic/src/types/constraints/projection/tests.rs new file mode 100644 index 0000000000..7d636a791f --- /dev/null +++ b/crates/ty_python_semantic/src/types/constraints/projection/tests.rs @@ -0,0 +1,633 @@ +use itertools::Itertools; +use ruff_db::files::system_path_to_file; +use ruff_db::system::DbWithWritableSystem; +use ruff_python_ast::name::Name; +use rustc_hash::FxHashSet; +use ty_python_core::ProgramFile; + +use super::{ProjectionError, ProjectionTypeBudget, SolutionBudget, SolutionProjection}; +use crate::db::tests::{TestDb, setup_db}; +use crate::place::global_symbol; +use crate::types::constraints::{ + ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, PathBound, + PathBoundSolution, PathBounds, Solution, SolutionPaths, Solutions, TypeVarSolution, +}; +use crate::types::typevar::TypeVarSet; +use crate::types::{ + BoundTypeVarInstance, IntersectionType, KnownClass, Type, TypeVarVariance, UnionType, +}; + +type Paths<'db> = FxHashSet>; + +fn create_typevar<'db>(db: &'db TestDb, name: &'static str) -> BoundTypeVarInstance<'db> { + BoundTypeVarInstance::synthetic( + db, + &db.program_environment(), + Name::new_static(name), + TypeVarVariance::Invariant, + ) +} + +fn known_instance(db: &TestDb, class: KnownClass) -> Type<'_> { + class.to_instance(db, &db.program_environment()) +} + +fn exact<'db, 'c>( + db: &'db TestDb, + builder: &'c ConstraintSetBuilder<'db>, + typevar: BoundTypeVarInstance<'db>, + ty: Type<'db>, +) -> ConstraintSet<'db, 'c> { + ConstraintSet::constrain_typevar(db, &db.program_environment(), builder, typevar, ty, ty) +} + +fn binary_choice<'db, 'c>( + db: &'db TestDb, + builder: &'c ConstraintSetBuilder<'db>, + typevar: BoundTypeVarInstance<'db>, + alternatives: [Type<'db>; 2], +) -> ConstraintSet<'db, 'c> { + alternatives + .into_iter() + .when_any(db, builder, |ty| exact(db, builder, typevar, ty)) +} + +fn binding<'db>( + bound_typevar: BoundTypeVarInstance<'db>, + solution: Type<'db>, +) -> TypeVarSolution<'db> { + TypeVarSolution { + bound_typevar, + solution, + } +} + +fn collect_paths<'db, 'c>( + db: &'db TestDb, + builder: &'c ConstraintSetBuilder<'db>, + set: ConstraintSet<'db, 'c>, + typevars: &[BoundTypeVarInstance<'db>], + budget: SolutionBudget, +) -> Result>, ProjectionError> { + let env = db.program_environment(); + set.try_fold_solutions( + db, + &env, + TypeVarSet::from_typevars(db, typevars.iter().copied()), + budget, + |_, bound| PathBounds::default_solve(db, &env, builder, bound), + Paths::default(), + |mut paths, path, budget| { + for binding in path { + budget.charge_type(db, binding.solution)?; + } + let mut path = path.to_vec(); + path.sort_by_key(|binding| { + typevars + .iter() + .position(|typevar| *typevar == binding.bound_typevar) + }); + paths.insert(path); + Ok(paths) + }, + ) +} + +#[test] +fn path_limit_is_checked_before_solving() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let builder = ConstraintSetBuilder::new(); + let set = binary_choice(db, &builder, t, [int, str]) + .and(db, &builder, || binary_choice(db, &builder, u, [int, str])); + let inferable = TypeVarSet::from_typevars(db, [t, u]); + + for max_paths in [0, 3, 4] { + let mut selected = 0; + let mut folded = 0; + let result = set.try_fold_solutions( + db, + &env, + inferable, + SolutionBudget { + paths: max_paths, + ..SolutionBudget::default() + }, + |_, bound| { + selected += 1; + PathBounds::default_solve(db, &env, &builder, bound) + }, + 0, + |count, _, _| { + folded += 1; + Ok(count + 1) + }, + ); + + if max_paths < 4 { + assert_eq!(result, Err(ProjectionError::PathBudgetExceeded)); + assert_eq!(selected, 0); + assert_eq!(folded, 0); + } else { + assert_eq!(result, Ok(SolutionProjection::Constrained(4))); + assert_eq!(selected, 8); + } + } +} + +#[test] +fn terminal_projections_need_no_paths_or_types() { + let db = setup_db(); + let db = &db; + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + + // Terminal answers do not allocate any path or construct any type. + let terminal_budget = SolutionBudget { + paths: 0, + visits: 1, + type_terms: 0, + }; + for (set, expected) in [ + ( + ConstraintSet::always(&builder), + SolutionProjection::Unconstrained, + ), + ( + ConstraintSet::never(&builder), + SolutionProjection::Unsatisfiable, + ), + ] { + assert_eq!( + collect_paths(db, &builder, set, &[t], terminal_budget), + Ok(expected) + ); + } +} + +#[test] +fn source_and_interning_order_do_not_change_correlated_projection() { + let db = setup_db(); + let db = &db; + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let bytes = known_instance(db, KnownClass::Bytes); + let bool = known_instance(db, KnownClass::Bool); + let atoms = [(t, int), (t, str), (u, bytes), (u, bool)]; + // These alternatives do not admit the crossed pairings of T and U. + let expected = FxHashSet::from_iter([ + vec![binding(t, int), binding(u, bytes)], + vec![binding(t, str), binding(u, bool)], + ]); + + for interning_order in (0..atoms.len()).permutations(atoms.len()) { + for reverse_source in [false, true] { + let builder = ConstraintSetBuilder::new(); + for index in &interning_order { + let (typevar, ty) = atoms[*index]; + exact(db, &builder, typevar, ty); + } + let [t_int, t_str, u_bytes, u_bool] = + atoms.map(|(typevar, ty)| exact(db, &builder, typevar, ty)); + let set = if reverse_source { + u_bool + .and(db, &builder, || t_str) + .or(db, &builder, || u_bytes.and(db, &builder, || t_int)) + } else { + t_int + .and(db, &builder, || u_bytes) + .or(db, &builder, || t_str.and(db, &builder, || u_bool)) + }; + + assert_eq!( + collect_paths(db, &builder, set, &[t, u], SolutionBudget::default()), + Ok(SolutionProjection::Constrained(expected.clone())), + "interning order {interning_order:?}, reverse source {reverse_source}" + ); + assert_eq!( + collect_paths( + db, + &builder, + set, + &[t, u], + SolutionBudget { + paths: 1, + ..SolutionBudget::default() + }, + ), + Err(ProjectionError::PathBudgetExceeded) + ); + } + } +} + +#[test] +fn four_independent_binary_arguments_have_sixteen_solutions() { + let db = setup_db(); + let db = &db; + let typevars = ["T", "U", "V", "W"].map(|name| create_typevar(db, name)); + let alternatives = + [[1, 2], [3, 4], [5, 6], [7, 8]].map(|choices| choices.map(Type::int_literal)); + let builder = ConstraintSetBuilder::new(); + + // Four arguments that independently admit two specializations produce sixteen whole-call + // solutions. The limit applies before constructing any of their projected return types. + let set = + typevars + .into_iter() + .zip(alternatives) + .when_all(db, &builder, |(typevar, alternatives)| { + binary_choice(db, &builder, typevar, alternatives) + }); + let expected = alternatives + .into_iter() + .multi_cartesian_product() + .map(|choices| { + typevars + .into_iter() + .zip(choices) + .map(|(typevar, ty)| binding(typevar, ty)) + .collect() + }) + .collect(); + + assert_eq!( + collect_paths( + db, + &builder, + set, + &typevars, + SolutionBudget { + paths: 16, + ..SolutionBudget::default() + }, + ), + Ok(SolutionProjection::Constrained(expected)) + ); + assert_eq!( + collect_paths( + db, + &builder, + set, + &typevars, + SolutionBudget { + paths: 15, + ..SolutionBudget::default() + }, + ), + Err(ProjectionError::PathBudgetExceeded) + ); +} + +#[test] +fn incomplete_solution_discards_the_projection() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let builder = ConstraintSetBuilder::new(); + let inferable = TypeVarSet::from_typevars(db, [t]); + let budget = SolutionBudget { + type_terms: 2, + ..SolutionBudget::default() + }; + + for alternatives in [[int, str], [str, int]] { + let set = binary_choice(db, &builder, t, alternatives); + let choose = |_, bound: &PathBound<'_>| { + if bound.evidence_lower == Some(str) { + PathBoundSolution::BudgetExceeded { + fallback: Some(str), + } + } else { + PathBoundSolution::Solved(int) + } + }; + + assert_eq!( + set.solutions_with(db, &env, inferable, budget, choose), + Ok(Solutions::Constrained(SolutionPaths::BudgetExceeded( + alternatives.map(|ty| vec![binding(t, ty)]).into() + ))) + ); + assert_eq!( + set.try_fold_solutions(db, &env, inferable, budget, choose, 0, |count, _, _| Ok( + count + 1 + ),), + Err(ProjectionError::IncompleteSolution) + ); + } +} + +#[test] +fn rejected_exhausted_path_does_not_poison_valid_sibling() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let u = create_typevar(db, "U"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let bytes = known_instance(db, KnownClass::Bytes); + let inferable = TypeVarSet::from_typevars(db, [t, u]); + let budget = SolutionBudget { + type_terms: 1, + ..SolutionBudget::default() + }; + + for reverse_bounds in [false, true] { + for reverse_paths in [false, true] { + let builder = ConstraintSetBuilder::new(); + let t_str = exact(db, &builder, t, str); + let u_bytes = exact(db, &builder, u, bytes); + let rejected = if reverse_bounds { + u_bytes.and(db, &builder, || t_str) + } else { + t_str.and(db, &builder, || u_bytes) + }; + let valid = exact(db, &builder, t, int); + let set = if reverse_paths { + valid.or(db, &builder, || rejected) + } else { + rejected.or(db, &builder, || valid) + }; + + // Only the valid sibling consumes the budget, even when the rejected path had + // already selected a type or retained a fallback before finding its contradiction. + for rejected_binding in [ + PathBoundSolution::Solved(str), + PathBoundSolution::BudgetExceeded { + fallback: Some(str), + }, + ] { + let choose = |_, bound: &PathBound<'_>| { + if bound.bound_typevar == u { + PathBoundSolution::Unsatisfiable + } else if bound.evidence_lower == Some(str) { + rejected_binding + } else { + PathBoundSolution::Solved(int) + } + }; + assert_eq!( + set.solutions_with(db, &env, inferable, budget, choose), + Ok(Solutions::Constrained(SolutionPaths::Complete(vec![vec![ + binding(t, int), + ]]))) + ); + assert_eq!( + set.try_fold_solutions( + db, + &env, + inferable, + budget, + choose, + Vec::new(), + |mut paths, path, budget| { + for binding in path { + budget.charge_type(db, binding.solution)?; + } + paths.push(path.to_vec()); + Ok(paths) + }, + ), + Ok(SolutionProjection::Constrained(vec![vec![binding(t, int)]])) + ); + } + } + } +} + +#[test] +fn valid_unsolved_path_is_not_unconstrained() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let set = exact(db, &builder, t, known_instance(db, KnownClass::Int)); + let inferable = TypeVarSet::from_typevars(db, [t]); + let budget = SolutionBudget { + type_terms: 0, + ..SolutionBudget::default() + }; + + for (selected, collected, projected) in [ + ( + PathBoundSolution::Unsolved, + Solutions::Constrained(SolutionPaths::Complete(vec![vec![]])), + Ok(SolutionProjection::Constrained(1)), + ), + ( + PathBoundSolution::BudgetExceeded { fallback: None }, + Solutions::Constrained(SolutionPaths::BudgetExceeded(vec![vec![]])), + Err(ProjectionError::IncompleteSolution), + ), + ( + PathBoundSolution::Unsatisfiable, + Solutions::Unsatisfiable, + Ok(SolutionProjection::Unsatisfiable), + ), + ] { + assert_eq!( + set.solutions_with(db, &env, inferable, budget, |_, _| selected), + Ok(collected) + ); + assert_eq!( + set.try_fold_solutions( + db, + &env, + inferable, + budget, + |_, _| selected, + 0, + |count, path, _| { + assert!(path.is_empty()); + Ok(count + 1) + }, + ), + projected + ); + } +} + +#[test] +fn type_budget_is_charged_before_constructing_a_union() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let bytes = known_instance(db, KnownClass::Bytes); + let builder = ConstraintSetBuilder::new(); + let set = binary_choice(db, &builder, t, [int, str]) + .or(db, &builder, || exact(db, &builder, t, bytes)); + let inferable = TypeVarSet::from_typevars(db, [t]); + + for max_type_terms in [0, 1, 2, 3] { + let budget = SolutionBudget { + type_terms: max_type_terms, + ..SolutionBudget::default() + }; + let mut selected = 0; + let collected = set.solutions_with(db, &env, inferable, budget, |_, bound| { + selected += 1; + PathBounds::default_solve(db, &env, &builder, bound) + }); + // One additional path is selected to discover that it exceeds the budget; later + // paths are not solved. + assert_eq!(selected, (max_type_terms + 1).min(3)); + + let mut constructed = 0; + let result = set.try_fold_solutions( + db, + &env, + inferable, + budget, + |_, bound| PathBounds::default_solve(db, &env, &builder, bound), + Type::Never, + |accumulated, path, budget| { + assert_eq!(path.len(), 1); + let ty = path[0].solution; + budget.charge_type(db, ty)?; + constructed += 1; + Ok(UnionType::from_two_elements(db, &env, accumulated, ty)) + }, + ); + + assert_eq!(constructed, max_type_terms); + if max_type_terms < 3 { + assert_eq!(collected, Err(ProjectionError::TypeBudgetExceeded)); + assert_eq!(result, Err(ProjectionError::TypeBudgetExceeded)); + } else { + assert_eq!( + collected, + Ok(Solutions::Constrained(SolutionPaths::Complete( + [int, str, bytes].map(|ty| vec![binding(t, ty)]).into() + ))) + ); + assert_eq!( + result, + Ok(SolutionProjection::Constrained(UnionType::from_elements( + db, + &env, + [int, str, bytes], + ))) + ); + } + } +} + +#[test] +fn type_budget_charges_nested_set_theoretic_terms() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +type Alias = int | str +type Recursive = int | Recursive +"#, + )?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let alias = |name| { + global_symbol(db, file, name) + .place + .expect_type() + .as_type_alias() + .map(Type::TypeAlias) + .ok_or_else(|| anyhow::anyhow!("expected alias {name}")) + }; + let int = known_instance(db, KnownClass::Int); + let str = known_instance(db, KnownClass::Str); + let union = UnionType::from_two_elements(db, &env, int, str); + let intersection = + IntersectionType::from_elements(db, &env, [int, Type::int_literal(1).negate(db, &env)]); + + // Existing set operations count their members; aliases cannot hide those members. A + // recursive alias is charged again at the cycle, but its body is expanded only once. + for (ty, terms) in [ + (union, 3), + (intersection, 3), + (alias("Alias")?, 4), + (alias("Recursive")?, 4), + ] { + assert_eq!( + ProjectionTypeBudget::new(terms - 1).charge_type(db, ty), + Err(ProjectionError::TypeBudgetExceeded) + ); + assert_eq!(ProjectionTypeBudget::new(terms).charge_type(db, ty), Ok(())); + } + Ok(()) +} + +#[test] +fn intersection_construction_failure_discards_the_projection() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... +"#, + )?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let instance = |name| { + global_symbol(db, file, name) + .place + .expect_type() + .to_instance_approximation(db, &env) + .ok_or_else(|| anyhow::anyhow!("expected class {name}")) + }; + let left = UnionType::from_elements(db, &env, [instance("A")?, instance("B")?]); + let right = + UnionType::from_elements(db, &env, [instance("C")?, instance("D")?, instance("E")?]); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + + // These classes can overlap, so distributing the intersection requires six DNF terms. + // Charging the input alone does not prevent that expansion; the fold also needs a bounded + // intersection constructor. + for alternatives in [[left, right], [right, left]] { + let paths = PathBounds::Constrained( + alternatives + .map(|ty| Box::new([PathBound::exact(t, ty)]) as Box<[_]>) + .into(), + ); + + assert_eq!( + paths.try_fold_with( + |_, bound| PathBounds::default_solve(db, &env, &builder, bound), + Type::object(), + &mut ProjectionTypeBudget::new(7), + |accumulated, path, budget| { + assert_eq!(path.len(), 1); + let ty = path[0].solution; + budget.charge_type(db, ty)?; + IntersectionType::bounded_from_elements(db, &env, [accumulated, ty]) + .ok_or(ProjectionError::TypeBudgetExceeded) + }, + ), + Err(ProjectionError::TypeBudgetExceeded) + ); + } + Ok(()) +} diff --git a/crates/ty_python_semantic/src/types/constraints/sequents.rs b/crates/ty_python_semantic/src/types/constraints/sequents.rs new file mode 100644 index 0000000000..ceb74779b5 --- /dev/null +++ b/crates/ty_python_semantic/src/types/constraints/sequents.rs @@ -0,0 +1,1593 @@ +//! The [`SequentMap`] and related functionality + +use std::cell::Cell; +use std::fmt::{Debug, Display}; + +use smallvec::SmallVec; + +use crate::types::constraints::{ + ALWAYS_FALSE, ALWAYS_TRUE, Constraint, ConstraintBound, ConstraintId, ConstraintSetBuilder, + ConstraintSetStorage, IntersectionResult, Node, +}; +use crate::types::typevar::TypeVarSet; +use crate::types::variance::VarianceInferable; +use crate::types::visitor::{ + TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, +}; +use crate::types::{BoundTypeVarInstance, Type, TypeVarVariance}; +use crate::{Db, ProgramEnvironment}; + +/// A collection of _sequents_ that describe how the constraints mentioned in a BDD relate to each +/// other. These are used in several BDD operations that need to know about "derived facts" even if +/// they are not mentioned in the BDD directly. These operations involve walking one or more paths +/// from the root node to a terminal node. Each sequent describes paths that are invalid (which are +/// pruned from the search), and new constraints that we can assume to be true even if we haven't +/// seen them directly. +/// +/// Sequent maps are primarily used when walking a BDD path with a +/// [`PathAssignments`][super::paths::PathAssignments]. The +/// `PathAssignments` will hold a sequent map containing all of the constraints that are +/// encountered during the walk. It builds up its sequent map lazily, so that it only has to +/// include sequents for the constraints that are actually encountered. However, we also don't want +/// to perform duplicate work if we perform multiple BDD walks on the same constraint set. The +/// [`for_constraint`][Self::for_constraint] and [`for_constraint_pair`][Self::for_constraint_pair] +/// methods are salsa-tracked, to ensure that we only perform them once for any particular +/// constraint or pair of constraints. `PathAssignments` invokes these methods when it encounters a +/// new constraint, and then merges those cached sequents into its own sequent map. (That means we +/// also share the work of calculating the sequent map across `PathAssignments` for _different_ +/// constraint sets.) +#[derive(Debug, Default)] +pub(super) struct SequentMap { + pub(super) sequents: Vec, +} + +/// Describes one rule for deriving new implicit constraints from existing constraints in a BDD +/// path. +#[derive(Clone, Copy, Debug)] +pub(super) enum Sequent { + /// Sequent of the form `¬C → false` + /// + /// This indicates that `C` is always true. Any path that assumes it is false is impossible and + /// can be pruned. + SingleTautology { ante: ConstraintId }, + + /// Sequent of the form `C₁ ∧ C₂ → false` + /// + /// This indicates that `C₁` and `C₂` are disjoint: it is not possible for both to hold. Any + /// path that assumes both is impossible and can be pruned. + PairImpossibility { + ante1: ConstraintId, + ante2: ConstraintId, + }, + + /// Sequent of the form `C → D` + /// + /// This indicates that `C` on its own is enough to imply `D`. For any path that assumes `C` + /// holds, we can add `D` to the path even if it doesn't appear in the BDD. + SingleImplication { + ante: ConstraintId, + post: ConstraintId, + }, + + /// Sequent of the form `C₁ ∧ C₂ → D` + /// + /// This indicates that if `C₁` and `C₂` are both true, then `D` is guaranteed to be true as + /// well. For any path that assumes both `C₁` and `C₂` hold, we can add `D` to the path even if + /// it doesn't appear in the BDD. + PairImplication { + ante1: ConstraintId, + ante2: ConstraintId, + post: ConstraintId, + }, +} + +impl SequentMap { + pub(super) fn consequents(&self) -> impl Iterator + '_ { + self.sequents.iter().filter_map(|sequent| match sequent { + Sequent::SingleImplication { post, .. } | Sequent::PairImplication { post, .. } => { + Some(*post) + } + Sequent::SingleTautology { .. } | Sequent::PairImpossibility { .. } => None, + }) + } + + /// Returns a sequent map containing the sequents that we can infer from a single constraint in + /// isolation. This method is salsa-tracked so that we only perform this work once per + /// constraint. + pub(super) fn for_constraint<'db, 'c>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &'c mut ConstraintSetStorage<'db>, + constraint: ConstraintId, + ) -> &'c Self { + let key = constraint; + if !storage.single_sequent_cache.contains_key(&key) { + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + constraint = %constraint.display(db, env, storage), + "add sequents for constraint", + ); + let mut map = SequentMap::default(); + map.add_sequents_for_single(db, env, storage, constraint); + storage.single_sequent_cache.insert(key, map); + } + &storage.single_sequent_cache[&key] + } + + /// Returns a sequent map containing the sequents that we can infer from a pair of constraints. + /// This method is salsa-tracked so that we only perform this work once per constraint pair. + /// + /// (Note that this method is _not_ commutative; you should provide `left` and `right` in the + /// order that they appear in the source code, so that we can construct derived constraints + /// that retain that ordering.) + pub(super) fn for_constraint_pair<'db, 'c>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &'c mut ConstraintSetStorage<'db>, + left: ConstraintId, + right: ConstraintId, + ) -> &'c Self { + let key = (left, right); + if !storage.pair_sequent_cache.contains_key(&key) { + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + left = %left.display(db, env, storage), + right = %right.display(db, env, storage), + "add sequents for constraint pair", + ); + let mut map = SequentMap::default(); + map.add_sequents_for_pair(db, env, storage, left, right); + storage.pair_sequent_cache.insert(key, map); + } + &storage.pair_sequent_cache[&key] + } + + /// Quickly determines whether two constraints cannot possibly produce any sequents when passed + /// to [`for_constraint_pair`][Self::for_constraint_pair]. If this returns `true`, it is safe + /// to skip calling `for_constraint_pair` for this pair of constraints. + pub(super) fn pair_cannot_produce_sequents<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left: ConstraintId, + right: ConstraintId, + ) -> bool { + // Currently, the only pattern we look for is when two constraints that have _only_ lower + // bounds, where those lower bounds are disjoint. Given `l₁ ≤ T ∧ l₂ ≤ T`, the only + // sequent we could theoretically produce is `(l₁ | l₂) ≤ T`. But we don't store that as a + // single constraint; we always break that apart into the two smaller constraints that we + // started with. + + let left = storage.constraint_data(left); + let right = storage.constraint_data(right); + if !left.typevar.is_same_typevar_as(db, right.typevar) { + return false; + } + + let (Some(left_lower), Some(right_lower)) = + (left.stored_lower_bound(), right.stored_lower_bound()) + else { + return false; + }; + if left.stored_upper_bound().is_some() || right.stored_upper_bound().is_some() { + return false; + } + let left_lower = left_lower.ty(); + let right_lower = right_lower.ty(); + + // This call might need its own borrow of the builder's storage, so create a new builder + // that it can use. + let builder = ConstraintSetBuilder::new(); + left_lower + .when_trivially_disjoint_from(db, env, right_lower, &builder, TypeVarSet::None) + .is_trivially_always_satisfied() + } + + fn add_single_tautology(&mut self, ante: ConstraintId) { + self.sequents.push(Sequent::SingleTautology { ante }); + } + + fn add_pair_impossibility(&mut self, ante1: ConstraintId, ante2: ConstraintId) { + self.sequents + .push(Sequent::PairImpossibility { ante1, ante2 }); + } + + fn add_pair_implication<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ante1: ConstraintId, + ante2: ConstraintId, + post: ConstraintId, + ) { + // If the post constraint is unsatisfiable, then the antecedents contradict each other. + let post_data = storage.constraint_data(post); + let post_lower = post_data.lower_bound(db).ty(); + let post_upper = post_data.upper_bound(db).ty(); + let (when, source_order) = storage.load( + db, + env, + &post_lower.when_constraint_set_assignable_to_owned(db, env, post_upper), + ); + if when.is_never_satisfied(db, env, storage, source_order) { + self.add_pair_impossibility(ante1, ante2); + return; + } + + // If either antecedent implies the consequent on its own, this new sequent is redundant. + if ante1.implies(db, env, storage, post) || ante2.implies(db, env, storage, post) { + return; + } + + self.sequents + .push(Sequent::PairImplication { ante1, ante2, post }); + } + + fn add_single_implication(&mut self, ante: ConstraintId, post: ConstraintId) { + if ante == post { + return; + } + + self.sequents + .push(Sequent::SingleImplication { ante, post }); + } + + fn add_sequents_for_single<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + constraint: ConstraintId, + ) { + // If this constraint binds its typevar to `Never ≤ T ≤ object`, then the typevar can take + // on any type, and the constraint is always satisfied. + // For a ParamSpec, the bottom and top parameter lists likewise allow every specialization. + // Record this fact without discarding the supplied bounds as inference evidence. + // Some internal producers still use the ordinary identities for ParamSpecs. + let constraint_data = storage.constraint_data(constraint); + let lower = constraint_data.lower_bound(db).ty(); + let upper = constraint_data.upper_bound(db).ty(); + if (constraint_data + .stored_lower_bound() + .is_none_or(|bound| bound.ty().is_never()) + && constraint_data + .stored_upper_bound() + .is_none_or(|bound| bound.ty().is_object())) + || (constraint_data.typevar.is_paramspec(db) + && constraint_data.typevar.paramspec_attr(db).is_none() + && lower.is_equivalent_to(db, env, constraint_data.default_lower_bound(db)) + && upper.is_equivalent_to(db, env, constraint_data.default_upper_bound(db))) + { + self.add_single_tautology(constraint); + return; + } + + // Given a constraint `L ≤ T ≤ U`, `L ≤ U` must also hold. If those bounds contain other + // typevars, we can infer additional constraints. This is easiest to see when the bounds + // _are_ typevars: + // + // 1. `(S ≤ T ≤ U) → (S ≤ U)` + // 2. `(S ≤ T ≤ τ) → (S ≤ τ)` + // 3. `(τ ≤ T ≤ U) → (τ ≤ U)` + // + // but it also holds when the bounds _contain_ typevars: + // + // 4. `(Covariant[S] ≤ T ≤ Covariant[U]) → (S ≤ U)` + // `(Covariant[S] ≤ T ≤ Covariant[τ]) → (S ≤ τ)` + // `(Covariant[τ] ≤ T ≤ Covariant[U]) → (τ ≤ U)` + // + // 5. `(Contravariant[S] ≤ T ≤ Contravariant[U]) → (U ≤ S)` + // `(Contravariant[S] ≤ T ≤ Contravariant[τ]) → (τ ≤ S)` + // `(Contravariant[τ] ≤ T ≤ Contravariant[U]) → (U ≤ τ)` + // + // 6. `(Invariant[S] ≤ T ≤ Invariant[U]) → (S = U)` + // `(Invariant[S] ≤ T ≤ Invariant[τ]) → (S = τ)` + // `(Invariant[τ] ≤ T ≤ Invariant[U]) → (τ = U)` + // + // and whenever the bounds are assignable, even if they don't mention exactly the same + // types: + // + // class Sub(Covariant[int]): ... + // + // 7. `(Covariant[S] ≤ T ≤ Sub) → (S ≤ int)` + // `(Sub ≤ T ≤ Covariant[U]) → (int ≤ U)` + // + // To handle all of these cases, we perform a constraint set assignability check to see + // when `L ≤ U`. This gives us a constraint set, which should be the rhs of the sequent + // implication. (That is, this check directly encodes `(L ≤ T ≤ U) → (L ≤ U)` as an + // implication.) + + // Missing endpoints add no relation to derive. In particular, do not turn a synthetic + // ParamSpec default into stored evidence through a lazy comparison with another typevar. + if constraint_data.stored_lower_bound().is_none() + || constraint_data.stored_upper_bound().is_none() + || lower.is_never() + || upper.is_object() + { + return; + } + + let (when, source_order) = storage.load( + db, + env, + &lower.when_constraint_set_assignable_to_owned(db, env, upper), + ); + + // If L is _never_ assignable to U, this constraint would violate transitivity, and should + // never have been added. + #[expect(clippy::debug_assert_with_mut_call)] + { + debug_assert!(!when.is_never_satisfied(db, env, storage, source_order)); + } + + // Fast path: If L is trivially always assignable to U, there are no derived constraints + // that we can infer. This would be handled correctly by the logic below, but this is a + // useful early return. Since we only use this check as an early return happy path, we can + // accept false negatives. That lets us use the simpler and cheaper check against + // ALWAYS_TRUE, rather than a more expensive is_always_satisfiable call. + if when == ALWAYS_TRUE { + return; + } + + // Technically, we've just calculated a _constraint set_ as the rhs of this implication. + // Unfortunately, our sequent map can currently only store implications where the rhs is a + // single constraint. + // + // If the constraint set that we get represents a single conjunction, we can still shoehorn + // it into this shape, since we can "break apart" a conjunction on the rhs of an + // implication: + // + // a → b ∧ c ∧ d + // + // becomes + // + // a → b + // a → c + // a → d + // + // That takes care of breaking apart the rhs conjunction: we can add each positive + // constraint as a separate single_implication. + // + // We can also handle _negative_ constraints, because those turn into impossibilities: + // + // a → ¬b + // + // becomes + // + // a ∧ b → false + // + // TODO: This should handle the most common cases. In the future, we could handle arbitrary + // rhs constraint sets by moving this logic into PathAssignments::walk_path, and performing + // it once for _every_ root→always path in the BDD. (That would require resetting the + // PathAssignments state for each of those paths, which is why the logic would have to + // move.) + let mut node = when; + if !node.is_single_conjunction(storage) { + return; + } + + loop { + match node.node() { + Node::AlwaysTrue | Node::AlwaysFalse => break, + Node::Interior(interior) => { + let interior = storage.interior_node_data(interior.node()); + let derived = storage.constraint_data(interior.constraint); + let derived = ConstraintId::new_with_bounds( + db, + env, + storage, + derived.typevar, + derived + .stored_lower_bound() + .map(|bound| bound.with_source_provenance(constraint_data)), + derived + .stored_upper_bound() + .map(|bound| bound.with_source_provenance(constraint_data)), + ); + if interior.if_true != ALWAYS_FALSE { + self.add_single_implication(constraint, derived); + node = interior.if_true; + } else { + self.add_pair_impossibility(constraint, derived); + node = interior.if_false; + } + } + } + } + } + + fn add_sequents_for_pair<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left_constraint: ConstraintId, + right_constraint: ConstraintId, + ) { + // If either of the constraints has another typevar as a lower/upper bound, the only + // sequents we can add are for the transitive closure. For instance, if we have + // `(S ≤ T) ∧ (T ≤ int)`, then `(S ≤ int)` will also hold, and we should add a sequent for + // this implication. These are the `mutual_sequents` mentioned below — sequents that come + // about because two typevars are mutually constrained. + // + // Complicating things is that `(S ≤ T)` will be encoded differently depending on how `S` + // and `T` compare in our arbitrary BDD variable ordering. + // + // When `S` comes before `T`, `(S ≤ T)` will be encoded as `(Never ≤ S ≤ T)`, and the + // overall antecedent will be `(Never ≤ S ≤ T) ∧ (T ≤ int)`. Those two individual + // constraints constrain different typevars (`S` and `T`, respectively), and are handled by + // `add_mutual_sequents_for_different_typevars`. + // + // When `T` comes before `S`, `(S ≤ T)` will be encoded as `(S ≤ T ≤ object)`, and the + // overall antecedent will be `(S ≤ T ≤ object) ∧ (T ≤ int)`. Those two individual + // constraints both constrain `T`, and are handled by + // `add_mutual_sequents_for_same_typevars`. + // + // If all of the lower and upper bounds are concrete (i.e., not typevars), then there + // several _other_ sequents that we can add, as handled by `add_concrete_sequents`. + let left_constraint_data = storage.constraint_data(left_constraint); + let left_typevar = left_constraint_data.typevar; + let right_constraint_data = storage.constraint_data(right_constraint); + let right_typevar = right_constraint_data.typevar; + + if !left_typevar.is_same_typevar_as(db, right_typevar) { + self.add_mutual_sequents_for_different_typevars( + db, + env, + storage, + left_constraint, + right_constraint, + ); + self.add_nested_typevar_sequents(db, env, storage, left_constraint, right_constraint); + } else if left_constraint_data + .iter_stored_bounds() + .chain(right_constraint_data.iter_stored_bounds()) + .any(|bound| bound.ty().is_type_var()) + { + self.add_mutual_sequents_for_same_typevars( + db, + env, + storage, + left_constraint, + right_constraint, + ); + } else { + self.add_concrete_sequents(db, env, storage, left_constraint, right_constraint); + } + } + + fn add_mutual_sequents_for_different_typevars<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left_constraint: ConstraintId, + right_constraint: ConstraintId, + ) { + // We've structured our constraints so that a typevar's upper/lower bound can only + // be another typevar if the bound is "later" in our arbitrary ordering. That means + // we only have to check this pair of constraints in one direction — though we do + // have to figure out which of the two typevars is constrained, and which one is + // the upper/lower bound. + let left_constraint_data = storage.constraint_data(left_constraint); + let left_typevar = left_constraint_data.typevar; + let right_constraint_data = storage.constraint_data(right_constraint); + let right_typevar = right_constraint_data.typevar; + let (bound_constraint, constrained_constraint) = + if left_typevar.can_be_bound_for(db, storage, right_typevar) { + (left_constraint, right_constraint) + } else { + (right_constraint, left_constraint) + }; + + // We then look for cases where the "constrained" typevar's upper and/or lower bound + // matches the "bound" typevar. If so, we're going to add an implication sequent that + // replaces the upper/lower bound that matched with the bound constraint's corresponding + // bound. + let bound_constraint_data = storage.constraint_data(bound_constraint); + let bound_typevar = bound_constraint_data.typevar; + let constrained_constraint_data = storage.constraint_data(constrained_constraint); + let constrained_typevar = constrained_constraint_data.typevar; + let constrained_lower_bound = constrained_constraint_data.stored_lower_bound(); + let constrained_upper_bound = constrained_constraint_data.stored_upper_bound(); + let bound_lower_bound = bound_constraint_data.stored_lower_bound(); + let bound_upper_bound = bound_constraint_data.stored_upper_bound(); + // A pivot can equal a missing endpoint's identity, such as an alias of Never. That + // comparison is useful even though there is no stored bound to copy. + let effective_bound_lower = bound_constraint_data.lower_bound(db); + let effective_bound_upper = bound_constraint_data.upper_bound(db); + + // Transitive pivots require subtyping; classes with dynamic bases can be assignable to + // unrelated types without being subtypes. + let (new_lower, new_upper) = match ( + constrained_lower_bound, + constrained_upper_bound, + bound_lower_bound, + bound_upper_bound, + ) { + // (B ≤ C ≤ B) ∧ (BL ≤ B ≤ BU) → (BL ≤ C ≤ BU) + (Some(constrained_lower), Some(constrained_upper), _, _) + if let Type::TypeVar(constrained_lower_typevar) = constrained_lower.ty() + && let Type::TypeVar(constrained_upper_typevar) = constrained_upper.ty() + && constrained_lower_typevar.is_same_typevar_as(db, bound_typevar) + && constrained_upper_typevar.is_same_typevar_as(db, bound_typevar) => + { + ( + bound_lower_bound.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + constrained_lower, + bound, + ) + }), + bound_upper_bound.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + constrained_upper, + bound, + ) + }), + ) + } + + // (CL ≤ C ≤ B) ∧ (BL ≤ B ≤ BU) → (CL ≤ C ≤ BU) + (_, Some(constrained_upper), _, _) + if let Type::TypeVar(constrained_upper_typevar) = constrained_upper.ty() + && constrained_upper_typevar.is_same_typevar_as(db, bound_typevar) => + { + ( + constrained_lower_bound, + bound_upper_bound.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + constrained_upper, + bound, + ) + }), + ) + } + + // (B ≤ C ≤ CU) ∧ (BL ≤ B ≤ BU) → (BL ≤ C ≤ CU) + (Some(constrained_lower), _, _, _) + if let Type::TypeVar(constrained_lower_typevar) = constrained_lower.ty() + && constrained_lower_typevar.is_same_typevar_as(db, bound_typevar) => + { + ( + bound_lower_bound.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + constrained_lower, + bound, + ) + }), + constrained_upper_bound, + ) + } + + // (CL ≤ C ≤ pivot) ∧ (pivot ≤ B ≤ BU) → (CL ≤ C ≤ B) + (_, Some(constrained_upper), _, _) + if !constrained_upper.ty().is_never() + && !constrained_upper.ty().is_object() + && storage.cached_is_constraint_set_subtype_of( + db, + env, + constrained_upper.ty().top_materialization(db, env), + effective_bound_lower.ty().bottom_materialization(db, env), + ) => + { + ( + constrained_lower_bound, + Some(ConstraintBound::from_transitive_derivation( + Type::TypeVar(bound_typevar), + constrained_upper, + effective_bound_lower, + )), + ) + } + + // (pivot ≤ C ≤ CU) ∧ (BL ≤ B ≤ pivot) → (B ≤ C ≤ CU) + (Some(constrained_lower), _, _, _) + if !constrained_lower.ty().is_never() + && !constrained_lower.ty().is_object() + && storage.cached_is_constraint_set_subtype_of( + db, + env, + effective_bound_upper.ty().top_materialization(db, env), + constrained_lower.ty().bottom_materialization(db, env), + ) => + { + ( + Some(ConstraintBound::from_transitive_derivation( + Type::TypeVar(bound_typevar), + constrained_lower, + effective_bound_upper, + )), + constrained_upper_bound, + ) + } + + _ => return, + }; + + let mut post_constraints: SmallVec<[ConstraintId; 3]> = SmallVec::new(); + // These are derived logical constraints, not direct inference evidence. Avoid preserving + // explicit bounds that are equivalent to missing lower/upper bounds, so a derived + // `T ≤ U ≤ object` can satisfy a later query for `T ≤ U` without requiring a separate + // materialized-default implication. + let mut constrained_lower = new_lower.filter(|bound| !bound.ty().is_never()); + let mut constrained_upper = new_upper.filter(|bound| !bound.ty().is_object()); + + // The transitive rule above gives us an intended post-condition + // `new_lower ≤ [constrained] ≤ new_upper`. + // + // If a top-level bound typevar is "earlier" than `constrained`, we cannot represent that + // directly as a bound on `constrained` without violating our canonical ordering. + // Instead, split it into equivalent canonical constraints by "moving" that bound onto the + // other typevar: + // + // invalid lower `L ≤ [C]` -> `(Never ≤ [L] ≤ C)` and drop `L` from C's lower bound + // invalid upper `[C] ≤ U` -> `(C ≤ [U] ≤ object)` and drop `U` from C's upper bound + // + // Example: if we derive `[A] ≤ T ≤ [B]` but `A`/`B` are not valid top-level bounds for + // `T` in this ordering, we emit two pair implications: + // `(Never ≤ [A] ≤ T)` and `(T ≤ [B] ≤ object)`. + // This preserves the relationship while keeping all derived constraints canonical. + if let Some(new_lower) = new_lower + && let Type::TypeVar(lower_bound_typevar) = new_lower.ty() + && !lower_bound_typevar.can_be_bound_for(db, storage, constrained_typevar) + { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + lower_bound_typevar, + None, + Some(new_lower.with_type(Type::TypeVar(constrained_typevar))), + )); + constrained_lower = None; + } + + if let Some(new_upper) = new_upper + && let Type::TypeVar(upper_bound_typevar) = new_upper.ty() + && !upper_bound_typevar.can_be_bound_for(db, storage, constrained_typevar) + { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + upper_bound_typevar, + Some(new_upper.with_type(Type::TypeVar(constrained_typevar))), + None, + )); + constrained_upper = None; + } + + if constrained_lower.is_some() || constrained_upper.is_some() { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + constrained_typevar, + constrained_lower, + constrained_upper, + )); + } + + for post_constraint in post_constraints { + self.add_pair_implication( + db, + env, + storage, + left_constraint, + right_constraint, + post_constraint, + ); + } + } + + /// Adds sequents for the case where one constraint's lower or upper bound contains another + /// constraint's typevar nested inside a parameterized type (e.g., `U ≤ Covariant[T]`). + /// + /// This is distinct from `add_mutual_sequents_for_different_typevars`, which handles the case + /// where a typevar appears _directly_ as a top-level lower/upper bound (e.g., `U ≤ T`). A + /// bare `Type::TypeVar` is technically a special case of covariant nesting (since the variance + /// of `T` in `T` itself is covariant), but the existing direct-typevar logic handles it + /// separately because it requires careful canonical ordering of typevar-to-typevar constraints + /// that the generic nested-typevar logic here does not need to worry about. + fn add_nested_typevar_sequents<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left_constraint: ConstraintId, + right_constraint: ConstraintId, + ) { + // Keep this precheck aligned with `variance_of`, which visits lazy types. + let has_typevar_bound = |constraint: Constraint<'db>| { + constraint + .iter_stored_bounds() + .any(|bound| any_over_type(db, env, bound.ty(), true, Type::is_type_var)) + }; + if !has_typevar_bound(storage.constraint_data(left_constraint)) + && !has_typevar_bound(storage.constraint_data(right_constraint)) + { + return; + } + + let mut try_tightening = + |bound_constraint: ConstraintId, constrained_constraint: ConstraintId| { + let bound_data = storage.constraint_data(bound_constraint); + let bound_typevar = bound_data.typevar; + let bound_identity = bound_typevar.identity(db); + let bound_lower_bound = bound_data.stored_lower_bound(); + let bound_upper_bound = bound_data.stored_upper_bound(); + let constrained_data = storage.constraint_data(constrained_constraint); + let constrained_typevar = constrained_data.typevar; + let constrained_identity = constrained_typevar.identity(db); + let constrained_lower_bound = constrained_data.lower_bound(db); + let constrained_upper_bound = constrained_data.upper_bound(db); + let constrained_lower = constrained_lower_bound.ty(); + let constrained_upper = constrained_upper_bound.ty(); + + // If the replacement contains the bound typevar itself (e.g., the bound + // constraint is `_V ≤ G[_V]`), or the constrained typevar (e.g., the bound + // constraint is `_T ≤ G[_V]` and we're about to substitute into `_V ≤ G[_T]`), + // substituting would create a deeper nesting of the same recursive pattern + // that triggers the same substitution again ad infinitum. Skip in both cases. + // + // Fast-path bare typevar replacements (`Type::TypeVar`) using equality checks + // instead of calling `variance_of` on them. This avoids a large number of tiny + // cached variance queries in hot paths. + let replacement_mentions_bound_or_constrained = |replacement: Type<'db>| { + replacement + .variance_of(db, env, bound_identity) + .evaluate(db) + != TypeVarVariance::Bivariant + || replacement + .variance_of(db, env, constrained_identity) + .evaluate(db) + != TypeVarVariance::Bivariant + }; + + // Check the upper bound of the constrained constraint for nested occurrences of + // the bound typevar. We use `variance_of` as our combined presence + variance + // check: `Bivariant` means the typevar doesn't appear in the type (or is genuinely + // bivariant, which is semantically equivalent — no implication is needed in either + // case). + // + // Note: if `Bivariant` is ever removed from the `TypeVarVariance` enum, we would + // need an alternative representation for "typevar not present" + // (e.g., `Option`). + let upper_replacement = match ( + constrained_upper + .variance_of(db, env, bound_identity) + .evaluate(db), + bound_lower_bound, + bound_upper_bound, + ) { + (TypeVarVariance::Bivariant, _, _) => None, + // Skip bare typevars — those are handled by + // `add_mutual_sequents_for_different_typevars`. + _ if constrained_upper.is_type_var() => None, + // Covariance preserves direction: upper bound on T substitutes into upper + // bound. A ≤ B → G[A] ≤ G[B], so (T ≤ u_B) gives G[T] ≤ G[u_B]. + (TypeVarVariance::Covariant, _, Some(bound_upper)) + if !bound_upper.ty().is_object() => + { + Some(bound_upper) + } + // Contravariance flips direction: lower bound on T substitutes into upper + // bound. A ≤ B → G[B] ≤ G[A], so (l_B ≤ T) gives G[T] ≤ G[l_B]. + (TypeVarVariance::Contravariant, Some(bound_lower), _) + if !bound_lower.ty().is_never() => + { + Some(bound_lower) + } + // Invariance requires equality: only substitute if l_B = u_B. + (TypeVarVariance::Invariant, Some(bound_lower), Some(bound_upper)) + if bound_lower.ty() == bound_upper.ty() && !bound_lower.ty().is_never() => + { + Some(ConstraintBound::from_combination( + bound_lower.ty(), + bound_lower, + bound_upper, + )) + } + // An object lower bound already forces equality without a supplied upper bound. + (TypeVarVariance::Invariant, Some(bound_lower), None) + if bound_lower.ty().is_object() => + { + Some(bound_lower) + } + _ => None, + }; + let upper_replacement = upper_replacement.filter(|replacement| { + // Substituting one typevar for another into large unions can generate many + // very-weak derived constraints and cause severe performance regressions. + // Keep the common/non-union case enabled; skip union upper bounds for this + // specific typevar-to-typevar replacement shape. + if replacement.ty().is_type_var() && constrained_upper.is_union() { + return false; + } + !replacement_mentions_bound_or_constrained(replacement.ty()) + }); + if let Some(replacement) = upper_replacement { + let new_upper = constrained_upper.substitute_one_typevar( + db, + env, + bound_typevar, + replacement.ty(), + ); + if new_upper != constrained_upper { + let post = ConstraintId::new_with_bounds( + db, + env, + storage, + constrained_typevar, + constrained_data.stored_lower_bound(), + Some(ConstraintBound::from_transitive_derivation( + new_upper, + constrained_upper_bound, + replacement, + )), + ); + self.add_pair_implication( + db, + env, + storage, + bound_constraint, + constrained_constraint, + post, + ); + } + } + + // Check the lower bound of the constrained constraint for nested occurrences. + let lower_replacement = match ( + constrained_lower + .variance_of(db, env, bound_identity) + .evaluate(db), + bound_lower_bound, + bound_upper_bound, + ) { + (TypeVarVariance::Bivariant, _, _) => None, + _ if constrained_lower.is_type_var() => None, + // Covariance preserves direction: lower bound on T substitutes into lower + // bound. A ≤ B → G[A] ≤ G[B], so (l_B ≤ T) gives G[l_B] ≤ G[T]. + (TypeVarVariance::Covariant, Some(bound_lower), _) + if !bound_lower.ty().is_never() => + { + Some(bound_lower) + } + // Contravariance flips direction: upper bound on T substitutes into lower + // bound. A ≤ B → G[B] ≤ G[A], so (T ≤ u_B) gives G[u_B] ≤ G[T]. + (TypeVarVariance::Contravariant, _, Some(bound_upper)) + if !bound_upper.ty().is_object() => + { + Some(bound_upper) + } + // Invariance requires equality: only substitute if l_B = u_B. + (TypeVarVariance::Invariant, Some(bound_lower), Some(bound_upper)) + if bound_lower.ty() == bound_upper.ty() && !bound_lower.ty().is_never() => + { + Some(ConstraintBound::from_combination( + bound_lower.ty(), + bound_lower, + bound_upper, + )) + } + (TypeVarVariance::Invariant, Some(bound_lower), None) + if bound_lower.ty().is_object() => + { + Some(bound_lower) + } + _ => None, + }; + let lower_replacement = lower_replacement.filter(|replacement| { + // Substituting one typevar for another into large intersections can generate + // many very-weak derived constraints and cause severe performance regressions. + // Keep the common/non-intersection case enabled; skip intersection lower + // bounds for this specific typevar-to-typevar replacement shape. + if replacement.ty().is_type_var() && constrained_lower.is_intersection() { + return false; + } + !replacement_mentions_bound_or_constrained(replacement.ty()) + }); + if let Some(replacement) = lower_replacement { + let new_lower = constrained_lower.substitute_one_typevar( + db, + env, + bound_typevar, + replacement.ty(), + ); + if new_lower != constrained_lower { + let post = ConstraintId::new_with_bounds( + db, + env, + storage, + constrained_typevar, + Some(ConstraintBound::from_transitive_derivation( + new_lower, + constrained_lower_bound, + replacement, + )), + constrained_data.stored_upper_bound(), + ); + self.add_pair_implication( + db, + env, + storage, + bound_constraint, + constrained_constraint, + post, + ); + } + } + }; + + try_tightening(left_constraint, right_constraint); + try_tightening(right_constraint, left_constraint); + + // Additionally, check if one constraint's bare typevar *bound* appears nested in the other + // constraint's bounds. This handles the "dual" direction: instead of substituting a + // typevar's concrete bounds into another constraint (tightening), we substitute the + // typevar itself for one of its bare typevar bounds (weakening), creating a cross-typevar + // link. + // + // For example, given `(Covariant[S] ≤ C) ∧ (Never ≤ B ≤ S)`, S is B's upper bound and + // appears covariantly in C's lower bound. Since `B ≤ S`, covariance tells us that + // `Covariant[B] ≤ Covariant[S]`. Transitivity then lets us derive `Covariant[B] ≤ C`. + // + // The derived constraint is weaker than the original, but it introduces a relationship + // between B and C that we need to remember and propagate if we ever existentially quantify + // away S. + // + // TODO: This only handles the case where the bound (in this case, S) is a bare typevar. A + // future extension could handle arbitrary types by pattern-matching on generic alias + // structure. + // + // This is defined as a separate closure because it iterates over the bound constraint's + // bare typevar bounds, which is a different axis than `try_tightening`'s check on the + // bound constraint's typevar. + let mut try_weakening = + |bound_constraint: ConstraintId, constrained_constraint: ConstraintId| { + let bound_data = storage.constraint_data(bound_constraint); + let bound_typevar = bound_data.typevar; + let bound_lower_bound = bound_data.lower_bound(db); + let bound_upper_bound = bound_data.upper_bound(db); + let bound_lower = bound_lower_bound.ty(); + let constrained_data = storage.constraint_data(constrained_constraint); + let constrained_typevar = constrained_data.typevar; + let constrained_lower_bound = constrained_data.lower_bound(db); + let constrained_upper_bound = constrained_data.upper_bound(db); + let constrained_lower = constrained_lower_bound.ty(); + let constrained_upper = constrained_upper_bound.ty(); + + let mut try_one_bound = |bound: ConstraintBound<'db>, is_upper_bound: bool| { + let Some(nested_typevar) = bound.ty().as_typevar() else { + return; + }; + + // Skip if the nested typevar is the same as the constrained typevar — that + // case is handled by `add_mutual_sequents_for_different_typevars`. + if nested_typevar.is_same_typevar_as(db, constrained_typevar) + || nested_typevar.is_same_typevar_as(db, bound_typevar) + { + return; + } + + let replacement = Type::TypeVar(bound_typevar); + + // Check the constrained constraint's upper bound for nested occurrences of + // nested_typevar (S). We want to *weaken* (relax) the upper bound by making it + // larger: + // - Covariant + S is B's lower bound (S ≤ B): G[S] ≤ G[B] → weaker. Emit. + // - Contravariant + S is B's upper bound (B ≤ S): G[S] ≤ G[B] → weaker. Emit. + // - Other combinations tighten rather than weaken. Skip. + let should_weaken_upper = !constrained_upper.is_type_var() + && !constrained_upper.is_never() + && !constrained_upper.is_object() + && !constrained_upper.is_dynamic() + && match constrained_upper + .variance_of(db, env, nested_typevar.identity(db)) + .evaluate(db) + { + TypeVarVariance::Bivariant => false, + TypeVarVariance::Covariant => !is_upper_bound, + TypeVarVariance::Contravariant => is_upper_bound, + TypeVarVariance::Invariant => { + bound_lower_bound.ty() == bound_upper_bound.ty() + && !bound_lower.is_never() + } + }; + if should_weaken_upper { + let new_upper = constrained_upper.substitute_one_typevar( + db, + env, + nested_typevar, + replacement, + ); + if new_upper != constrained_upper { + let post = ConstraintId::new_with_bounds( + db, + env, + storage, + constrained_typevar, + constrained_data.stored_lower_bound(), + Some(ConstraintBound::from_transitive_derivation( + new_upper, + constrained_upper_bound, + bound, + )), + ); + self.add_pair_implication( + db, + env, + storage, + bound_constraint, + constrained_constraint, + post, + ); + } + } + + // Ditto for the lower bound. + let should_weaken_lower = !constrained_lower.is_type_var() + && !constrained_lower.is_never() + && !constrained_lower.is_object() + && !constrained_lower.is_dynamic() + && match constrained_lower + .variance_of(db, env, nested_typevar.identity(db)) + .evaluate(db) + { + TypeVarVariance::Bivariant => false, + TypeVarVariance::Covariant => is_upper_bound, + TypeVarVariance::Contravariant => !is_upper_bound, + TypeVarVariance::Invariant => { + bound_lower_bound.ty() == bound_upper_bound.ty() + && !bound_lower.is_never() + } + }; + if should_weaken_lower { + let new_lower = constrained_lower.substitute_one_typevar( + db, + env, + nested_typevar, + replacement, + ); + if new_lower != constrained_lower { + let post = ConstraintId::new_with_bounds( + db, + env, + storage, + constrained_typevar, + Some(ConstraintBound::from_transitive_derivation( + new_lower, + constrained_lower_bound, + bound, + )), + constrained_data.stored_upper_bound(), + ); + self.add_pair_implication( + db, + env, + storage, + bound_constraint, + constrained_constraint, + post, + ); + } + } + }; + + // For each bare typevar bound S of the bound constraint, check if S appears + // nested in the constrained constraint's bounds. If so, we can substitute B + // (the bound constraint's typevar) for S, producing a weaker but useful + // constraint. + if let Some(upper) = bound_data.stored_upper_bound() { + try_one_bound(upper, true); + } + if let Some(lower) = bound_data.stored_lower_bound() { + try_one_bound(lower, false); + } + }; + + try_weakening(left_constraint, right_constraint); + try_weakening(right_constraint, left_constraint); + } + + fn add_mutual_sequents_for_same_typevars<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left_constraint: ConstraintId, + right_constraint: ConstraintId, + ) { + let mut try_one_direction = + |left_constraint: ConstraintId, right_constraint: ConstraintId| { + let left_constraint_data = storage.constraint_data(left_constraint); + let left_lower = left_constraint_data.stored_lower_bound(); + let left_upper = left_constraint_data.stored_upper_bound(); + let right_constraint_data = storage.constraint_data(right_constraint); + let right_lower = right_constraint_data.stored_lower_bound(); + let right_upper = right_constraint_data.stored_upper_bound(); + let mut new_constraints = + |bound_typevar: BoundTypeVarInstance<'db>, + mut right_lower: Option>, + mut right_upper: Option>| { + if let Some(right_lower_bound) = right_lower + && let Type::TypeVar(other_bound_typevar) = right_lower_bound.ty() + && bound_typevar.is_same_typevar_as(db, other_bound_typevar) + { + right_lower = None; + } + if let Some(right_upper_bound) = right_upper + && let Type::TypeVar(other_bound_typevar) = right_upper_bound.ty() + && bound_typevar.is_same_typevar_as(db, other_bound_typevar) + { + right_upper = None; + } + + // Same idea as `add_mutual_sequents_for_different_typevars`: if a derived + // post-condition for `[bound]` has top-level typevar bounds in the wrong + // orientation, split it into equivalent canonical constraints instead of + // dropping it. + let mut post_constraints: SmallVec<[ConstraintId; 3]> = SmallVec::new(); + // These are derived logical constraints, not direct inference evidence. + // Avoid preserving explicit bounds that are equivalent to missing + // lower/upper bounds; direct constraints still retain their explicit + // bound presence. + let mut constrained_lower = + right_lower.filter(|bound| !bound.ty().is_never()); + let mut constrained_upper = + right_upper.filter(|bound| !bound.ty().is_object()); + + if let Some(right_lower_bound) = right_lower + && let Type::TypeVar(lower_bound_typevar) = right_lower_bound.ty() + && !lower_bound_typevar.can_be_bound_for(db, storage, bound_typevar) + { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + lower_bound_typevar, + None, + Some(right_lower_bound.with_type(Type::TypeVar(bound_typevar))), + )); + constrained_lower = None; + } + + if let Some(right_upper_bound) = right_upper + && let Type::TypeVar(upper_bound_typevar) = right_upper_bound.ty() + && !upper_bound_typevar.can_be_bound_for(db, storage, bound_typevar) + { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + upper_bound_typevar, + Some(right_upper_bound.with_type(Type::TypeVar(bound_typevar))), + None, + )); + constrained_upper = None; + } + + if constrained_lower.is_some() || constrained_upper.is_some() { + post_constraints.push(ConstraintId::new_with_bounds( + db, + env, + storage, + bound_typevar, + constrained_lower, + constrained_upper, + )); + } + + post_constraints + }; + let post_constraints = match (left_lower, left_upper) { + (Some(left_lower), Some(left_upper)) + if let Type::TypeVar(bound_typevar) = left_lower.ty() + && let Type::TypeVar(other_bound_typevar) = left_upper.ty() + && bound_typevar.is_same_typevar_as(db, other_bound_typevar) => + { + new_constraints( + bound_typevar, + right_lower.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + left_lower, + bound, + ) + }), + right_upper.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + left_upper, + bound, + ) + }), + ) + } + (Some(left_lower), _) if let Type::TypeVar(bound_typevar) = left_lower.ty() => { + new_constraints( + bound_typevar, + None, + right_upper.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + left_lower, + bound, + ) + }), + ) + } + (_, Some(left_upper)) if let Type::TypeVar(bound_typevar) = left_upper.ty() => { + new_constraints( + bound_typevar, + right_lower.map(|bound| { + ConstraintBound::from_transitive_derivation( + bound.ty(), + left_upper, + bound, + ) + }), + None, + ) + } + _ => return, + }; + for post_constraint in post_constraints { + self.add_pair_implication( + db, + env, + storage, + left_constraint, + right_constraint, + post_constraint, + ); + } + }; + + try_one_direction(left_constraint, right_constraint); + try_one_direction(right_constraint, left_constraint); + } + + fn add_concrete_sequents<'db>( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + left_constraint: ConstraintId, + right_constraint: ConstraintId, + ) { + // These might seem redundant with the intersection check below, since `a → b` means that + // `a ∧ b = a`. But we are not normalizing constraint bounds, and these clauses help us + // identify constraints that are identical besides e.g. ordering of union/intersection + // elements. (For instance, when processing `T ≤ τ₁ & τ₂` and `T ≤ τ₂ & τ₁`, these clauses + // would add sequents for `(T ≤ τ₁ & τ₂) → (T ≤ τ₂ & τ₁)` and vice versa.) + if storage.cached_constraint_implies(db, env, left_constraint, right_constraint) { + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + left = %left_constraint.display(db, env, storage), + right = %right_constraint.display(db, env, storage), + "left implies right", + ); + self.add_single_implication(left_constraint, right_constraint); + } + if storage.cached_constraint_implies(db, env, right_constraint, left_constraint) { + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + left = %left_constraint.display(db, env, storage), + right = %right_constraint.display(db, env, storage), + "right implies left", + ); + self.add_single_implication(right_constraint, left_constraint); + } + + match left_constraint.intersect(db, env, storage, right_constraint) { + IntersectionResult::Simplified(intersection_constraint_data) => { + let intersection_constraint = + storage.intern_constraint(db, env, intersection_constraint_data); + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + left = %left_constraint.display(db, env, storage), + right = %right_constraint.display(db, env, storage), + intersection = %intersection_constraint.display(db, env, storage), + "left and right overlap", + ); + self.add_pair_implication( + db, + env, + storage, + left_constraint, + right_constraint, + intersection_constraint, + ); + self.add_single_implication(intersection_constraint, left_constraint); + self.add_single_implication(intersection_constraint, right_constraint); + } + + // The sequent map only needs to include constraints that might appear in a BDD. If the + // intersection does not collapse to a single constraint, then there's no new + // constraint that we need to add to the sequent map. + IntersectionResult::CannotSimplify => {} + + IntersectionResult::Disjoint => { + tracing::trace!( + target: "ty_python_semantic::types::constraints::SequentMap", + left = %left_constraint.display(db, env, storage), + right = %right_constraint.display(db, env, storage), + "left and right are disjoint", + ); + self.add_pair_impossibility(left_constraint, right_constraint); + } + } + } + + #[expect(dead_code)] // Keep this around for debugging purposes + fn display<'db, 'a>( + &'a self, + db: &'db dyn Db, + env: &'a ProgramEnvironment<'db>, + storage: &'a ConstraintSetStorage<'db>, + prefix: &'a dyn Display, + ) -> impl Display + 'a { + std::fmt::from_fn(move |f| { + let mut first = true; + let mut maybe_write_prefix = |f: &mut std::fmt::Formatter<'_>| { + if first { + first = false; + Ok(()) + } else { + write!(f, "\n{prefix}") + } + }; + + for sequent in &self.sequents { + match sequent { + Sequent::SingleTautology { .. } => {} + + Sequent::PairImpossibility { ante1, ante2 } => { + maybe_write_prefix(f)?; + write!( + f, + "{} ∧ {} → false", + ante1.display(db, env, storage), + ante2.display(db, env, storage), + )?; + } + + Sequent::PairImplication { ante1, ante2, post } => { + maybe_write_prefix(f)?; + write!( + f, + "{} ∧ {} → {}", + ante1.display(db, env, storage), + ante2.display(db, env, storage), + post.display(db, env, storage), + )?; + } + + Sequent::SingleImplication { ante, post } => { + maybe_write_prefix(f)?; + write!( + f, + "{} → {}", + ante.display(db, env, storage), + post.display(db, env, storage) + )?; + } + } + } + + if first { + f.write_str("[no sequents]")?; + } + Ok(()) + }) + } +} + +impl<'db> Type<'db> { + /// Returns whether this type can participate in a transitive sequent proof. + /// + /// Gradual assignability is not transitive, so constraints with dynamic bounds are ineligible. + /// Note that we can't use [`is_fully_static`][Type::is_fully_static] here, since that + /// considers the declared bounds/constraints of typevars. In the context of a sequent map, + /// typevars are opaque symbolic atoms: considering their bounds or defaults could incorrectly + /// make their eligibility depend on a specialization that the sequent is meant to constrain. + pub(super) fn is_static_sequent_eligible( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + struct EligibilityVisitor<'a, 'db> { + env: &'a ProgramEnvironment<'db>, + seen: TypeCollector<'db>, + eligible: Cell, + } + + impl<'db> TypeVisitor<'db> for EligibilityVisitor<'_, 'db> { + fn program_environment(&self) -> &ProgramEnvironment<'db> { + self.env + } + + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if !self.eligible.get() || ty.is_type_var() { + return; + } + if ty.is_dynamic() { + self.eligible.set(false); + return; + } + walk_type_with_recursion_guard(db, ty, self, &self.seen); + } + } + + let visitor = EligibilityVisitor { + env, + seen: TypeCollector::default(), + eligible: Cell::new(true), + }; + visitor.visit_type(db, self); + visitor.eligible.get() + } +} + +impl<'db> ConstraintSetStorage<'db> { + /// Returns how much sequent fuel is needed to derive this constraint. + /// + /// This cost is driven by two factors. + /// + /// First, nested types containing typevars can produce increasingly complex families of + /// derived constraints. Charge more fuel for those constraints so that each additional level + /// of typevar depth shortens the remaining derivation chain. + /// + /// Second, even without considering typevars, the lower and upper bounds can become more + /// structurally complex. We consider a type to be more complex if it has deeper nesting of + /// type constructors. Each sequent is charged the _increase_ in that complexity between its + /// antecedents and its consequent. (Measuring growth rather than absolute depth avoids + /// penalizing a complex concrete bound that is merely propagated unchanged.) + pub(super) fn sequent_fuel_cost( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + constraint: ConstraintId, + antecedent_constructor_depth: u16, + ) -> u16 { + let (constructor_depth, typevar_depth) = + self.cached_constraint_bound_depth(db, env, constraint); + let constructor_growth = constructor_depth.saturating_sub(antecedent_constructor_depth); + typevar_depth.max(constructor_growth).saturating_add(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::db::tests::{TestDb, setup_db}; + use crate::types::typevar::TypeVarBoundOrConstraints; + use crate::types::{BoundTypeVarInstance, KnownClass, SubclassOfType, TypeVarVariance}; + use ruff_python_ast::name::Name; + + fn create_typevar<'db>(db: &'db TestDb, name: &'static str) -> BoundTypeVarInstance<'db> { + BoundTypeVarInstance::synthetic( + db, + &db.program_environment(), + Name::new_static(name), + TypeVarVariance::Invariant, + ) + } + + fn known_instance(db: &TestDb, class: KnownClass) -> Type<'_> { + class.to_instance(db, &db.program_environment()) + } + + #[test] + fn overlapping_lower_bounds_do_not_skip_nonempty_sequent_map() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let builder = ConstraintSetBuilder::new(); + let t = create_typevar(db, "T"); + let bool = known_instance(db, KnownClass::Bool); + let u = create_typevar(db, "U") + .map_bound_or_constraints(db, |_| Some(TypeVarBoundOrConstraints::UpperBound(bool))); + let type_of_u = SubclassOfType::from(db, &env, u); + let bool_class = KnownClass::Bool.to_class_literal(db, &env); + let mut storage = builder.storage.borrow_mut(); + let left = ConstraintId::new_with_bounds( + db, + &env, + &mut storage, + t, + Some(ConstraintBound::Evidence(type_of_u)), + None, + ); + let right = ConstraintId::new_with_bounds( + db, + &env, + &mut storage, + t, + Some(ConstraintBound::Evidence(bool_class)), + None, + ); + + for (left, right) in [(left, right), (right, left)] { + let sequents = SequentMap::for_constraint_pair(db, &env, &mut storage, left, right); + + assert!( + sequents + .sequents + .iter() + .any(|sequent| matches!(sequent, Sequent::SingleImplication { .. })) + ); + assert!(!SequentMap::pair_cannot_produce_sequents( + db, + &env, + &mut storage, + left, + right + )); + } + } + + #[test] + fn constraint_implications_are_cached() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let t = create_typevar(db, "T"); + let builder = ConstraintSetBuilder::new(); + let mut storage = builder.storage.borrow_mut(); + let t_int = ConstraintId::new( + db, + &env, + &mut storage, + t, + Type::Never, + KnownClass::Int.to_instance(db, &env), + ); + let t_bool = ConstraintId::new( + db, + &env, + &mut storage, + t, + Type::Never, + KnownClass::Bool.to_instance(db, &env), + ); + + assert!(storage.cached_constraint_implies(db, &env, t_bool, t_int)); + assert!(storage.cached_constraint_implies(db, &env, t_bool, t_int)); + drop(storage); + + { + let storage = builder.storage.borrow(); + assert_eq!( + storage.constraint_implication_cache.get(&(t_bool, t_int)), + Some(&true) + ); + assert_eq!(storage.constraint_implication_cache.len(), 1); + } + + let mut storage = builder.storage.borrow_mut(); + assert!(!storage.cached_constraint_implies(db, &env, t_int, t_bool)); + assert!(!storage.cached_constraint_implies(db, &env, t_int, t_bool)); + drop(storage); + + let storage = builder.storage.borrow(); + assert_eq!( + storage.constraint_implication_cache.get(&(t_int, t_bool)), + Some(&false) + ); + assert_eq!(storage.constraint_implication_cache.len(), 2); + } +} diff --git a/crates/ty_python_semantic/src/types/constraints/solutions.rs b/crates/ty_python_semantic/src/types/constraints/solutions.rs new file mode 100644 index 0000000000..6c81063bb4 --- /dev/null +++ b/crates/ty_python_semantic/src/types/constraints/solutions.rs @@ -0,0 +1,147 @@ +use std::marker::PhantomData; +use std::ops::ControlFlow; + +use crate::types::constraints::paths::PathAssignments; +use crate::types::constraints::{ + ALWAYS_FALSE, ALWAYS_TRUE, ConstraintBoundsBuilder, ConstraintId, ConstraintSetStorage, NodeId, + PathBounds, SolutionLimits, +}; +use crate::types::{BoundTypeVarInstance, Type}; +use crate::{Db, FxIndexMap, FxIndexSet, ProgramEnvironment}; + +pub(super) struct SolutionWalker<'db> { + source_orders: FxIndexSet, + sorted_paths: Vec>, + _phantom: PhantomData<&'db ()>, +} + +impl<'db> SolutionWalker<'db> { + pub(super) fn new(source_orders: FxIndexSet) -> Self { + Self { + source_orders, + sorted_paths: Vec::default(), + _phantom: PhantomData, + } + } + + pub(super) fn visit_node( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + path: &mut PathAssignments, + node: NodeId, + limits: &mut L, + ) -> ControlFlow { + limits.visit_node()?; + if node == ALWAYS_FALSE { + return ControlFlow::Continue(()); + } + + // If the current node is ALWAYS_TRUE, we can immediately report the current solution. + if node == ALWAYS_TRUE { + limits.satisfied_path()?; + self.found_satisfied_path(path); + return ControlFlow::Continue(()); + } + + // At this point we actually have to walk the outgoing edges of this node. + let interior = storage.interior_node_data(node); + let constraint = interior.constraint; + for (assignment, child) in [ + (constraint.when_true(), interior.if_true), + (constraint.when_unconstrained(), interior.if_uncertain), + (constraint.when_false(), interior.if_false), + ] { + path.walk_edge( + db, + env, + storage, + assignment, + |storage, path, _new_range, found_conflict| { + if !found_conflict { + self.visit_node(db, env, storage, path, child, limits)?; + } + ControlFlow::Continue(()) + }, + )?; + } + ControlFlow::Continue(()) + } + + fn found_satisfied_path(&mut self, path: &PathAssignments) { + let mut path: Vec<_> = path + .positive_constraints() + .map(|(constraint, source_constraint)| { + let source_order = self + .source_orders + .get_index_of(&source_constraint) + .expect("every TDD constraint should have a source order"); + (constraint, source_order) + }) + .collect(); + // Sort the constraints in each path by their `source_order`s, to ensure that we construct + // any unions or intersections in our type mappings in a stable order. Constraints might + // come out of `PathAssignments` with identical `source_order`s, but if they do, those + // "tied" constraints will still be ordered in a stable way. So we need a stable sort to + // retain that stable per-tie ordering. + path.sort_by_key(|(_, source_order)| *source_order); + self.sorted_paths.push(path); + } + + pub(super) fn finish( + mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + storage: &mut ConstraintSetStorage<'db>, + ) -> PathBounds<'db> { + if self.sorted_paths.is_empty() { + return PathBounds::Unsatisfiable; + } + + self.sorted_paths.sort_by(|path1, path2| { + let source_orders1 = path1.iter().map(|(_, source_order)| *source_order); + let source_orders2 = path2.iter().map(|(_, source_order)| *source_order); + source_orders1.cmp(source_orders2) + }); + + let mut result = Vec::with_capacity(self.sorted_paths.len()); + let mut mappings: FxIndexMap, ConstraintBoundsBuilder<'db>> = + FxIndexMap::default(); + + for path in self.sorted_paths { + mappings.clear(); + for (constraint, _) in path { + let constraint = storage.constraint_data(constraint); + let typevar = constraint.typevar; + if let Some(lower) = constraint.stored_lower_bound() { + let bounds = mappings.entry(typevar).or_default(); + bounds.add_lower(db, env, lower); + + if let Type::TypeVar(lower_bound_typevar) = lower.ty() { + let bounds = mappings.entry(lower_bound_typevar).or_default(); + bounds.add_upper(db, env, lower.with_type(Type::TypeVar(typevar))); + } + } + + if let Some(upper) = constraint.stored_upper_bound() { + let bounds = mappings.entry(typevar).or_default(); + bounds.add_upper(db, env, upper); + + if let Type::TypeVar(upper_bound_typevar) = upper.ty() { + let bounds = mappings.entry(upper_bound_typevar).or_default(); + bounds.add_lower(db, env, upper.with_type(Type::TypeVar(typevar))); + } + } + } + + let path_bounds = mappings + .drain(..) + .map(|(bound_typevar, bounds)| bounds.finish(db, env, bound_typevar)) + .collect(); + result.push(path_bounds); + } + + PathBounds::Constrained(result.into_boxed_slice()) + } +} diff --git a/crates/ty_python_semantic/src/types/constraints/support.rs b/crates/ty_python_semantic/src/types/constraints/support.rs index 5b9a09ce67..07a8c046b9 100644 --- a/crates/ty_python_semantic/src/types/constraints/support.rs +++ b/crates/ty_python_semantic/src/types/constraints/support.rs @@ -20,6 +20,7 @@ pub(super) struct SupportId; #[derive(Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct Support { chunks: SmallVec<[usize; 2]>, + has_skipped_lazy_attributes: bool, } const CHUNK_SIZE: usize = usize::BITS as usize; @@ -64,6 +65,21 @@ impl Support { }) }) } + + /// Returns whether this support contains any type variables in common with `other`. + pub(super) fn overlaps_with(&self, other: &Self) -> bool { + std::iter::zip(&self.chunks, &other.chunks).any(|(lhs, rhs)| (*lhs & *rhs) != 0) + } + + /// Records that lazy type attributes may contain additional type variables. + pub(super) fn mark_incomplete(&mut self) { + self.has_skipped_lazy_attributes = true; + } + + /// Returns whether all type attributes were inspected while collecting this support. + pub(super) fn is_complete(&self) -> bool { + !self.has_skipped_lazy_attributes + } } impl BitOrAssign<&Self> for Support { @@ -74,6 +90,7 @@ impl BitOrAssign<&Self> for Support { for (lhs, rhs) in std::iter::zip(&mut self.chunks, &rhs.chunks) { *lhs |= *rhs; } + self.has_skipped_lazy_attributes |= rhs.has_skipped_lazy_attributes; } } diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index c47d4297f6..bbe77164ad 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -47,7 +47,7 @@ impl<'db> ProgramEnvironment<'db> { } /// Creates an environment that lazily obtains its program from `definition`. - pub fn from_definition(definition: Definition<'db>) -> Self { + pub(crate) fn from_definition(definition: Definition<'db>) -> Self { Self { environment: Cell::new(ProgramSource::Definition(definition.as_id())), lifetime: PhantomData, @@ -55,7 +55,7 @@ impl<'db> ProgramEnvironment<'db> { } /// Creates an environment that lazily obtains its program from `scope`. - pub fn from_scope(scope: ScopeId<'db>) -> Self { + pub(crate) fn from_scope(scope: ScopeId<'db>) -> Self { Self { environment: Cell::new(ProgramSource::Scope(scope.as_id())), lifetime: PhantomData, @@ -102,13 +102,13 @@ impl<'db> ProgramEnvironment<'db> { /// Returns the Python version used by this operation. #[inline] - pub fn python_version(&self, db: &'db dyn Db) -> PythonVersion { + pub(crate) fn python_version(&self, db: &'db dyn Db) -> PythonVersion { self.program(db).python_version(db) } /// Returns the resolver environment used by this operation. #[inline] - pub fn resolver_environment(&self, db: &'db dyn Db) -> ResolverEnvironment<'db> { + pub(crate) fn resolver_environment(&self, db: &'db dyn Db) -> ResolverEnvironment<'db> { self.program(db).resolver_environment(db) } } @@ -185,7 +185,7 @@ impl<'db, 'ast> InferContext<'db, 'ast> { self.file } - pub(crate) fn python_file(&self) -> PythonFile<'db> { + fn python_file(&self) -> PythonFile<'db> { self.program_file.python_file(self.db()) } @@ -527,11 +527,14 @@ impl Drop for LintDiagnosticGuard<'_, '_> { LintSource::File => { format!("rule `{rule}` was selected in the configuration file") } + LintSource::ScriptMetadata => { + format!("rule `{rule}` was selected in script metadata") + } LintSource::Editor => { format!("rule `{rule}` was selected in the editor settings") } - LintSource::UvWorkspace => { - format!("rule `{rule}` was selected by uv workspace metadata") + LintSource::UvMetadata => { + format!("rule `{rule}` was selected by uv metadata") } }); } diff --git a/crates/ty_python_semantic/src/types/context_manager.rs b/crates/ty_python_semantic/src/types/context_manager.rs index c4b9071837..dd24079502 100644 --- a/crates/ty_python_semantic/src/types/context_manager.rs +++ b/crates/ty_python_semantic/src/types/context_manager.rs @@ -1,16 +1,154 @@ use crate::Db; use crate::ProgramEnvironment; use crate::{ - FxOrderSet, + FxOrderSet, Program, types::{ - Bindings, CallArguments, CallDunderError, Type, TypeContext, call::CallErrorKind, - context::InferContext, diagnostic::INVALID_CONTEXT_MANAGER, + Bindings, CallArguments, CallDunderError, KnownClass, MemberLookupPolicy, Type, + TypeContext, call::CallErrorKind, context::InferContext, + diagnostic::INVALID_CONTEXT_MANAGER, }, }; use ruff_python_ast as ast; use ty_python_core::EvaluationMode; impl<'db> Type<'db> { + /// Returns whether this context manager can suppress an exception raised inside its suite. + /// + /// Following the [typing specification], only exit methods returning exactly `bool` or + /// `Literal[True]` are considered suppressing; `bool | None` and `Any` are not. This + /// intentionally differs from runtime truthiness: non-suppressing context managers are + /// commonly annotated as returning `bool | None`, so treating every potentially truthy return + /// type as suppressing would incorrectly preserve exception paths for ordinary managers. + /// Asynchronous exit results are awaited before applying this rule. + /// + /// [typing specification]: https://typing.python.org/en/latest/spec/exceptions.html#context-managers + /// + /// Suppression is cached by manager type because the same predicate can be evaluated repeatedly + /// for different bindings and context managers. Each alternative in a union is classified + /// separately: if any possible manager can suppress exceptions, the union can suppress + /// exceptions too. Exceptional-exit overloads are also classified independently. Merging the + /// return types of different manager alternatives or overloads could incorrectly classify a + /// suppressing exit alongside a non-suppressing exit as returning `bool | None`. + /// + /// Python passes `(None, None, None)` to an exit method when a suite completes normally and + /// passes the exception type, value, and traceback when it raises. Consequently, overloads + /// whose first two arguments cannot accept an exception type and instance cannot describe an + /// exceptional exit and must not affect the suppression result: + /// + /// ```python + /// @overload + /// def __exit__(self, typ: None, value: None, tb: None) -> None: ... + /// + /// @overload + /// def __exit__( + /// self, + /// typ: type[BaseException], + /// value: BaseException, + /// tb: TracebackType | None, + /// ) -> Literal[True]: ... + /// ``` + /// + /// This manager can suppress exceptions despite its normal-exit overload returning `None`. + /// Suppression preserves any state from before an operation that raises: + /// + /// ```python + /// from contextlib import suppress + /// + /// value = None + /// with suppress(ValueError): + /// value = int("invalid") + /// reveal_type(value) # int | None + /// ``` + pub(crate) fn can_suppress_exceptions( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + mode: EvaluationMode, + ) -> bool { + #[salsa::tracked( + returns(copy), + cycle_initial = |_, _, _, _, _| false, + heap_size = ruff_memory_usage::heap_size + )] + fn can_suppress_exceptions_impl<'db>( + db: &'db dyn Db, + program: Program<'db>, + manager: Type<'db>, + is_async: bool, + ) -> bool { + if let Some(union) = manager.as_union_like(db) { + return union + .elements(db) + .iter() + .any(|&element| can_suppress_exceptions_impl(db, program, element, is_async)); + } + + let env = ProgramEnvironment::from_program(program); + let method = if is_async { "__aexit__" } else { "__exit__" }; + let Some(callables) = manager + .member_lookup_with_policy( + db, + &env, + method, + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + .ignore_possibly_undefined() + .and_then(|exit| exit.try_upcast_to_callable(db, &env)) + else { + return false; + }; + + let exception_type = KnownClass::BaseException.to_subclass_of(db, &env); + let exception_instance = KnownClass::BaseException.to_instance(db, &env); + for signature in callables + .iter() + .flat_map(|callable| callable.signatures(db)) + { + if signature + .parameters() + .get_positional(0) + .is_some_and(|parameter| { + parameter + .annotated_type() + .is_disjoint_from(db, &env, exception_type) + }) + || signature + .parameters() + .get_positional(1) + .is_some_and(|parameter| { + parameter.annotated_type().is_disjoint_from( + db, + &env, + exception_instance, + ) + }) + { + continue; + } + + let return_type = if is_async { + let Ok(awaited) = signature.return_ty.try_await(db, &env) else { + continue; + }; + awaited + } else { + signature.return_ty + }; + + if return_type.is_equivalent_to(db, &env, KnownClass::Bool.to_instance(db, &env)) + || return_type.is_equivalent_to(db, &env, Type::bool_literal(true)) + { + return true; + } + } + + false + } + + can_suppress_exceptions_impl(db, env.program(db), self, mode.is_async()) + } + /// Returns the type bound from a context manager with type `self`. /// /// This method should only be used outside of type checking because it omits any errors. diff --git a/crates/ty_python_semantic/src/types/conversions.rs b/crates/ty_python_semantic/src/types/conversions.rs index 9ae9924b51..4559689b44 100644 --- a/crates/ty_python_semantic/src/types/conversions.rs +++ b/crates/ty_python_semantic/src/types/conversions.rs @@ -47,11 +47,11 @@ use crate::types::{MemberLookupPolicy, Type, TypeContext}; use ty_module_resolver::ImportingFile; /// the classmethod on a target that converts a value of some other type -pub(crate) const FROM: &str = "__from__"; +const FROM: &str = "__from__"; /// the method on a source that converts it into the type it returns -pub(crate) const INTO: &str = "__into__"; +const INTO: &str = "__into__"; /// the classmethod on a target that converts a *literal* -pub(crate) const OF: &str = "__of__"; +const OF: &str = "__of__"; /// every conversion dunder, for the declaration-site validation pub(crate) const CONVERSION_DUNDERS: [&str; 3] = [FROM, INTO, OF]; @@ -117,11 +117,11 @@ impl<'db> Route<'db> { /// a conversion the checker found for an assignment that would otherwise fail #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConversionRepair<'db> { - pub(crate) route: Route<'db>, + route: Route<'db>, /// every *other* applicable route — an ambiguity the checker reports at the /// site. all of them, not just the runner-up: a site served by three /// conversions should not have to be fixed one report at a time - pub(crate) ambiguous_with: Vec>, + ambiguous_with: Vec>, } /// would an in-scope conversion make `source` assignable to `target`? @@ -451,7 +451,7 @@ fn source_declares_into<'db>( /// the `__from__` / `__of__` declared on `class`, when it is the classmethod the /// lowered call needs -pub(crate) fn conversion_classmethod<'db>( +fn conversion_classmethod<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, class: ClassType<'db>, @@ -470,7 +470,7 @@ pub(crate) fn conversion_classmethod<'db>( /// the `__into__` declared on `class`, when it is the plain instance method the /// lowered `x.__into__()` needs. an overloaded one is rejected: the call carries /// no target, so there would be nothing to dispatch on at runtime -pub(crate) fn conversion_method<'db>( +fn conversion_method<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, class: ClassType<'db>, @@ -542,7 +542,7 @@ pub(crate) fn may_convert<'db>( /// the elements need not be literals (`[1, 2, foo()]` is a list display). a /// comprehension is not one: its contents come from another collection, which is /// the line element-wise conversion is drawn on -pub(crate) fn is_literal_expression(expr: &ast::Expr) -> bool { +fn is_literal_expression(expr: &ast::Expr) -> bool { matches!( expr, ast::Expr::NoneLiteral(_) @@ -772,7 +772,7 @@ pub(crate) fn function_declared_return_type<'db>( /// `None` for anything else — including a literal containing an unpack /// (`[*bs]`, `{**d}`), whose elements come from another collection and so have no /// expression of their own at this site -pub(crate) fn addressable_elements(value: &ast::Expr) -> Option> { +fn addressable_elements(value: &ast::Expr) -> Option> { fn plain(elements: &[ast::Expr]) -> Option> { elements .iter() @@ -799,7 +799,7 @@ pub(crate) fn addressable_elements(value: &ast::Expr) -> Option> /// the type a declared collection's elements must satisfy: a mapping's *value* /// type, else what iterating the declared type yields -pub(crate) fn declared_element_type<'db>( +fn declared_element_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, declared: Type<'db>, @@ -1389,9 +1389,7 @@ fn arity_after_receiver(parameters: &Parameters<'_>) -> (usize, bool) { let rest = || parameters.iter().skip(1); let required = rest() .filter(|parameter| { - parameter.default_type().is_none() - && !parameter.is_variadic() - && !parameter.is_keyword_variadic() + !parameter.has_default() && !parameter.is_variadic() && !parameter.is_keyword_variadic() }) .count(); let takes_positional = diff --git a/crates/ty_python_semantic/src/types/cyclic.rs b/crates/ty_python_semantic/src/types/cyclic.rs index 38a8a066d9..ac94375c32 100644 --- a/crates/ty_python_semantic/src/types/cyclic.rs +++ b/crates/ty_python_semantic/src/types/cyclic.rs @@ -22,6 +22,7 @@ use std::cell::{Cell, OnceCell, RefCell}; use std::cmp::Eq; +use std::collections::hash_map::Entry; use std::fmt; use std::hash::Hash; use std::marker::PhantomData; @@ -32,9 +33,12 @@ use smallvec::SmallVec; use ty_python_core::definition::Definition; use crate::types::function::FunctionLiteral; -use crate::types::generics::Specialization; +use crate::types::generics::{GenericContext, Specialization}; use crate::types::visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}; -use crate::types::{ClassType, ProtocolInstanceType, Type, TypeAliasType, TypedDictType}; +use crate::types::{ + BoundTypeVarIdentity, BoundTypeVarInstance, ProtocolInstanceType, StaticClassLiteral, Type, + TypeAliasType, TypedDictType, +}; use crate::{Db, ProgramEnvironment}; /// The type identity used for recursive checks/transformations. @@ -42,16 +46,16 @@ use crate::{Db, ProgramEnvironment}; pub enum TypeIdentity<'db> { FunctionLiteral(FunctionLiteral<'db>), NewTypeInstance(Definition<'db>), - RecursiveProtocol(Definition<'db>), - RecursiveTypeAlias(Definition<'db>), - RecursiveTypedDict(Definition<'db>), - NonRecursive(Type<'db>), + GrowingProtocol(Definition<'db>), + GrowingTypeAlias(Definition<'db>), + GrowingTypedDict(Definition<'db>), + Other(Type<'db>), } impl<'db> Type<'db> { pub(crate) fn to_type_identity(self, db: &'db dyn Db) -> TypeIdentity<'db> { self.recursive_identity(db) - .unwrap_or(TypeIdentity::NonRecursive(self)) + .unwrap_or(TypeIdentity::Other(self)) } /// Returns `false` if `self` and `other` cannot have the same [`TypeIdentity`]. @@ -89,187 +93,669 @@ impl<'db> Type<'db> { Type::NewTypeInstance(newtype) => { Some(TypeIdentity::NewTypeInstance(newtype.definition(db))) } - // Type aliases can be self-referential: e.g. `type RecursiveT = int | tuple[RecursiveT, ...]` - Type::TypeAlias(alias) if alias.is_recursive(db) => { - Some(TypeIdentity::RecursiveTypeAlias(alias.definition(db))) - } - Type::ProtocolInstance(protocol) if protocol.is_recursive(db) => { - Some(TypeIdentity::RecursiveProtocol(protocol.definition(db)?)) - } - Type::TypedDict(typed_dict) if typed_dict.is_recursive(db) => { - let definition = typed_dict.definition(db)?; - Some(TypeIdentity::RecursiveTypedDict(definition)) + // Recursive aliases, protocols, and TypedDicts whose specialization can keep changing + // (e.g. `type Growing[T] = T | Growing[list[T]]`) are collapsed to their definition so + // that visits stop even though no exact type repeats. Recursion that revisits one + // exact specialization (e.g. `type RecursiveT = int | tuple[RecursiveT, ...]`) needs + // no definition-level identity: the detectors stop on the repeated type itself. + Type::TypeAlias(_) | Type::ProtocolInstance(_) | Type::TypedDict(_) => { + let target = RecursiveDefinition::from_type(db, self)?.target; + if !target.may_have_unbounded_specialization(db) { + return None; + } + let definition = target.definition(db); + Some(match target { + RecursiveDefinition::TypeAlias(_) => TypeIdentity::GrowingTypeAlias(definition), + RecursiveDefinition::Protocol(_) => TypeIdentity::GrowingProtocol(definition), + RecursiveDefinition::TypedDict(_) => TypeIdentity::GrowingTypedDict(definition), + }) } _ => None, } } } -struct DefinitionReferenceVisitor<'db> { +/// A definition whose formal parameters can flow through recursive type references. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +enum RecursiveDefinition<'db> { + TypeAlias(TypeAliasType<'db>), + Protocol(StaticClassLiteral<'db>), + TypedDict(StaticClassLiteral<'db>), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum FlowKind { + /// The source parameter is passed directly or only through normalized set operations. + Direct, + /// The source parameter occurs inside a type structure that can accumulate. + Nested, +} + +/// Formal parameters are identified by their [`BoundTypeVarIdentity`], which is unique across +/// definitions, so parameter identities can serve directly as the flow graph's nodes. +/// e.g. +/// +/// definition: +/// ```py +/// type A[A1, A2] = B[A2, A1] +/// type B[B1, B2] = None +/// ``` +/// produces the flow graph: +/// ```ignore +/// FlowEdge { +/// from: A::A2, +/// to: B::B1, +/// kind: FlowKind::Direct, +/// } +/// FlowEdge { +/// from: A::A1, +/// to: B::B2, +/// kind: FlowKind::Direct, +/// } +/// ``` +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct FlowEdge<'db> { + from: BoundTypeVarIdentity<'db>, + to: BoundTypeVarIdentity<'db>, + kind: FlowKind, +} + +#[derive(Clone, Copy)] +struct DefinitionUse<'db> { + target: RecursiveDefinition<'db>, + specialization: Option>, +} + +/// Parameter flow between all recursive definitions reachable from one root. +/// +/// Whether a recursive definition can keep producing new specializations is modeled as a graph +/// problem. The formal parameters of every reachable definition are the nodes, and each argument +/// of a recursive reference adds an edge to the parameter it specializes from every source +/// parameter occurring in it: [`FlowKind::Direct`] if the parameter is passed as is, and +/// [`FlowKind::Nested`] if it occurs inside a type structure that can accumulate. Arguments +/// without source parameters add no edges and act as resets. +/// +/// Each expansion step moves the argument types along these edges, so the root's specialization +/// can grow without bound only if a directed cycle through one of the root's parameters contains +/// a nested edge: +/// +/// ```py +/// type Growing[X, Y] = Growing[list[Y], X] # Y -> X nested, X -> Y direct: growing cycle +/// type Shifting[A, B, C] = Shifting[B, C, None] # direct edges only: repeats after 3 steps +/// type Resetting[X, Y] = Resetting[list[Y], None] # Y -> X nested, but X flows nowhere: no cycle +/// type GrowingOuter[T] = GrowingHelper[list[T]] # T -> U nested +/// type GrowingHelper[U] = GrowingOuter[U] # U -> T direct: the growing cycle spans both +/// type ResetOuter[T] = ResetHelper[list[T]] # T -> U nested +/// type ResetHelper[U] = ResetOuter[int] # parameter-free argument: no edge, no cycle +/// ``` +/// +/// Without such a cycle, every parameter's value stays within the finite set of types built from +/// the initial arguments and reset types by normalized set operations, so the expansions reach an +/// exact repetition and the cycle detectors can rely on exact type identities. +#[derive(Default)] +struct SpecializationFlowGraph<'db> { + edges: FxHashSet>, + /// Definition references used to decide whether an unresolved flow can return to the root. + definition_edges: Vec<(Definition<'db>, Definition<'db>)>, + /// Definitions whose captured outer parameters cannot be mapped to their parent specialization. + inconclusive_definitions: FxHashSet>, + /// Whether a definition body or its formal parameters could not be inspected. + inconclusive: bool, +} + +/// Walks one identity-specialized definition body and records references as graph edges. +/// +/// Referenced definitions are queued for a separate walk instead of being expanded here. +struct SpecializationFlowVisitor<'db> { + source_parameters: FxHashSet>, env: ProgramEnvironment<'db>, - target: Definition<'db>, - active_definitions: ActiveRecursionDetector>, visited_types: TypeCollector<'db>, - found: Cell, + edges: RefCell>>, + referenced_definitions: RefCell>>, + inconclusive: Cell, } -impl<'db> DefinitionReferenceVisitor<'db> { - /// Returns whether the definition represented by `ty` references `target`. - fn references(db: &'db dyn Db, ty: Type<'db>, target: Definition<'db>) -> bool { - let visitor = Self::new(target); - visitor.visit_definition_body(db, ty); - visitor.found.get() - } +/// Finds which parameters of the current source definition occur in one actual argument. +struct SourceParameterCollector<'a, 'db> { + source_parameters: &'a FxHashSet>, + env: &'a ProgramEnvironment<'db>, + found: RefCell, bool>>, + visited_types: TypeCollector<'db>, + in_nested_type: Cell, +} - fn new(target: Definition<'db>) -> Self { - Self { - env: ProgramEnvironment::from_definition(target), +impl<'db> RecursiveDefinition<'db> { + fn from_type(db: &'db dyn Db, ty: Type<'db>) -> Option> { + let (target, specialization) = match ty { + Type::TypeAlias(alias) => ( + Self::TypeAlias(alias.unspecialized(db)), + alias.specialization(db), + ), + Type::ProtocolInstance(protocol) => { + let (origin, specialization) = + protocol.class_origin(db)?.static_class_literal(db)?; + (Self::Protocol(origin), specialization) + } + Type::TypedDict(typed_dict) => { + let (origin, specialization) = + typed_dict.defining_class()?.static_class_literal(db)?; + (Self::TypedDict(origin), specialization) + } + _ => return None, + }; + + let specialization = match target.generic_context(db) { + Some(generic_context) => Some( + specialization + .unwrap_or_else(|| target.default_specialization(db, generic_context)), + ), + None => specialization, + }; + Some(DefinitionUse { target, - active_definitions: ActiveRecursionDetector::default(), - visited_types: TypeCollector::default(), - found: Cell::new(false), + specialization, + }) + } + + fn definition(self, db: &'db dyn Db) -> Definition<'db> { + match self { + Self::TypeAlias(alias) => alias.definition(db), + Self::Protocol(origin) | Self::TypedDict(origin) => origin.definition(db), } } - fn definition_and_specialization( - db: &'db dyn Db, - ty: Type<'db>, - ) -> Option<(Definition<'db>, Option>)> { - if let Type::TypeAlias(alias) = ty { - return Some((alias.definition(db), alias.specialization(db))); + fn generic_context(self, db: &'db dyn Db) -> Option> { + match self { + Self::TypeAlias(alias) => alias.generic_context(db), + Self::Protocol(origin) | Self::TypedDict(origin) => origin.generic_context(db), } + } - let class = match ty { - Type::ProtocolInstance(protocol) => *protocol.class_origin(db)?, - Type::TypedDict(typed_dict) => typed_dict.defining_class()?, - _ => return None, + fn default_specialization( + self, + db: &'db dyn Db, + generic_context: GenericContext<'db>, + ) -> Specialization<'db> { + let known_class = match self { + Self::TypeAlias(_) => None, + Self::Protocol(origin) | Self::TypedDict(origin) => origin.known(db), }; - let definition = class.definition(db)?; - let specialization = class - .into_generic_alias() - .map(|generic| generic.specialization(db)); - Some((definition, specialization)) + generic_context.default_specialization(db, known_class) } - fn visit_specialization(&self, db: &'db dyn Db, specialization: Specialization<'db>) { - for ty in specialization.types(db) { - self.visit_type(db, *ty); + fn parameter_identity( + db: &'db dyn Db, + parameter: BoundTypeVarInstance<'db>, + ) -> BoundTypeVarIdentity<'db> { + let identity = parameter.identity(db); + if identity.is_paramspec(db) { + identity.without_paramspec_attr(db) + } else { + identity } } - fn visit_definition_body(&self, db: &'db dyn Db, ty: Type<'db>) { - match ty { - Type::TypeAlias(alias) => self.visit_type_alias_type(db, alias), - Type::ProtocolInstance(protocol) => { - self.visit_protocol_instance_type(db, protocol); + /// The identities of this definition's formal parameters, in declaration order. + fn parameters(self, db: &'db dyn Db) -> impl Iterator> { + self.generic_context(db) + .into_iter() + .flat_map(|context| context.variables(db)) + .map(move |parameter| Self::parameter_identity(db, parameter)) + } + + /// Returns `None` if two formal parameters share an identity, since their flows could not be + /// distinguished. + fn source_parameters(self, db: &'db dyn Db) -> Option>> { + let mut parameters = FxHashSet::default(); + for identity in self.parameters(db) { + if !parameters.insert(identity) { + return None; } - Type::TypedDict(typed_dict) => self.visit_typed_dict_type(db, typed_dict), - _ => {} } + Some(parameters) + } + + fn may_have_unbounded_specialization(self, db: &'db dyn Db) -> bool { + #[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _, ()| true, + heap_size=ruff_memory_usage::heap_size, + )] + fn may_have_unbounded_specialization_inner<'db>( + db: &'db dyn Db, + root: RecursiveDefinition<'db>, + _: (), + ) -> bool { + let graph = SpecializationFlowGraph::build(db, root); + graph.root_may_have_unbounded_specialization(db, root) + } + + may_have_unbounded_specialization_inner(db, self, ()) } } -impl<'db> TypeVisitor<'db> for DefinitionReferenceVisitor<'db> { - fn program_environment(&self) -> &ProgramEnvironment<'db> { - &self.env +impl<'db> DefinitionUse<'db> { + fn walk_arguments(self, db: &'db dyn Db, visitor: &impl TypeVisitor<'db>) { + if let Some(specialization) = self.specialization { + for argument in specialization.types(db) { + visitor.visit_type(db, *argument); + } + } } +} - fn should_visit_lazy_type_attributes(&self) -> bool { - false +impl<'db> SpecializationFlowGraph<'db> { + fn build(db: &'db dyn Db, root: RecursiveDefinition<'db>) -> Self { + let mut graph = Self::default(); + let mut pending = vec![root]; + let mut visited = FxHashSet::default(); + + while let Some(source) = pending.pop() { + let source_definition = source.definition(db); + if !visited.insert(source_definition) { + continue; + } + let Some(visitor) = SpecializationFlowVisitor::new(db, source) else { + graph.inconclusive = true; + continue; + }; + if !visitor.visit_definition_body(db, source) { + graph.inconclusive = true; + } + let (edges, referenced_definitions, inconclusive) = visitor.finish(); + graph.edges.extend(edges); + if inconclusive { + graph.inconclusive_definitions.insert(source_definition); + } + graph.definition_edges.extend( + referenced_definitions + .iter() + .map(|target| (source_definition, target.definition(db))), + ); + pending.extend(referenced_definitions); + } + graph } - fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { - if self.found.get() { - return; + fn root_may_have_unbounded_specialization( + &self, + db: &'db dyn Db, + root: RecursiveDefinition<'db>, + ) -> bool { + if self.inconclusive { + return true; } - if let Some((definition, specialization)) = Self::definition_and_specialization(db, ty) { - if definition == self.target { - self.found.set(true); - return; + let root_definition = root.definition(db); + if self.inconclusive_definition_reaches(root_definition) { + return true; + } + + if !self.edges.iter().any(|edge| edge.kind == FlowKind::Nested) { + return false; + } + + let root_parameters = root.parameters(db).collect::>(); + let components = + self.strongly_connected_parameter_components(root_parameters.iter().copied()); + let root_components = root_parameters + .iter() + .filter_map(|parameter| components.get(parameter).copied()) + .collect::>(); + + self.edges.iter().any(|edge| { + if edge.kind != FlowKind::Nested { + return false; + } + components + .get(&edge.from) + .zip(components.get(&edge.to)) + .is_some_and(|(from_component, to_component)| { + // True if this edge is inside the SCC (a nested cycle is formed). + from_component == to_component + // Only a nested cycle containing a root parameter can grow the root's + // specialization. Helper-only cycles are handled when visiting the helper. + && root_components.contains(from_component) + }) + }) + } + + /// Assigns each parameter to its strongly connected component. + /// This function returns a map from typevar to the index of the SCC to which it belongs. + /// This means that typevars with the same index belong to the same SCC. + fn strongly_connected_parameter_components( + &self, + additional_parameters: impl IntoIterator>, + ) -> FxHashMap, usize> { + let mut parameters = additional_parameters.into_iter().collect::>(); + let mut outgoing = FxHashMap::<_, SmallVec<[_; 2]>>::default(); + let mut incoming = FxHashMap::<_, SmallVec<[_; 2]>>::default(); + #[expect( + clippy::iter_over_hash_type, + reason = "component membership is independent of traversal order" + )] + for edge in &self.edges { + parameters.insert(edge.from); + parameters.insert(edge.to); + outgoing.entry(edge.from).or_default().push(edge.to); + incoming.entry(edge.to).or_default().push(edge.from); + } + + let mut visited = FxHashSet::default(); + let mut finishing_order = Vec::with_capacity(parameters.len()); + #[expect( + clippy::iter_over_hash_type, + reason = "component membership is independent of traversal order" + )] + for start in parameters { + if !visited.insert(start) { + continue; } - if let Some(specialization) = specialization { - self.visit_specialization(db, specialization); + let mut pending = vec![(start, 0)]; + while let Some((current, next_index)) = pending.pop() { + let next = outgoing + .get(¤t) + .and_then(|parameters| parameters.get(next_index)) + .copied(); + if let Some(next) = next { + pending.push((current, next_index + 1)); + if visited.insert(next) { + pending.push((next, 0)); + } + } else { + finishing_order.push(current); + } } + } - if !self.found.get() { - self.active_definitions.visit( - &definition, - || {}, - || self.visit_definition_body(db, ty), - ); + let mut components = FxHashMap::default(); + for start in finishing_order.into_iter().rev() { + if components.contains_key(&start) { + continue; + } + + let component = components.len(); + components.insert(start, component); + let mut pending = vec![start]; + while let Some(current) = pending.pop() { + if let Some(previous_parameters) = incoming.get(¤t) { + for &previous in previous_parameters { + if let Entry::Vacant(entry) = components.entry(previous) { + entry.insert(component); + pending.push(previous); + } + } + } } - } else { - walk_type_with_recursion_guard(db, ty, self, &self.visited_types); } + components } - fn visit_protocol_instance_type(&self, db: &'db dyn Db, protocol: ProtocolInstanceType<'db>) { - if let Some(class) = protocol.class_origin(db) { - class.walk_recursive_member_types(db, self); + fn inconclusive_definition_reaches(&self, target: Definition<'db>) -> bool { + if self.inconclusive_definitions.is_empty() { + return false; } + + let definitions_reaching_target = self.definitions_reaching(target); + !self + .inconclusive_definitions + .is_disjoint(&definitions_reaching_target) } - fn visit_type_alias_type(&self, db: &'db dyn Db, alias: TypeAliasType<'db>) { - self.visit_type(db, alias.raw_value_type(db)); + fn definition_reaches(&self, from: Definition<'db>, to: Definition<'db>) -> bool { + self.definitions_reaching(to).contains(&from) } - fn visit_typed_dict_type(&self, db: &'db dyn Db, typed_dict: TypedDictType<'db>) { - for field in typed_dict.items(db).values() { - self.visit_type(db, field.declared_ty); + fn definitions_reaching(&self, target: Definition<'db>) -> FxHashSet> { + let mut incoming = FxHashMap::>::default(); + for &(source, target) in &self.definition_edges { + incoming.entry(target).or_default().push(source); + } + + // Start from predecessors rather than the target itself so the result contains only + // definitions with a non-empty path to the target. The target itself is included only if + // it belongs to a cycle. + let mut pending = Vec::new(); + if let Some(sources) = incoming.get(&target) { + pending.extend(sources.iter().copied()); } - if let Some(extra_items) = typed_dict.explicit_extra_items(db) { - self.visit_type(db, extra_items.declared_ty); + let mut visited = FxHashSet::default(); + while let Some(current) = pending.pop() { + if !visited.insert(current) { + continue; + } + if let Some(sources) = incoming.get(¤t) { + pending.extend(sources.iter().copied()); + } } + visited } } -impl<'db> TypeAliasType<'db> { - fn is_recursive(self, db: &'db dyn Db) -> bool { - DefinitionReferenceVisitor::references( - db, - Type::TypeAlias(self.unspecialized(db)), - self.definition(db), +impl<'db> SpecializationFlowVisitor<'db> { + fn new(db: &'db dyn Db, source: RecursiveDefinition<'db>) -> Option { + Some(Self { + source_parameters: source.source_parameters(db)?, + env: ProgramEnvironment::from_definition(source.definition(db)), + visited_types: TypeCollector::default(), + edges: RefCell::default(), + referenced_definitions: RefCell::default(), + inconclusive: Cell::default(), + }) + } + + fn finish(self) -> (Vec>, Vec>, bool) { + ( + self.edges.into_inner(), + self.referenced_definitions.into_inner(), + self.inconclusive.get(), ) } -} -impl<'db> ProtocolInstanceType<'db> { - fn definition(self, db: &'db dyn Db) -> Option> { - let (origin, _) = self.class_origin(db)?.static_class_literal(db)?; - Some(origin.definition(db)) + /// Visits the definition with each formal parameter mapped to itself. + fn visit_definition_body(&self, db: &'db dyn Db, source: RecursiveDefinition<'db>) -> bool { + match source { + RecursiveDefinition::TypeAlias(alias) => { + self.visit_type(db, alias.raw_value_type(db)); + } + RecursiveDefinition::Protocol(origin) => { + let Some(protocol) = origin.identity_specialization(db).into_protocol_class(db) + else { + return false; + }; + protocol.walk_recursive_member_types(db, self); + } + RecursiveDefinition::TypedDict(origin) => { + let typed_dict = TypedDictType::new(origin.identity_specialization(db)); + for field in typed_dict.items(db).values() { + self.visit_type(db, field.declared_ty); + } + if let Some(extra_items) = typed_dict.explicit_extra_items(db) { + self.visit_type(db, extra_items.declared_ty); + } + } + } + true } - fn is_recursive(self, db: &'db dyn Db) -> bool { - let Some(class) = self.class_origin(db) else { - return false; + fn record_reference(&self, db: &'db dyn Db, reference: DefinitionUse<'db>) { + self.referenced_definitions + .borrow_mut() + .push(reference.target); + + let Some(target_context) = reference.target.generic_context(db) else { + if reference.specialization.is_some() { + self.inconclusive.set(true); + } + return; }; - let Some((origin, _)) = class.static_class_literal(db) else { - return false; + let Some(specialization) = reference.specialization else { + self.inconclusive.set(true); + return; }; - let definition = origin.definition(db); - let env = ProgramEnvironment::from_definition(definition); - // Inspect the definition without its current specialization. Otherwise, a finite - // type such as `Protocol[Protocol[int]]` would appear recursive. - let unspecialized = Type::instance(db, &env, ClassType::NonGeneric(origin.into())); - DefinitionReferenceVisitor::references(db, unspecialized, definition) + if specialization.generic_context(db) != target_context { + self.inconclusive.set(true); + return; + } + + let target_parameters = reference.target.parameters(db).collect::>(); + let arguments = specialization.types(db); + if target_parameters.len() != arguments.len() { + self.inconclusive.set(true); + return; + } + + for (target, argument) in target_parameters.into_iter().zip(arguments.iter().copied()) { + for (from, kind) in + SourceParameterCollector::classify(db, &self.env, &self.source_parameters, argument) + { + self.edges.borrow_mut().push(FlowEdge { + from, + to: target, + kind, + }); + } + } } } -impl<'db> TypedDictType<'db> { - fn is_recursive(self, db: &'db dyn Db) -> bool { - let Some(class) = self.defining_class() else { - return false; - }; - let Some((origin, _)) = class.static_class_literal(db) else { - return false; +impl<'db> TypeVisitor<'db> for SpecializationFlowVisitor<'db> { + fn program_environment(&self) -> &ProgramEnvironment<'db> { + &self.env + } + + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if let Type::TypeVar(typevar) = ty { + let identity = RecursiveDefinition::parameter_identity(db, typevar); + if !self.source_parameters.contains(&identity) { + // Nested definitions can capture a type variable from an outer generic scope. + // Specialization does not yet retain the parent mapping needed to model it. + self.inconclusive.set(true); + } + return; + } + + if let Some(reference) = RecursiveDefinition::from_type(db, ty) { + self.record_reference(db, reference); + reference.walk_arguments(db, self); + return; + } + + walk_type_with_recursion_guard(db, ty, self, &self.visited_types); + } + + fn visit_bound_type_var_type( + &self, + _db: &'db dyn Db, + _bound_typevar: BoundTypeVarInstance<'db>, + ) { + } +} + +impl<'a, 'db> SourceParameterCollector<'a, 'db> { + fn classify( + db: &'db dyn Db, + env: &'a ProgramEnvironment<'db>, + source_parameters: &'a FxHashSet>, + argument: Type<'db>, + ) -> impl Iterator, FlowKind)> { + let collector = Self { + source_parameters, + env, + found: RefCell::default(), + visited_types: TypeCollector::default(), + in_nested_type: Cell::default(), }; - let definition = origin.definition(db); - // Inspect the definition without its current specialization for the same reason as - // protocols above. - let unspecialized = Type::typed_dict(ClassType::NonGeneric(origin.into())); - DefinitionReferenceVisitor::references(db, unspecialized, definition) + collector.visit_type(db, argument); + collector + .found + .into_inner() + .into_iter() + .map(|(parameter, nested)| { + ( + parameter, + if nested { + FlowKind::Nested + } else { + FlowKind::Direct + }, + ) + }) + } +} + +impl<'db> TypeVisitor<'db> for SourceParameterCollector<'_, 'db> { + fn program_environment(&self) -> &ProgramEnvironment<'db> { + self.env + } + + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if let Type::TypeVar(typevar) = ty { + let identity = RecursiveDefinition::parameter_identity(db, typevar); + if self.source_parameters.contains(&identity) { + self.found + .borrow_mut() + .entry(identity) + .and_modify(|nested| *nested |= self.in_nested_type.get()) + .or_insert_with(|| self.in_nested_type.get()); + } + return; + } + + // Unions and intersections are normalized set operations. Reapplying the same operation + // does not add another structural layer to a parameter. + match ty { + Type::Union(union) => { + self.visit_union_type(db, union); + return; + } + Type::Intersection(intersection) => { + self.visit_intersection_type(db, intersection); + return; + } + _ => {} + } + + let was_in_nested_type = self.in_nested_type.replace(true); + if let Some(reference) = RecursiveDefinition::from_type(db, ty) { + reference.walk_arguments(db, self); + } else { + walk_type_with_recursion_guard(db, ty, self, &self.visited_types); + } + self.in_nested_type.set(was_in_nested_type); + } + + fn visit_bound_type_var_type( + &self, + _db: &'db dyn Db, + _bound_typevar: BoundTypeVarInstance<'db>, + ) { + } +} + +impl<'db> TypeAliasType<'db> { + /// Returns whether this alias can refer back to its own definition. + pub(crate) fn is_recursive(self, db: &'db dyn Db) -> bool { + let root = RecursiveDefinition::TypeAlias(self.unspecialized(db)); + let root_definition = root.definition(db); + SpecializationFlowGraph::build(db, root) + .definition_reaches(root_definition, root_definition) + } +} + +impl<'db> ProtocolInstanceType<'db> { + fn definition(self, db: &'db dyn Db) -> Option> { + let (origin, _) = self.class_origin(db)?.static_class_literal(db)?; + Some(origin.definition(db)) } } @@ -387,19 +873,37 @@ where /// /// The caller must convert `Err(item)` into an operation-specific conservative result. An /// exact recursive reentry uses the detector's configured fallback and is returned as `Ok`. + /// + /// Completed results are reused only when `reuse_cached` accepts them. Otherwise, the visit + /// recomputes the result using the same active recursion guards, without replacing the cached + /// value. Results for previously uncached items are memoized as usual. #[inline] pub(super) fn try_visit( &self, db: &'db dyn Db, item: T, + reuse_cached: impl FnOnce(&R) -> bool, compute: impl FnOnce() -> R, ) -> Result { - match self.begin_visit(db, item) { + let cached_result = self.cache.borrow().get(&item).cloned(); + let was_cached = cached_result.is_some(); + if let Some(result) = cached_result + && reuse_cached(&result) + { + return Ok(result); + } + + match self.begin_active_visit(db, item) { CycleDetectorVisit::Ready(result) => Ok(result), CycleDetectorVisit::Cycle(item) => Err(item), CycleDetectorVisit::Pending(item) => { let result = compute(); - Ok(self.finish_visit(item, result)) + if was_cached { + self.finish_active_visit(&item); + Ok(result) + } else { + Ok(self.finish_visit(item, result)) + } } } } @@ -409,6 +913,10 @@ where return CycleDetectorVisit::Ready(result.clone()); } + self.begin_active_visit(db, item) + } + + fn begin_active_visit(&self, db: &'db dyn Db, item: T) -> CycleDetectorVisit { let seen = self.seen.borrow(); if seen.iter().any(|active| active.item == item) { return CycleDetectorVisit::Ready(self.fallback.clone()); @@ -442,13 +950,17 @@ where /// Finish a [`CycleDetectorVisit::Pending`] visit and cache its result. fn finish_visit(&self, item: T, result: R) -> R { - let active = self.seen.borrow_mut().pop(); - debug_assert!(active.as_ref().is_some_and(|active| active.item == item)); + self.finish_active_visit(&item); self.cache .borrow_mut() .insert_completed(item, result.clone()); result } + + fn finish_active_visit(&self, item: &T) { + let active = self.seen.borrow_mut().pop(); + debug_assert!(active.as_ref().is_some_and(|active| active.item == *item)); + } } struct ActiveCycleDetectorVisit<'db, T: HasIdentity<'db>> { @@ -651,6 +1163,10 @@ impl Default for ActiveRecursionDetector { } impl ActiveRecursionDetector { + pub(crate) fn is_empty(&self) -> bool { + self.seen.borrow().is_empty() + } + pub(crate) fn visit( &self, item: &T, @@ -685,11 +1201,16 @@ impl Drop for ActiveRecursionGuard<'_, T> { #[cfg(test)] mod tests { - use super::{CycleDetector, CycleDetectorVisit, Db, HasIdentity, TypeIdentity}; + use std::assert_matches; + + use super::{ + CycleDetector, CycleDetectorVisit, Db, FlowEdge, FlowKind, HasIdentity, + RecursiveDefinition, SpecializationFlowGraph, TypeIdentity, + }; use crate::ProgramEnvironment; use crate::db::tests::setup_db; use crate::place::global_symbol; - use crate::types::Type; + use crate::types::{KnownInstanceType, Type, TypeAliasType}; use ruff_db::files::system_path_to_file; use ruff_db::system::DbWithWritableSystem; use std::cell::Cell; @@ -750,7 +1271,7 @@ mod tests { } } - #[derive(Clone, Eq, Hash, PartialEq)] + #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct ConstantIdentityItem(u8); impl<'db> HasIdentity<'db> for ConstantIdentityItem { @@ -773,6 +1294,255 @@ mod tests { .unwrap() } + fn global_type_alias<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + name: &str, + ) -> TypeAliasType<'db> { + let file = system_path_to_file(db, "/src/a.py").unwrap(); + let file = ProgramFile::new(db, file, env.program(db)); + let Type::KnownInstance(KnownInstanceType::TypeAliasType(alias)) = + global_symbol(db, file, name).place.expect_type() + else { + panic!("expected `{name}` to be a type alias"); + }; + alias + } + + #[test] + fn combines_flows_from_multiple_recursive_references() { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +type Alternating[X, Y] = tuple[ + Alternating[Y, None], + Alternating[None, list[X]], +] +"#, + ) + .unwrap(); + let env = db.program_environment(); + + assert!(matches!( + Type::TypeAlias(global_type_alias(&db, &env, "Alternating")).recursive_identity(&db), + Some(TypeIdentity::GrowingTypeAlias(_)) + )); + } + + #[test] + fn classifies_flow_graph_cycles() { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +type One[T] = T +type Two[X, Y] = tuple[X, Y] +type Helper[U] = U +"#, + ) + .unwrap(); + let env = db.program_environment(); + let one = + RecursiveDefinition::TypeAlias(global_type_alias(&db, &env, "One").unspecialized(&db)); + let two = + RecursiveDefinition::TypeAlias(global_type_alias(&db, &env, "Two").unspecialized(&db)); + let helper = RecursiveDefinition::TypeAlias( + global_type_alias(&db, &env, "Helper").unspecialized(&db), + ); + let mut one_parameters = one.parameters(&db); + let Some(one_t) = one_parameters.next() else { + panic!("expected one parameter"); + }; + let mut two_parameters = two.parameters(&db); + let (Some(two_x), Some(two_y)) = (two_parameters.next(), two_parameters.next()) else { + panic!("expected two parameters"); + }; + let mut helper_parameters = helper.parameters(&db); + let Some(helper_u) = helper_parameters.next() else { + panic!("expected one helper parameter"); + }; + + for (root, edges, expected) in [ + ( + one, + vec![FlowEdge { + from: one_t, + to: one_t, + kind: FlowKind::Direct, + }], + false, + ), + ( + one, + vec![FlowEdge { + from: one_t, + to: one_t, + kind: FlowKind::Nested, + }], + true, + ), + ( + two, + vec![ + FlowEdge { + from: two_x, + to: two_y, + kind: FlowKind::Direct, + }, + FlowEdge { + from: two_y, + to: two_x, + kind: FlowKind::Direct, + }, + ], + false, + ), + ( + two, + vec![FlowEdge { + from: two_y, + to: two_x, + kind: FlowKind::Nested, + }], + false, + ), + ( + two, + vec![ + FlowEdge { + from: two_y, + to: two_x, + kind: FlowKind::Nested, + }, + FlowEdge { + from: two_x, + to: two_y, + kind: FlowKind::Direct, + }, + ], + true, + ), + ( + one, + vec![FlowEdge { + from: one_t, + to: helper_u, + kind: FlowKind::Nested, + }], + false, + ), + ( + one, + vec![ + FlowEdge { + from: one_t, + to: helper_u, + kind: FlowKind::Nested, + }, + FlowEdge { + from: helper_u, + to: one_t, + kind: FlowKind::Direct, + }, + ], + true, + ), + ] { + let graph = SpecializationFlowGraph { + edges: edges.into_iter().collect(), + ..SpecializationFlowGraph::default() + }; + assert_eq!( + graph.root_may_have_unbounded_specialization(&db, root), + expected, + ); + } + } + + #[test] + fn scopes_inconclusive_parameter_flows_to_recursive_paths() { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +from typing import Protocol + +class Outer[T](Protocol): + type Inner = T + value: Inner + +class RecursiveOuter[T](Protocol): + type Inner = tuple[T, RecursiveOuter[list[T]]] + value: Inner +"#, + ) + .unwrap(); + let env = db.program_environment(); + + assert!( + global_instance_type(&db, &env, "Outer") + .recursive_identity(&db) + .is_none() + ); + assert!(matches!( + global_instance_type(&db, &env, "RecursiveOuter").recursive_identity(&db), + Some(TypeIdentity::GrowingProtocol(_)) + )); + } + + #[test] + fn classifies_recursive_parameter_flows() { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +type Stable[T] = tuple[T, Stable[T]] +type Growing[T] = tuple[T, Growing[list[T]]] +type Swap[X, Y] = tuple[X, Swap[Y, X]] +type ShiftReset[X, Y] = tuple[X, ShiftReset[list[Y], None]] +type ShiftCycle[X, Y] = tuple[X, ShiftCycle[list[Y], X]] + +type ResetOuter[T] = tuple[T, ResetHelper[list[T]]] +type ResetHelper[U] = tuple[U, ResetOuter[int]] + +type TransitiveOuter[T] = tuple[T, TransitiveHelper[list[T]]] +type TransitiveHelper[U] = tuple[U, TransitiveOuter[U]] + +type PeriodicOuter[X, Y] = tuple[X, PeriodicHelper[X, Y]] +type PeriodicHelper[X, Y] = tuple[X, PeriodicHelper[Y, X]] + +type Saturating[T] = tuple[T, Saturating[T | int]] +"#, + ) + .unwrap(); + let env = db.program_environment(); + + for (name, expected) in [ + ("Stable", false), + ("Growing", true), + ("Swap", false), + ("ShiftReset", false), + ("ShiftCycle", true), + ("ResetOuter", false), + ("ResetHelper", false), + ("TransitiveOuter", true), + ("TransitiveHelper", true), + ("PeriodicOuter", false), + ("PeriodicHelper", false), + ("Saturating", false), + ] { + let alias = RecursiveDefinition::TypeAlias( + global_type_alias(&db, &env, name).unspecialized(&db), + ); + assert_eq!( + alias.may_have_unbounded_specialization(&db), + expected, + "unexpected result for {name}", + ); + } + } + #[test] fn property_receiver_does_not_make_protocol_recursive() { let mut db = setup_db(); @@ -806,14 +1576,14 @@ class RecursivePropertySetter[T](Protocol): global_instance_type(&db, &env, "GenericProperty").recursive_identity(&db), None ); - assert!(matches!( + assert_matches!( global_instance_type(&db, &env, "RecursiveProperty").recursive_identity(&db), - Some(TypeIdentity::RecursiveProtocol(_)) - )); - assert!(matches!( + Some(TypeIdentity::GrowingProtocol(_)) + ); + assert_matches!( global_instance_type(&db, &env, "RecursivePropertySetter").recursive_identity(&db), - Some(TypeIdentity::RecursiveProtocol(_)) - )); + Some(TypeIdentity::GrowingProtocol(_)) + ); } #[test] @@ -845,6 +1615,89 @@ class RecursivePropertySetter[T](Protocol): ); } + #[test] + fn selectively_reuses_cached_results() { + let db = setup_db(); + let db = &db; + let detector = Detector::new(0); + + assert_eq!(detector.try_visit(db, 1, |_| true, || 10), Ok(10)); + assert_eq!( + detector.try_visit(db, 1, |&result| result == 10, || 20), + Ok(10) + ); + assert_eq!( + detector.try_visit( + db, + 1, + |&result| result == 20, + || { + assert_eq!(detector.try_visit(db, 1, |_| false, || 30), Ok(0)); + detector.visit(db, 1, || 30) + 10 + } + ), + Ok(20) + ); + assert_eq!(detector.visit(db, 1, || 30), 10); + } + + #[test] + fn recomputed_visits_share_exact_recursion_guards() { + let db = setup_db(); + let db = &db; + let detector = Detector::new(0); + + assert_eq!( + detector.try_visit(db, 1, |_| false, || detector.visit(db, 1, || 20) + 10), + Ok(10) + ); + assert_eq!( + detector.visit(db, 2, || { + assert_eq!(detector.try_visit(db, 2, |_| false, || 20), Ok(0)); + 10 + }), + 10 + ); + } + + #[test] + fn recomputed_visits_share_abstract_identity_guards() { + let db = setup_db(); + let db = &db; + let detector = CycleDetector::::new(0); + + assert_eq!( + detector.try_visit( + db, + ConstantIdentityItem(1), + |_| false, + || { + assert_eq!( + detector.try_visit(db, ConstantIdentityItem(2), |_| true, || 20), + Err(ConstantIdentityItem(2)) + ); + 10 + } + ), + Ok(10) + ); + assert_eq!( + detector.try_visit( + db, + ConstantIdentityItem(3), + |_| true, + || { + assert_eq!( + detector.try_visit(db, ConstantIdentityItem(4), |_| false, || 20), + Err(ConstantIdentityItem(4)) + ); + 10 + } + ), + Ok(10) + ); + } + #[test] fn computes_each_active_identity_once() { let db = setup_db(); diff --git a/crates/ty_python_semantic/src/types/dedicated/django.rs b/crates/ty_python_semantic/src/types/dedicated/django.rs index e8236bcecf..81fcf8f514 100644 --- a/crates/ty_python_semantic/src/types/dedicated/django.rs +++ b/crates/ty_python_semantic/src/types/dedicated/django.rs @@ -477,7 +477,7 @@ fn attname_type<'db>( return None; } let target = field_get_type(db, env, field.declared_ty)? - .filter_union(db, |element| !element.is_none(db)); + .filter_union(db, env, |element| !element.is_none(db)); let target_class = instance_static_class(db, env, target)?; if !is_model(db, target_class) { return None; @@ -621,7 +621,7 @@ pub(in crate::types) fn reverse_accessors<'db>( } else { match field_get_type(db, env, field.declared_ty) { Some(target) => ( - target.filter_union(db, |element| !element.is_none(db)), + target.filter_union(db, env, |element| !element.is_none(db)), None, ), None => continue, @@ -919,7 +919,7 @@ fn field_ref<'db>( if let Some(field) = fields.get(name) { if is_relation_field_instance(db, env, field.declared_ty) { let target = field_get_type(db, env, field.declared_ty) - .map(|ty| ty.filter_union(db, |element| !element.is_none(db))); + .map(|ty| ty.filter_union(db, env, |element| !element.is_none(db))); let relation_model = target .and_then(|target| instance_static_class(db, env, target)) .filter(|target| is_model(db, *target)); @@ -1093,7 +1093,7 @@ fn lookup_suffix(op: ast::CmpOp) -> Option<&'static str> { /// only the methods whose stub declares `*args` do. the `*_or_create` family is /// a lookup method too, but its first positional parameter is `defaults`, so an /// expression there would bind to that rather than to the keywords it lowers to -pub(crate) fn accepts_lookup_expressions(method: &str) -> bool { +fn accepts_lookup_expressions(method: &str) -> bool { matches!(method, "filter" | "exclude" | "get" | "aget") } @@ -1546,7 +1546,7 @@ impl MetaFieldsDeclarer { /// /// the declared-field side is read out of the place tables rather than by /// member lookup: the declaring class's body is the scope this runs from -pub(in crate::types) fn is_meta_fields_entry_valid<'db>( +fn is_meta_fields_entry_valid<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, model: StaticClassLiteral<'db>, diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index 4832fa6950..09926e2bcd 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -1,22 +1,25 @@ use crate::ProgramEnvironment; use char_str::CharStr; use ruff_db::parsed::parsed_module; -use ruff_python_ast::{ArgOrKeyword, Arguments, Expr, ExprCall, ExprDict, Keyword, name::Name}; +use ruff_python_ast::{ + ArgOrKeyword, Arguments, Expr, ExprCall, ExprDict, ExprRef, Keyword, name::Name, +}; use rustc_hash::FxHashSet; use ty_module_resolver::{KnownModule, file_to_module}; use ty_python_core::{ definition::{Definition, DefinitionKind}, - place_table, use_def_map, + place_table, semantic_index, use_def_map, }; +use crate::Db; use crate::diagnostic::format_enumeration; use crate::place::{DefinedPlace, Definedness, Place, Provenance, known_module_symbol}; use crate::reachability::DeclarationsIteratorExtension; use crate::types::call::Bindings; use crate::types::class::CodeGeneratorKind; use crate::types::context::InferContext; +use crate::types::definition_resolution::{ImportAliasResolution, definitions_for_name}; use crate::types::diagnostic::PYDANTIC_DISCARDED_EXTRA_ARGUMENT; -use crate::types::ide_support::{ImportAliasResolution, definitions_for_name}; use crate::types::infer::function_known_decorators; use crate::types::known_instance::FieldInstance; use crate::types::member::class_member; @@ -26,7 +29,6 @@ use crate::types::{ KnownInstanceType, KnownUnion, Parameter, Specialization, StaticClassLiteral, Type, UnionType, definition_expression_type, }; -use crate::{Db, SemanticModel}; /// Pydantic treats underscore-prefixed annotations as private instance attributes. pub(in crate::types) fn is_private_attribute(name: &str) -> bool { @@ -175,11 +177,15 @@ impl<'db> FieldMetadata<'db> { // using `StrictInt = Annotated[int, Strict()]`. Since we don't retain the `Annotated` // metadata, we need to follow the alias back to its definition and parse the metadata // from there. - let model = SemanticModel::new(db, definition.program_file(db)); + let file = definition.program_file(db); + let index = semantic_index(db, file); + let Some(scope) = index.try_expression_scope_id(&ExprRef::Name(name)) else { + return; + }; let Some(alias_definition) = definitions_for_name( - &model, + db, + scope.to_scope_id(db, file), name.id.as_str(), - name.into(), ImportAliasResolution::ResolveAliases, ) .into_iter() diff --git a/crates/ty_python_semantic/src/types/dedicated/pytest.rs b/crates/ty_python_semantic/src/types/dedicated/pytest.rs index ba9f3bb684..90acb3b790 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pytest.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pytest.rs @@ -68,7 +68,7 @@ pub(in crate::types) fn is_fixture_function<'db>( fn fixture_marker<'db>(db: &'db dyn Db, function: FunctionType<'db>) -> Option { let file = function.file(db); let definition = function.definition(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, function.python_file(db)).load(db); let node = function.node(db, file, &module); let types = infer_definition_types(db, definition); @@ -262,7 +262,7 @@ fn resolve_builtin_fixture<'db>( /// a yield fixture annotates its return as `Iterator[T]` / `Generator[T, /// ...]` (or the async variants); the provided value is the yielded `T`, so /// the generator wrapper is unwrapped. a plain `-> T` fixture provides `T`. -pub(in crate::types) fn fixture_provided_type<'db>( +fn fixture_provided_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, function: FunctionType<'db>, @@ -299,11 +299,7 @@ fn unwrap_generator<'db>( /// `true` if `ty` is an instance of pytest's `MarkGenerator` — the type of /// `pytest.mark`, whose `.parametrize` attribute builds the decorator. -pub(in crate::types) fn is_mark_generator<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ty: Type<'db>, -) -> bool { +fn is_mark_generator<'db>(db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>) -> bool { let Some(class) = ty .nominal_class(db, env) .and_then(|class| class.class_literal(db).as_static()) @@ -369,7 +365,7 @@ pub(in crate::types) fn parametrized_names( ) -> FxHashSet { let env = &ProgramEnvironment::from_file(function.program_file(db)); let file = function.file(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, function.python_file(db)).load(db); let mut names: FxHashSet = function .node(db, file, &module) .decorator_list @@ -440,7 +436,7 @@ const COLLECTED_EXTENSIONS: [&str; 2] = ["py", "by"]; /// `true` if `file` is collected by pytest under the default conventions: /// its name is `conftest`, `test_*`, or `*_test`. -pub(in crate::types) fn is_test_file(db: &dyn Db, file: File) -> bool { +fn is_test_file(db: &dyn Db, file: File) -> bool { let FilePath::System(path) = file.path(db) else { return false; }; @@ -480,3 +476,25 @@ pub(in crate::types) fn is_test_function<'db>( && function.name(db).starts_with("test") && function.definition(db).file_scope(db).is_global() } + +use ty_python_core::use_def_map; + +use crate::place::definitions::DefinitionResolution; +use crate::types::may_exist_at_runtime; + +mod collection; +mod fixtures; + +pub use fixtures::{ + FixtureBinding, FixtureExposure, FixtureNameSource, fixture_bindings_for_parameter, + fixture_exposures_for_definition, pytest_global_plugin_files, +}; + +/// Returns whether `definition` remains bound in its defining scope and may exist at runtime. +fn is_available_definition<'db>(db: &'db dyn Db, definition: Definition<'db>) -> bool { + let resolution = DefinitionResolution::from_bindings( + db, + use_def_map(db, definition.scope(db)).end_of_scope_bindings(definition.place(db)), + ); + resolution.definitions().contains(&definition) && may_exist_at_runtime(db, definition) +} diff --git a/crates/ty_python_semantic/src/types/dedicated/pytest/collection.rs b/crates/ty_python_semantic/src/types/dedicated/pytest/collection.rs new file mode 100644 index 0000000000..437ef8a34a --- /dev/null +++ b/crates/ty_python_semantic/src/types/dedicated/pytest/collection.rs @@ -0,0 +1,1251 @@ +//! Models test collection for fixture resolution, parametrization, and editor test discovery. +//! +//! The model applies pytest's [default naming and class rules][test-discovery] to function +//! bindings in a file, including functions exposed through assignments and imports: +//! +//! - Files must be named `test_*.py` or `*_test.py`. +//! - Modules and classes with a falsy `__test__` flag are excluded, as are abstract test classes. +//! - Function and method names must start with `test`. Fixtures and known property descriptors are +//! excluded even when their names match. +//! - Subclasses of the standard-library [`unittest.TestCase`][unittest-tests] are eligible regardless +//! of their class names or constructors. Their methods are classified as +//! [`PytestTestKind::StdlibUnittest`], allowing consumers to distinguish them from tests that +//! support pytest fixture injection. A `runTest` method is collected when there are no callable +//! methods whose names start with `test`, including inherited methods. +//! - Classes that do not inherit from `unittest.TestCase` must have names starting with `Test` and +//! inherit both `__init__` and `__new__` from `object`. +//! +//! Test functions must be bound at module scope or in an eligible class. Test classes must be +//! defined at module scope or nested inside an eligible class that does not inherit from +//! `unittest.TestCase`. Definitions inside functions are excluded. +//! +//! For example, in `test_example.py`: +//! +//! ```py +//! import unittest +//! +//! def test_function(): ... # recognized as a pytest test +//! def helper(): ... # not recognized because the name does not start with test +//! test_alias = helper # recognized as a pytest test +//! +//! class TestGroup: +//! def test_method(self): ... # recognized as a pytest test +//! +//! class Example(unittest.TestCase): +//! def test_method(self): ... # recognized as a unittest test +//! +//! class Group: +//! def test_method(self): ... # not recognized because the class name lacks the Test prefix +//! +//! class TestWithCustomInit: +//! def __init__(self): ... +//! def test_method(self): ... # not recognized because the class defines __init__ +//! ``` +//! +//! There are two entry points: +//! +//! - [`pytest_test_for_binding`] classifies one binding definition, returning `None` when it does +//! not satisfy the collection rules. +//! - [`pytest_tests_in_file`] applies the same classification to bindings that are still available +//! at the end of their module or class scope, excluding bindings overwritten or deleted later in +//! that scope. It returns the collected tests in source order. +//! +//! Each [`PytestTest`] records the binding that exposes the test, its underlying function, +//! collection kind, and directly enclosing class, if any. Consequently: +//! +//! - Inheriting a test method does not produce another result for the subclass. +//! - A parametrized test function produces one result, regardless of how many parameter +//! combinations pytest would execute. +//! - Two aliases of the same function produce separate results under their bound names. +//! +//! [test-discovery]: https://docs.pytest.org/en/stable/explanation/goodpractices.html#conventions-for-python-test-discovery +//! [unittest-tests]: https://docs.pytest.org/en/stable/how-to/unittest.html + +use ruff_db::parsed::parsed_module; +use ruff_text_size::Ranged; +use ty_python_core::definition::{Definition, DefinitionKind}; +use ty_python_core::scope::ScopeKind; +use ty_python_core::{ProgramFile, global_scope, place_table, semantic_index, use_def_map}; + +use crate::Db; +use crate::place::definitions::DefinitionResolution; +use crate::place::{ConsideredDefinitions, Place, symbol}; +use crate::types::function::FunctionType; +use crate::types::infer::{function_known_decorators, original_class_type}; +use crate::types::{ + ClassBase, ClassLiteral, KnownClass, MemberLookupPolicy, ProgramEnvironment, Type, + binding_type, definition_expression_type, +}; + +/// Returns the tests that pytest collects from `file` under the default collection conventions. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +fn pytest_tests_in_file<'db>(db: &'db dyn Db, file: ProgramFile<'db>) -> Box<[PytestTest<'db>]> { + if !is_default_pytest_test_file(db, file) { + return Box::default(); + } + + let index = semantic_index(db, file); + let module = parsed_module(db, file.python_file(db)).load(db); + let mut tests = Vec::new(); + for scope in index.scope_ids() { + let scope = scope.file_scope_id(db); + if !matches!( + index.scope(scope).kind(), + ScopeKind::Module | ScopeKind::Class + ) { + continue; + } + for (symbol, bindings) in index.use_def_map(scope).all_end_of_scope_symbol_bindings() { + let name = index.place_table(scope).symbol(symbol).name(); + if !name.starts_with("test") && name != "runTest" { + continue; + } + let resolution = DefinitionResolution::from_bindings(db, bindings); + tests.extend( + resolution + .definitions() + .iter() + .filter_map(|binding| pytest_test_for_binding(db, *binding).cloned()), + ); + } + } + + tests.sort_unstable_by_key(|test| test.binding.focus_range(db, &module).start()); + + tests.into_boxed_slice() +} + +/// A function that pytest collects as a test. +#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct PytestTest<'db> { + binding: Definition<'db>, + function: Definition<'db>, + kind: PytestTestKind, + enclosing_class: Option>, +} + +impl PytestTest<'_> { + /// Returns the collection mechanism responsible for this test. + pub(crate) fn kind(&self) -> PytestTestKind { + self.kind + } +} + +/// The collection mechanism responsible for a test. +#[derive(Debug, Clone, Copy, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum PytestTestKind { + /// A function or method collected according to pytest's naming and class conventions. + Pytest, + /// A method collected because its class inherits from `unittest.TestCase`. + StdlibUnittest, +} + +/// Returns the pytest test exposed by `binding` under the default collection conventions. +/// +/// The binding can be a function declaration, assignment, or import. Its name, file, and enclosing +/// scope determine collection eligibility; the underlying function may be defined elsewhere. +/// Returns `None` for unavailable bindings, values whose function cannot be identified, fixtures, +/// and bindings that fail the naming or enclosing-class rules. +#[salsa::tracked(returns(as_ref))] +pub(crate) fn pytest_test_for_binding<'db>( + db: &'db dyn Db, + binding: Definition<'db>, +) -> Option> { + if !is_default_pytest_test_file(db, binding.program_file(db)) { + return None; + } + + let symbol = binding.place(db).as_symbol()?; + let table = place_table(db, binding.scope(db)); + let name = table.symbol(symbol).name(); + if !name.starts_with("test") && name != "runTest" { + return None; + } + + let scope = enclosing_scope(db, binding)?; + let (kind, enclosing_class) = match scope { + EnclosingScope::Module => (PytestTestKind::Pytest, None), + EnclosingScope::Class(class) => (pytest_test_class_kind(db, class)?, Some(class)), + }; + if name == "runTest" + && (kind != PytestTestKind::StdlibUnittest + || has_unittest_test_methods(db, original_class_type(db, enclosing_class?)?)) + { + return None; + } + if !super::is_available_definition(db, binding) { + return None; + } + + let function = if matches!(binding.kind(db), DefinitionKind::Function(_)) + && is_excluded_test_function(db, binding) + { + return None; + } else if matches!(binding.kind(db), DefinitionKind::Function(_)) { + binding + } else { + let function = match binding_value_type(db, binding) { + Type::FunctionLiteral(function) => function, + Type::BoundMethod(method) => method.function(db), + _ => return None, + }; + test_function_definition(db, function) + }; + + if super::fixtures::fixture_declaration(db, function).is_some() { + return None; + } + + Some(PytestTest { + binding, + function, + kind, + enclosing_class, + }) +} + +/// Returns whether a property decorator excludes a function from collection. +fn is_excluded_test_function<'db>(db: &'db dyn Db, definition: Definition<'db>) -> bool { + let DefinitionKind::Function(function) = definition.kind(db) else { + return false; + }; + + let module = parsed_module(db, definition.python_file(db)).load(db); + let decorators = &function.node(&module).decorator_list; + if decorators.is_empty() { + return false; + } + + let inference = function_known_decorators(db, definition); + for decorator in decorators { + match inference.expression_type(&decorator.expression) { + Some(Type::ClassLiteral(class)) if class.is_known(db, KnownClass::Property) => { + return true; + } + Some(Type::ClassLiteral(class)) + if class.is_known(db, KnownClass::Staticmethod) + || class.is_known(db, KnownClass::Classmethod) => {} + // Unknown outer decorators can transform the value. Only inspect beneath the + // method wrappers that pytest itself unwraps during collection. + _ => return false, + } + } + + false +} + +/// Returns the assigned target's type before assignment error recovery. +/// +/// For `class Test: __test__ = False`, class-member lookup exposes `bool`, but reading the +/// assignment target preserves `Literal[False]`, allowing collection to recognize the opt-out. +/// +/// An inherited `__test__` binding may belong to another file. Tracking keeps that file's AST and +/// semantic-index dependencies on the defining binding, so subclasses share the result and +/// unrelated edits to the defining file need not invalidate their collection queries. +#[salsa::tracked(returns(copy))] +fn binding_value_type<'db>(db: &'db dyn Db, binding: Definition<'db>) -> Type<'db> { + let module = parsed_module(db, binding.python_file(db)).load(db); + let target = match binding.kind(db) { + DefinitionKind::Assignment(assignment) => Some(assignment.target(&module)), + DefinitionKind::AnnotatedAssignment(assignment) if assignment.has_value() => { + Some(assignment.target(&module)) + } + _ => None, + }; + target.map_or_else( + || binding_type(db, binding), + |target| definition_expression_type(db, binding, target), + ) +} + +/// Uses surviving bindings' assigned values when lookup returns a declared or widened type. +fn place_value_type<'db>(db: &'db dyn Db, place: Place<'db>) -> Option> { + let Place::Defined(place) = place else { + return None; + }; + let Some(definition) = place.provenance.definition() else { + return Some(place.ty); + }; + let resolution = DefinitionResolution::from_bindings( + db, + use_def_map(db, definition.scope(db)).end_of_scope_bindings(definition.place(db)), + ); + match resolution.definitions() { + [] => None, + [binding] => Some(binding_value_type(db, *binding)), + _ => Some(place.ty), + } +} + +/// Returns the definition of a function that may be imported from another file. +/// +/// This is tracked because `FunctionType::definition` reads the function's semantic index. +/// Without this boundary, unrelated edits to that file could make importing bindings' collection +/// queries rerun. Tracking lets Salsa stop that invalidation when the returned definition is unchanged. +#[salsa::tracked(returns(copy))] +fn test_function_definition<'db>(db: &'db dyn Db, function: FunctionType<'db>) -> Definition<'db> { + function.definition(db) +} + +#[derive(Debug, Clone, Copy)] +enum EnclosingScope<'db> { + Module, + Class(Definition<'db>), +} + +fn enclosing_scope<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Option> { + let file = definition.program_file(db); + let index = semantic_index(db, file); + let scope = definition.file_scope(db); + + match index.scope(scope).kind() { + ScopeKind::Module => Some(EnclosingScope::Module), + ScopeKind::Class => { + let class_ref = index.scope(scope).node().as_class()?; + Some(EnclosingScope::Class( + index.expect_single_definition(class_ref), + )) + } + _ => None, + } +} + +/// Returns the collection kind for a class under pytest's default conventions. +/// +/// Returns [`PytestTestKind::Pytest`] for classes whose names start with `Test` and whose +/// constructors are inherited from `object`, or [`PytestTestKind::StdlibUnittest`] for +/// `unittest.TestCase` subclasses. In either case, the class must be at module scope or nested in an +/// eligible class that does not inherit from `unittest.TestCase`. Abstract classes and classes +/// with a falsy `__test__` flag (including an inherited flag) are excluded. +/// +/// Returns `None` when `definition` is not a class, the class fails those collection rules, or +/// its type or constructors cannot be resolved. This does not check the module's filename or +/// whether the class contains any test methods. +fn pytest_test_class_kind<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Option { + let DefinitionKind::Class(class_ref) = definition.kind(db) else { + return None; + }; + if !super::is_available_definition(db, definition) { + return None; + } + + match enclosing_scope(db, definition)? { + EnclosingScope::Module => {} + EnclosingScope::Class(parent) => { + if pytest_test_class_kind(db, parent) != Some(PytestTestKind::Pytest) { + return None; + } + } + } + + let class = original_class_type(db, definition)?; + let env = ProgramEnvironment::from_file(definition.program_file(db)); + if place_value_type( + db, + class + .class_member(db, &env, "__test__", MemberLookupPolicy::default()) + .place, + ) + .is_some_and(|flag| flag.bool(db, &env).is_always_false()) + { + return None; + } + + // The subtype check models `isinstance(cls, ABCMeta)`. Inheriting from ABC supplies this + // metaclass; @abstractmethod alone does not make a class abstract at runtime. + // Exclude classes with explicitly abstract members that have not been concretely overridden. + // Implicit abstract methods in protocols affect only type checking and are ignored here. + // Custom metaclass behavior is approximated. + if Type::ClassLiteral(class).is_subtype_of(db, &env, KnownClass::ABCMeta.to_instance(db, &env)) + && class + .identity_specialization(db) + .abstract_methods(db) + .values() + .any(|method| method.kind.is_explicit()) + { + return None; + } + + if is_unittest_test_case(db, class) { + return Some(PytestTestKind::StdlibUnittest); + } + + let module = parsed_module(db, definition.python_file(db)).load(db); + if !class_ref.node(&module).name.as_str().starts_with("Test") { + return None; + } + + has_default_pytest_constructors(db, class).then_some(PytestTestKind::Pytest) +} + +/// Returns whether `class` inherits both constructors from `object`. +fn has_default_pytest_constructors(db: &dyn Db, class: ClassLiteral<'_>) -> bool { + let Some(class) = class.as_static() else { + return false; + }; + let env = ProgramEnvironment::from_file(class.program_file(db)); + let Some(object) = KnownClass::Object.try_to_class_literal(db, &env) else { + return false; + }; + + ["__init__", "__new__"].into_iter().all(|name| { + let actual = ClassLiteral::Static(class) + .class_member(db, &env, name, MemberLookupPolicy::default()) + .place; + let expected = ClassLiteral::Static(object) + .class_member(db, &env, name, MemberLookupPolicy::default()) + .place; + let (Place::Defined(actual), Place::Defined(expected)) = (actual, expected) else { + return false; + }; + + actual.provenance == expected.provenance + }) +} + +/// Returns whether the class inherits from the canonical `unittest.TestCase`. +fn is_unittest_test_case(db: &dyn Db, class: ClassLiteral<'_>) -> bool { + class + .iter_mro(db) + .filter_map(ClassBase::into_class) + .any(|ancestor| ancestor.is_known(db, KnownClass::UnittestTestCase)) +} + +/// Unittest uses `runTest` only when no named test methods are available, including inherited ones. +fn has_unittest_test_methods<'db>(db: &'db dyn Db, class: ClassLiteral<'db>) -> bool { + let env = ProgramEnvironment::from_file(class.program_file(db)); + class + .iter_mro(db) + .filter_map(ClassBase::into_class) + .filter_map(|ancestor| ancestor.static_class_literal(db)) + .any(|(ancestor, _)| { + place_table(db, ancestor.body_scope(db)) + .symbols() + .any(|symbol| { + if !symbol.name().starts_with("test") { + return false; + } + let member = class + .class_member(db, &env, symbol.name(), MemberLookupPolicy::default()) + .place; + place_value_type(db, member) + .is_some_and(|ty| ty.try_upcast_to_callable(db, &env).is_some()) + }) + }) +} + +fn is_default_pytest_test_file(db: &dyn Db, file: ProgramFile<'_>) -> bool { + let Some(file_name) = file + .file(db) + .path(db) + .as_system_path() + .and_then(|path| path.file_name()) + else { + return false; + }; + + let Some(stem) = file_name.strip_suffix(".py") else { + return false; + }; + + if !(stem.starts_with("test_") || stem.ends_with("_test")) { + return false; + } + + let env = ProgramEnvironment::from_file(file); + !place_value_type( + db, + symbol( + db, + global_scope(db, file), + "__test__", + ConsideredDefinitions::EndOfScope, + ) + .place, + ) + .is_some_and(|flag| flag.bool(db, &env).is_always_false()) +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use ruff_db::diagnostic::{ + Annotation, Diagnostic, DiagnosticId, DisplayDiagnosticConfig, DisplayDiagnostics, Severity, + }; + use ruff_db::files::{FileRange, system_path_to_file}; + use ruff_db::parsed::parsed_module; + use ruff_python_ast as ast; + use ruff_text_size::Ranged; + use ty_python_core::ProgramFile; + use ty_python_core::definition::{Definition, DefinitionKind}; + use ty_python_core::semantic_index; + + use super::{PytestTestKind, pytest_test_for_binding, pytest_tests_in_file}; + use crate::Db; + use crate::db::tests::{TestDb, TestDbBuilder}; + + #[test] + fn collects_pytest_and_unittest_functions() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +import unittest + +import pytest + +def test_module(): ... +async def test_async(): ... +def helper(): ... + +class TestClass: + def test_method(self): ... + + class TestNested: + def test_nested(self): ... + +class Example: + def test_not_collected(self): ... + +class UnitCase(unittest.TestCase): + def test_unit(self): ... + +def outer(): + def test_local(): ... + +@pytest.fixture +def test_fixture(): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:6:5 + | + 6 | def test_module(): ... + | ^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:7:11 + | + 7 | async def test_async(): ... + | ^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:11:9 + | + 11 | def test_method(self): ... + | ^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:14:13 + | + 14 | def test_nested(self): ... + | ^^^^^^^^^^^ + + info[pytest-collection]: Collected unittest test + --> src/test_example.py:20:9 + | + 20 | def test_unit(self): ... + | ^^^^^^^^^ + "); + assert!( + pytest_test_for_binding(&test.db, test.function("test_module")) + .expect("module test should be collected") + .enclosing_class + .is_none() + ); + assert!( + pytest_test_for_binding(&test.db, test.function("TestClass.test_method")) + .expect("method should be collected") + .enclosing_class + .is_some() + ); + } + + #[test] + fn matches_test_function_prefix_case_sensitively() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +def testFunction(): ... +def Test_function(): ... +def TEST_FUNCTION(): ... + +class TestClass: + def testMethod(self): ... + def Test_method(self): ... + def TEST_METHOD(self): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:2:5 + | + 2 | def testFunction(): ... + | ^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:7:9 + | + 7 | def testMethod(self): ... + | ^^^^^^^^^^ + "); + // File-wide collection skips this name before checking individual bindings. + assert!(pytest_test_for_binding(&test.db, test.function("Test_function")).is_none()); + } + + #[test] + fn collects_generic_functions_and_classes() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +def test_generic[T](): ... + +class TestGeneric[T]: + def test_generic_method[U](self): ... + + class TestNested[V]: + def test_nested_generic[W](self): ... + +def outer[T](): + def test_local[U](): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:2:5 + | + 2 | def test_generic[T](): ... + | ^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:5:9 + | + 5 | def test_generic_method[U](self): ... + | ^^^^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:8:13 + | + 8 | def test_nested_generic[W](self): ... + | ^^^^^^^^^^^^^^^^^^^ + "); + } + + #[test] + fn collects_parametrized_generic_function_once() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.mark.parametrize("value", [1, "foo"]) +def test_generic[T](value: T) -> None: ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:5:5 + | + 5 | def test_generic[T](value: T) -> None: ... + | ^^^^^^^^^^^^ + "); + } + + #[test] + fn treats_project_defined_unittest_test_case_as_an_ordinary_base() { + // The project-local `unittest` package shadows the standard library. Inheriting from its + // `unittest.case.TestCase` does not grant unittest collection: `Example` is excluded, while + // `TestExample` is collected by name because it starts with `Test`. + let db = pytest_db_with_files(&[ + ( + "/src/unittest/__init__.py", + r#" +from .case import TestCase +"#, + ), + ( + "/src/unittest/case.py", + r#" +class TestCase: ... +"#, + ), + ( + "/src/test_example.py", + r#" +from unittest import TestCase + +class Example(TestCase): + def test_not_collected(self): ... + +class TestExample(TestCase): + def test_pytest(self): ... +"#, + ), + ]); + + assert_snapshot!(collected_tests(&db, "/src/test_example.py"), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:8:9 + | + 8 | def test_pytest(self): ... + | ^^^^^^^^^^^ + "); + } + + #[test] + fn requires_a_default_test_module_name() { + let test = CollectionTest::new( + "/src/example.py", + r#" +def test_example(): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @"No tests collected"); + // File-wide collection returns early without checking individual bindings. + assert_eq!( + pytest_test_for_binding(&test.db, test.function("test_example")), + None + ); + } + + #[test] + fn rejects_custom_constructors() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +class InitBase: + def __init__(self): ... + +class NewBase: + def __new__(cls): ... + +class TestOwnInit: + def __init__(self): ... + def test_own_init(self): ... + +class TestInheritedInit(InitBase): + def test_inherited_init(self): ... + +class TestOwnNew: + def __new__(cls): ... + def test_own_new(self): ... + +class TestInheritedNew(NewBase): + def test_inherited_new(self): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @"No tests collected"); + } + + #[test] + fn collects_only_remaining_bindings() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +def test_redefined(): ... +test_original = original_alias = test_redefined +def test_redefined(): ... + +def test_overwritten(): ... +test_overwritten = None + +def test_unpacked_overwrite(): ... +test_unpacked_overwrite, other = None, 0 + +def test_deleted(): ... +del test_deleted + +class TestOverwritten: + def test_hidden(self): ... +TestOverwritten = None + +class TestMethods: + def test_redefined_method(self): ... + def test_redefined_method(self): ... + +if False: + def test_unreachable(): ... +"#, + ); + + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:3:1 + | + 2 | def test_redefined(): ... + | -------------- + 3 | test_original = original_alias = test_redefined + | ^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:4:5 + | + 4 | def test_redefined(): ... + | ^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:21:9 + | + 21 | def test_redefined_method(self): ... + | ^^^^^^^^^^^^^^^^^^^^^ + "); + // Check the original definition, which file-wide collection never visits. + assert!(pytest_test_for_binding(&test.db, test.function("test_redefined")).is_none()); + } + + #[test] + fn collects_function_aliases_and_imports() { + let db = pytest_db_with_files(&[ + ( + "/src/helpers.py", + r#" +def external_function(): ... +"#, + ), + ( + "/src/reexport.py", + r#" +from helpers import external_function as forwarded +"#, + ), + ( + "/src/test_example.py", + r#" +from helpers import external_function as test_imported +from reexport import forwarded as test_reexported + +def local_function(): ... +test_unpacked_alias, _ = local_function, 0 +test_annotated_alias: object = local_function +local_function = None + +class TestMethods: + test_imported_method = test_imported +"#, + ), + ]); + + assert_snapshot!(collected_tests(&db, "/src/test_example.py"), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:2:42 + | + 2 | from helpers import external_function as test_imported + | ^^^^^^^^^^^^^ + | + ::: src/helpers.py:2:5 + | + 2 | def external_function(): ... + | ----------------- + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:3:35 + | + 3 | from reexport import forwarded as test_reexported + | ^^^^^^^^^^^^^^^ + | + ::: src/helpers.py:2:5 + | + 2 | def external_function(): ... + | ----------------- + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:6:1 + | + 5 | def local_function(): ... + | -------------- + 6 | test_unpacked_alias, _ = local_function, 0 + | ^^^^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:7:1 + | + 5 | def local_function(): ... + | -------------- + 6 | test_unpacked_alias, _ = local_function, 0 + 7 | test_annotated_alias: object = local_function + | ^^^^^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:11:5 + | + 11 | test_imported_method = test_imported + | ^^^^^^^^^^^^^^^^^^^^ + | + ::: src/helpers.py:2:5 + | + 2 | def external_function(): ... + | ----------------- + "); + } + + #[test] + fn honors_module_opt_out() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +__test__: bool = True +__test__ = False +def test_example(): ... +"#, + ); + assert_snapshot!(test.collected_tests(), @"No tests collected"); + // File-wide collection returns early without checking individual bindings. + assert!(pytest_test_for_binding(&test.db, test.function("test_example")).is_none()); + } + + #[test] + fn honors_inherited_class_opt_outs() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +import unittest + +class TestDisabled: + __test__ = False + def test_disabled(self): ... + +class TestInherited(TestDisabled): + def test_inherited(self): ... + +class TestEnabled(TestDisabled): + __test__ = True + def test_enabled(self): ... + +class DisabledUnit(unittest.TestCase): + __test__ = False + def test_disabled_unit(self): ... +"#, + ); + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:13:9 + | + 13 | def test_enabled(self): ... + | ^^^^^^^^^^^^ + "); + } + + #[test] + fn excludes_abstract_test_classes() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +from abc import ABC, abstractmethod +import unittest + +class TestAbstract(ABC): + @abstractmethod + def value(self): ... + def test_abstract(self): ... + +class TestConcrete(TestAbstract): + def value(self): return 1 + def test_concrete(self): ... + +class AbstractUnit(unittest.TestCase, TestAbstract): + def test_abstract_unit(self): ... + +class TestABCWithoutAbstractMethods(ABC): + def test_concrete_abc(self): ... + +class TestWithoutABCMeta: + @abstractmethod + def test_without_abcmeta(self): ... +"#, + ); + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:12:9 + | + 12 | def test_concrete(self): ... + | ^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:18:9 + | + 18 | def test_concrete_abc(self): ... + | ^^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:22:9 + | + 22 | def test_without_abcmeta(self): ... + | ^^^^^^^^^^^^^^^^^^^^ + "); + } + + #[test] + fn excludes_known_property_decorators() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +from builtins import property as descriptor +import unittest + +@descriptor +def helper(self): ... +test_getter = helper.fget +test_property = helper + +class TestDescriptors: + @descriptor + def test_data(self): return 42 + + @staticmethod + @descriptor + def test_wrapped_data(self): return 42 + + @staticmethod + def test_staticmethod(): ... + + @classmethod + def test_classmethod(cls): ... + +class UnitDescriptors(unittest.TestCase): + @descriptor + def test_unit_data(self): return 42 + +def property[F](function: F) -> F: + return function + +@property +def test_custom_property_decorator(): ... +"#, + ); + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected pytest test + --> src/test_example.py:7:1 + | + 6 | def helper(self): ... + | ------ + 7 | test_getter = helper.fget + | ^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:19:9 + | + 19 | def test_staticmethod(): ... + | ^^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:22:9 + | + 22 | def test_classmethod(cls): ... + | ^^^^^^^^^^^^^^^^ + + info[pytest-collection]: Collected pytest test + --> src/test_example.py:32:5 + | + 32 | def test_custom_property_decorator(): ... + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + "); + } + + #[test] + fn collects_unittest_run_test_only_without_named_tests() { + let test = CollectionTest::new( + "/src/test_example.py", + r#" +import unittest + +def runTest(): ... +class TestOrdinary: + def runTest(self): ... + +class Fallback(unittest.TestCase): + def runTest(self): ... + +class Named(unittest.TestCase): + def test_named(self): ... + def runTest(self): ... + +class Inherited(Named): + def runTest(self): ... + +class NonCallable(unittest.TestCase): + test_data = None + @property + def test_property(self): return 1 + def runTest(self): ... +"#, + ); + assert_snapshot!(test.collected_tests(), @" + info[pytest-collection]: Collected unittest test + --> src/test_example.py:9:9 + | + 9 | def runTest(self): ... + | ^^^^^^^ + + info[pytest-collection]: Collected unittest test + --> src/test_example.py:12:9 + | + 12 | def test_named(self): ... + | ^^^^^^^^^^ + + info[pytest-collection]: Collected unittest test + --> src/test_example.py:22:9 + | + 22 | def runTest(self): ... + | ^^^^^^^ + "); + } + + struct CollectionTest { + db: TestDb, + path: &'static str, + } + + impl CollectionTest { + fn new(path: &'static str, source: &'static str) -> Self { + Self { + db: pytest_db(path, source), + path, + } + } + + fn program_file(&self) -> ProgramFile<'_> { + let file = system_path_to_file(&self.db, self.path).expect("test file should exist"); + self.db.program_file(file) + } + + fn function<'db>(&'db self, selector: &str) -> Definition<'db> { + let file = self.program_file(); + let module = parsed_module(&self.db, file.python_file(&self.db)).load(&self.db); + let function = find_function(module.suite(), selector).expect("test function exists"); + semantic_index(&self.db, file).expect_single_definition(function) + } + + fn collected_tests(&self) -> String { + collected_tests(&self.db, self.path) + } + } + + fn find_function<'ast>( + statements: &'ast [ast::Stmt], + selector: &str, + ) -> Option<&'ast ast::StmtFunctionDef> { + if let Some((class_name, nested)) = selector.split_once('.') { + return statements.iter().find_map(|statement| { + let class = statement.as_class_def_stmt()?; + (class.name.as_str() == class_name) + .then(|| find_function(&class.body, nested)) + .flatten() + }); + } + + statements.iter().find_map(|statement| { + statement + .as_function_def_stmt() + .filter(|function| function.name.as_str() == selector) + }) + } + + fn pytest_db(path: &'static str, source: &'static str) -> TestDb { + pytest_db_with_files(&[(path, source)]) + } + + fn pytest_db_with_files(files: &[(&'static str, &'static str)]) -> TestDb { + let builder = TestDbBuilder::new() + .with_third_party_packages() + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/__init__.pyi", + r#" +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/fixtures.pyi", + r#" +from typing import Any, Callable + +def fixture(function: Callable[..., Any] | None = ...) -> Any: ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/pytest/__init__.pyi", + r#" +from typing import Callable +from _pytest.fixtures import fixture as fixture + +class MarkGenerator: + def parametrize[F](self, argnames: str, argvalues: object) -> Callable[[F], F]: ... + +mark: MarkGenerator +"#, + ); + files + .iter() + .fold(builder, |builder, (path, source)| { + builder.with_file(path, source) + }) + .build() + .expect("valid pytest test database") + } + + fn collected_tests(db: &TestDb, path: &str) -> String { + let file = program_file(db, path); + let tests = pytest_tests_in_file(db, file); + if tests.is_empty() { + return "No tests collected".to_owned(); + } + + let module = parsed_module(db, file.python_file(db)).load(db); + // Render one diagnostic per result so the snapshot preserves collection order. + let diagnostics = tests + .iter() + .map(|test| { + let kind = match test.kind { + PytestTestKind::Pytest => "pytest", + PytestTestKind::StdlibUnittest => "unittest", + }; + let mut diagnostic = Diagnostic::new( + DiagnosticId::lint("pytest-collection"), + Severity::Info, + format_args!("Collected {kind} test"), + ); + let range = match test.binding.kind(db) { + DefinitionKind::ImportFrom(import) => { + let alias = import.alias(&module); + FileRange::new( + test.binding.file(db), + alias.asname.as_ref().unwrap_or(&alias.name).range(), + ) + } + _ => test.binding.focus_range(db, &module), + }; + diagnostic.annotate(Annotation::primary(range.into())); + if test.binding != test.function { + let module = parsed_module(db, test.function.python_file(db)).load(db); + diagnostic.annotate(Annotation::secondary( + test.function.focus_range(db, &module).into(), + )); + } + diagnostic + }) + .collect::>(); + + DisplayDiagnostics::new( + db, + &DisplayDiagnosticConfig::new("ty").context(0), + &diagnostics, + ) + .to_string() + .replace('\\', "/") + } + + fn program_file<'db>(db: &'db TestDb, path: &str) -> ProgramFile<'db> { + let file = system_path_to_file(db, path).expect("test file should exist"); + db.program_file(file) + } +} diff --git a/crates/ty_python_semantic/src/types/dedicated/pytest/fixtures.rs b/crates/ty_python_semantic/src/types/dedicated/pytest/fixtures.rs new file mode 100644 index 0000000000..aee22dfc0c --- /dev/null +++ b/crates/ty_python_semantic/src/types/dedicated/pytest/fixtures.rs @@ -0,0 +1,3752 @@ +//! Models the semantic relationships that pytest creates between fixtures and parameters. +//! +//! Pytest injects fixture values by matching parameter names to fixtures available in a particular +//! search scope. Ordinary Python name resolution does not represent that relationship: the +//! parameter is a local definition, and the fixture function may be defined in another scope. +//! This module therefore overlays the pytest relationship on top of the parameter's normal +//! Python definition (which is preserved). +//! +//! The model distinguishes four concepts: +//! +//! - A [`FixtureDeclaration`] is a function decorated with pytest's canonical `fixture` or +//! `yield_fixture` decorator. +//! - A [`FixtureExposure`] is the name under which that declaration is available during fixture +//! lookup. The decorator's `name` argument can make this differ from the Python binding name. +//! - A [`FixtureRequest`] is an eligible parameter in a collected test or another fixture function. +//! - A [`FixtureBinding`] links a request to the selected declaration and the exposures through +//! which it was found. +//! +//! For example: +//! +//! ```py +//! import pytest +//! +//! @pytest.fixture(name="database") # Exposure: the public fixture name is `database`. +//! def make_database(): # Declaration: this decorated function is the fixture identity. +//! return object() +//! +//! # Request: the parameter asks for the fixture exposed as `database`. +//! # Binding: fixture lookup connects the request to the `make_database` declaration. +//! def test_query(database): +//! assert database is not None +//! ``` +//! +//! We provide two entry points to this model: +//! +//! - [`fixture_bindings_for_parameter`]: Given a parameter definition, it classifies the parameter as +//! a possible request, inspects fixture search scopes in pytest precedence order, and returns every +//! equally viable declaration in the first matching scope. Language server and type-inference +//! features can consume this data without changing general definition, reference or rename behavior +//! for the parameter. +//! - [`fixture_exposures_for_definition`]: Given a definition, it returns the fixture exposures made +//! available by that definition, including those reached through imports. Each exposure records the +//! fixture's public name, canonical declaration, and local and source bindings. + +use std::cmp::Ordering; + +use itertools::Either; +use ruff_db::files::FileRange; +use ruff_db::files::system_path_to_file; +use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +use ruff_python_ast::{self as ast, name::Name}; +use ruff_text_size::TextRange; +use rustc_hash::FxHashSet; +use ty_module_resolver::{ + ImportingFile, KnownModule, ModuleName, file_to_module, resolve_module_for_import_from, + resolve_real_module_confident, stub_file_to_real_module, +}; +use ty_python_core::ast_node_ref::AstNodeRef; +use ty_python_core::definition::{Definition, DefinitionKind, ParameterDefinitionNodeKind}; +use ty_python_core::scope::{FileScopeId, ScopeId, ScopeKind}; +use ty_python_core::{ + Program, ProgramFile, global_scope, place_table, semantic_index, use_def_map, +}; + +use super::collection::{PytestTestKind, pytest_test_for_binding}; +use super::is_available_definition; +use crate::lexical_name_path::lexical_name_path_for_definition; +use crate::place::definitions::DefinitionResolution; +use crate::types::function::{FunctionType, KnownFunction}; +use crate::types::infer::{function_known_decorators, infer_definition_types, original_class_type}; +use crate::types::signatures::Parameter as SignatureParameter; +use crate::types::{ + ClassBase, ClassLiteral, KnownClass, ProgramEnvironment, Type, definition_expression_type, + extract_fixed_length_iterable_element_types, may_exist_at_runtime, +}; +use crate::{Db, FxIndexMap, FxIndexSet}; + +/// Resolves pytest fixtures requested by `parameter`. +/// +/// This function can be used to resolve either a fixture requested by a test +/// function (`consumer` in context "A" below) or a fixture requested by another +/// fixture (`dependency` in context "B" below): +/// +/// ```py +/// import pytest +/// +/// def test_consumer(consumer): ... # A +/// +/// @pytest.fixture +/// def dependency(): ... +/// +/// @pytest.fixture +/// def consumer(dependency): ... # B +/// ``` +/// +/// At present, we search the parameter's class hierarchy, module, enclosing +/// conftest hierarchy, and installed core pytest plugins. Fixtures from other +/// plugins are not yet supported. +/// +/// The resolution implemented here matches what pytest will actually resolve at +/// runtime for context A, but not necessarily for context B. To see why, +/// consider this example: +/// +/// ```py +/// import pytest +/// +/// def test_consumer(consumer): ... # A +/// +/// class TestOverride: +/// @pytest.fixture +/// def dependency(self): ... +/// +/// def test_consumer(self, consumer): ... # C +/// +/// @pytest.fixture +/// def dependency(): ... +/// +/// @pytest.fixture +/// def consumer(dependency): ... # B +/// ``` +/// +/// There is no single correct answer for the resolution at B in this example. +/// Rather, it depends on the test that requests `consumer`: if it is requested +/// at A then the correct answer is the global `dependency` fixture, but if it +/// is requested at C then the correct answer is `TestOverride.dependency`. We +/// might eventually return all statically reachable definitions of a fixture +/// named `dependency`, but for now we just resolve both A and B with the same +/// approach (a search through lexical scopes). That always resolves that to the +/// global `dependency` fixture at B even though that result is incomplete. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +pub fn fixture_bindings_for_parameter<'db>( + db: &'db dyn Db, + parameter: Definition<'db>, +) -> Box<[FixtureBinding<'db>]> { + let Some(request) = fixture_request_for_parameter(db, parameter) else { + return Box::default(); + }; + + // pytest creates the special `request` fixture on demand, so there is no decorated fixture + // declaration to return as a `FixtureBinding`. + // https://docs.pytest.org/en/stable/reference/reference.html#request + if request.name == "request" { + return Box::default(); + } + + // First, resolve bindings from the containing class scope, if any. + let containing_scope = request.function_definition.scope(db); + let class_scope = containing_scope + .node(db) + .as_class() + .map(|_| containing_scope.file_scope_id(db)); + if let Some(class_scope) = class_scope { + let file = parameter.program_file(db); + let index = semantic_index(db, file); + for class_ref in std::iter::successors(Some(class_scope), |scope| { + non_type_parameter_parent(index, *scope) + }) + .map_while(|scope| index.scope(scope).node().as_class()) + { + let class_definition = index.expect_single_definition(class_ref); + let Some(class) = original_class_type(db, class_definition) else { + return Box::default(); + }; + let bindings = bindings_in_search_scope(db, &request, FixtureSearchScope::Class(class)); + if !bindings.is_empty() { + return bindings; + } + } + } + + // Second, resolve bindings from the module scope. + let request_file = parameter.program_file(db); + let bindings = bindings_in_search_scope( + db, + &request, + FixtureSearchScope::Scope(global_scope(db, request_file)), + ); + if !bindings.is_empty() { + return bindings; + } + + // Third, resolve bindings from the conftest hierarchy. + for conftest in conftest_files(db, request_file) { + let bindings = bindings_in_search_scope( + db, + &request, + FixtureSearchScope::Scope(global_scope(db, conftest)), + ); + if !bindings.is_empty() { + return bindings; + } + } + + // Finally, search installed core plugins in reverse registration order. The legacy + // temporary-directory plugin is registered after the static core plugins, so it comes first. + if let Some(plugin) = pytest_legacy_tmpdir_plugin(db, request_file.program(db)) { + let bindings = bindings_in_search_scope(db, &request, FixtureSearchScope::Class(plugin)); + if !bindings.is_empty() { + return bindings; + } + } + for plugin in pytest_global_plugin_files(db, request_file.program(db)) + .iter() + .rev() + { + let bindings = bindings_in_search_scope( + db, + &request, + FixtureSearchScope::Scope(global_scope(db, *plugin)), + ); + if !bindings.is_empty() { + return bindings; + } + } + + Box::default() +} + +/// A pytest fixture request and the fixture selected by static fixture lookup. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub struct FixtureBinding<'db> { + request: Definition<'db>, + fixture: Definition<'db>, + exposures: Box<[FixtureExposure<'db>]>, +} + +impl<'db> FixtureBinding<'db> { + /// Returns the decorated function that declares the fixture. + pub fn fixture(&self) -> Definition<'db> { + self.fixture + } + + /// Returns the equally viable exposures through which the request reaches the fixture. + pub fn exposures(&self) -> &[FixtureExposure<'db>] { + &self.exposures + } +} + +/// Returns the available pytest fixture exposures contributed by `definition`. +/// +/// A decorated function contributes an exposure directly: +/// +/// ```python +/// # fixtures.py +/// import pytest +/// +/// @pytest.fixture +/// def resource(): ... +/// ``` +/// +/// Querying the definition of `resource` returns one exposure, schematically: +/// +/// ```text +/// FixtureExposure { +/// name: "resource", +/// local_binding: Definition(fixtures.resource), +/// fixture: Definition(fixtures.resource), +/// source_binding: None, +/// } +/// ``` +/// +/// An import contributes the exposures reachable through that import: +/// +/// ```python +/// # fixtures.py +/// import pytest +/// +/// @pytest.fixture +/// def resource(): ... +/// +/// # plugin.py +/// from fixtures import resource as helper +/// ``` +/// +/// Querying the import definition of `helper` returns: +/// +/// ```text +/// FixtureExposure { +/// name: "helper", +/// local_binding: Definition(plugin.helper), +/// fixture: Definition(fixtures.resource), +/// source_binding: Some(Definition(fixtures.resource)), +/// } +/// ``` +pub fn fixture_exposures_for_definition<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Vec> { + let index = semantic_index(db, definition.program_file(db)); + let definition_scope = definition.file_scope(db); + if !is_available_fixture_search_scope(db, index, definition_scope) + || !is_available_definition(db, definition) + { + return Vec::new(); + } + + let Some(symbol) = definition.place(db).as_symbol() else { + return Vec::new(); + }; + let name = place_table(db, definition.scope(db)).symbol(symbol).name(); + + exposures_contributed_by_definition(db, definition, name) +} + +/// Returns the installed core pytest plugin files in registration order. +pub fn pytest_global_plugin_files<'db>( + db: &'db dyn Db, + program: Program<'db>, +) -> &'db [ProgramFile<'db>] { + let Some(config_module) = resolve_real_module_confident( + db, + program.resolver_environment(db), + &KnownModule::PytestConfig.name(), + ) else { + return &[]; + }; + if !config_module.is_known(db, KnownModule::PytestConfig) { + return &[]; + } + let Some(config_file) = config_module.file(db) else { + return &[]; + }; + + pytest_global_plugin_files_from_config(db, program.program_file(db, config_file)) +} + +/// Reads the installed core pytest plugin files from the resolved configuration module. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +fn pytest_global_plugin_files_from_config<'db>( + db: &'db dyn Db, + config_file: ProgramFile<'db>, +) -> Box<[ProgramFile<'db>]> { + let Some(plugin_names) = + static_string_sequence_for_module_symbol(db, config_file, "default_plugins") + else { + return Box::default(); + }; + + let Some(config_module) = file_to_module(db, config_file.resolver_file(db)) else { + return Box::default(); + }; + + let config_search_path = config_module.search_path(db); + let resolver_environment = config_file.resolver_environment(db); + let mut seen = FxHashSet::default(); + let mut plugins = Vec::new(); + + for plugin_name in plugin_names { + let qualified_name = if plugin_name.starts_with("_pytest.") { + plugin_name + } else { + format!("_pytest.{plugin_name}") + }; + if !seen.insert(qualified_name.clone()) { + continue; + } + let Some(module_name) = ModuleName::new(&qualified_name) else { + tracing::debug!( + plugin_name = qualified_name, + "Skipping invalid pytest core plugin name" + ); + continue; + }; + let Some(module) = resolve_real_module_confident(db, resolver_environment, &module_name) + else { + continue; + }; + if module.search_path(db) != config_search_path { + continue; + } + if let Some(file) = module.file(db) { + plugins.push(ProgramFile::new(db, file, config_file.program(db))); + } + } + + plugins.into_boxed_slice() +} + +/// Returns pytest's dynamically registered legacy temporary-directory plugin class. +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] +fn pytest_legacy_tmpdir_plugin<'db>( + db: &'db dyn Db, + program: Program<'db>, +) -> Option> { + let mut legacypath_file = None; + let mut has_tmpdir = false; + + for file in pytest_global_plugin_files(db, program) { + let module = file_to_module(db, file.resolver_file(db))?; + match module.name(db).as_str() { + "_pytest.legacypath" => legacypath_file = Some(*file), + "_pytest.tmpdir" => has_tmpdir = true, + _ => {} + } + } + + // pytest registers this class during `pytest_configure` only when both plugins are active. + // https://github.com/pytest-dev/pytest/blob/9.1.1/src/_pytest/legacypath.py#L439-L459 + if has_tmpdir && let Some(file) = legacypath_file { + original_class_type(db, end_of_scope_definition(db, file, "LegacyTmpdirPlugin")?) + } else { + None + } +} + +/// An eligible fixture parameter and the context needed to resolve its request. +#[derive(Debug)] +struct FixtureRequest<'db> { + parameter_definition: Definition<'db>, + function_definition: Definition<'db>, + name: Name, +} + +/// Function-level information shared by all possible fixture requests in a signature. +struct FixtureRequestContext<'db, 'ast> { + function_definition: Definition<'db>, + function_type: FunctionType<'db>, + function: &'ast ast::StmtFunctionDef, + module: &'ast ParsedModuleRef, + index: &'db ty_python_core::SemanticIndex<'db>, + class_scope: Option, + is_fixture_dependency: bool, + mock_patch_count: usize, +} + +impl<'db, 'ast> FixtureRequestContext<'db, 'ast> { + /// Constructs a new context representing a function that can receive fixtures + /// through one or more parameters. + fn new( + db: &'db dyn Db, + function_ref: &'db AstNodeRef, + class_scope: Option, + module: &'ast ParsedModuleRef, + index: &'db ty_python_core::SemanticIndex<'db>, + ) -> Option { + let function_definition = index.expect_single_definition(function_ref); + let function = function_ref.node(module); + + // Parameters on fixture declarations request their values from other fixtures: + // + // ```py + // @pytest.fixture + // def database(): ... + // + // @pytest.fixture + // def service(database): ... # `database` is a fixture request. + // ``` + let is_fixture_dependency = !function.decorator_list.is_empty() + && fixture_declaration(db, function_definition).is_some(); + + // Pytest collects `unittest.TestCase` methods but does not inject fixtures into them. + // https://docs.pytest.org/en/9.0.x/how-to/unittest.html#pytest-features-in-unittest-testcase-subclasses + if !is_fixture_dependency + && pytest_test_for_binding(db, function_definition) + .is_none_or(|test| test.kind() != PytestTestKind::Pytest) + { + return None; + } + + let function_type = + infer_definition_types(db, function_definition).function_type(function_definition)?; + + Some(Self { + function_definition, + function_type, + function, + module, + index, + class_scope, + is_fixture_dependency, + mock_patch_count: mock_patch_count(db, function_definition, function), + }) + } + + /// Classifies the nearest non-type-parameter parent of a fixture-request function. + fn parent_scope( + index: &ty_python_core::SemanticIndex<'db>, + function_scope: FileScopeId, + ) -> Option { + let parent_scope = non_type_parameter_parent(index, function_scope)?; + match index.scope(parent_scope).kind() { + ScopeKind::Module => Some(FixtureRequestParentScope::Module), + ScopeKind::Class => Some(FixtureRequestParentScope::Class(parent_scope)), + _ => None, + } + } + + /// Returns the fixture request represented by `definition`, if it is eligible for injection. + fn fixture_request_for_parameter( + &self, + db: &'db dyn Db, + definition: Definition<'db>, + ) -> Option> { + let signature_parameters = self + .function_type + .last_definition_signature(db) + .parameters(); + let parameter = signature_parameters + .iter() + .find(|parameter| parameter.definition() == Some(definition))?; + let parameter_name = parameter.keyword_name()?; + + // Match pytest's logic for only injecting fixtures for required + // parameters and by keyword: + // https://docs.pytest.org/en/9.0.x/how-to/fixtures.html#requesting-fixtures + // https://github.com/pytest-dev/pytest/blob/9.0.1/src/_pytest/compat.py#L145-L153 + if parameter.has_default() { + return None; + } + + if self.function_type.has_implicit_receiver(db) + && signature_parameters + .get_positional(0) + .is_some_and(|parameter| parameter.definition() == Some(definition)) + { + return None; + } + + if self.is_mock_patch_parameter(db, definition) { + return None; + } + + if !self.is_fixture_dependency && self.directly_parametrized(db, parameter_name.as_str()) { + return None; + } + + Some(FixtureRequest { + parameter_definition: definition, + function_definition: self.function_definition, + name: parameter_name.clone(), + }) + } + + /// Returns whether `parameter` is supplied by `unittest.mock.patch`. + fn is_mock_patch_parameter( + &self, + db: &'db dyn Db, + parameter_definition: Definition<'db>, + ) -> bool { + if self.mock_patch_count == 0 { + return false; + } + + let signature = self.function_type.last_definition_signature(db); + let parameters = signature.parameters(); + let is_source_keyword_parameter = |parameter: &SignatureParameter<'db>| { + parameter.keyword_name().is_some() + // ty applies PEP 484's legacy positional-only convention to leading `__name` + // parameters, but Python and pytest still inspect them as positional-or-keyword. + || (self.function.parameters.posonlyargs.is_empty() + && parameter.is_positional_only()) + }; + let skips_receiver = self.function_type.has_implicit_receiver(db) + && parameters + .get_positional(0) + .is_some_and(is_source_keyword_parameter); + + parameters + .iter() + .filter(|parameter| is_source_keyword_parameter(parameter) && !parameter.has_default()) + .skip(usize::from(skips_receiver)) + .take(self.mock_patch_count) + .any(|candidate| candidate.definition() == Some(parameter_definition)) + } + + /// Returns whether static parametrization on the function or an enclosing class prevents this + /// fixture request. + fn directly_parametrized(&self, db: &'db dyn Db, parameter_name: &str) -> bool { + if !self.function.decorator_list.is_empty() { + let decorators = function_known_decorators(db, self.function_definition); + if self.function.decorator_list.iter().any(|decorator| { + mark_excludes_fixture( + db, + self.function_definition, + &decorator.expression, + parameter_name, + |expression| decorators.expression_type(expression), + ) + }) { + return true; + } + } + + std::iter::successors(self.class_scope, |class_scope| { + let parent = non_type_parameter_parent(self.index, *class_scope)?; + (self.index.scope(parent).kind() == ScopeKind::Class).then_some(parent) + }) + .any(|class_scope| { + let class_ref = self.index.scope(class_scope).node().expect_class(); + let definition = self.index.expect_single_definition(class_ref); + class_ref + .node(self.module) + .decorator_list + .iter() + .any(|decorator| { + mark_excludes_fixture( + db, + definition, + &decorator.expression, + parameter_name, + |expression| Some(definition_expression_type(db, definition, expression)), + ) + }) + }) + } +} + +enum FixtureRequestParentScope { + Module, + Class(FileScopeId), +} + +impl FixtureRequestParentScope { + fn class_scope(self) -> Option { + match self { + Self::Module => None, + Self::Class(scope) => Some(scope), + } + } +} + +/// Returns the fixture request represented by `definition`, if it is eligible for injection. +fn fixture_request_for_parameter<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Option> { + let DefinitionKind::Parameter(ParameterDefinitionNodeKind::Parameter(_)) = definition.kind(db) + else { + return None; + }; + + let file = definition.program_file(db); + let index = semantic_index(db, file); + let function_scope = definition.scope(db).file_scope_id(db); + let function_ref = index.scope(function_scope).node().as_function()?; + let class_scope = FixtureRequestContext::parent_scope(index, function_scope)?.class_scope(); + let module = parsed_module(db, file.python_file(db)).load(db); + let context = FixtureRequestContext::new(db, function_ref, class_scope, &module, index)?; + + context.fixture_request_for_parameter(db, definition) +} + +/// Returns the number of parameters supplied by `unittest.mock.patch`. +fn mock_patch_count<'db>( + db: &'db dyn Db, + function_definition: Definition<'db>, + function: &ast::StmtFunctionDef, +) -> usize { + if function.decorator_list.is_empty() { + return 0; + } + + let decorators = function_known_decorators(db, function_definition); + function + .decorator_list + .iter() + .filter(|decorator| { + let Some(call) = decorator.expression.as_call_expr() else { + return false; + }; + let new_position = if is_known_class_instance( + db, + function_definition, + decorators.expression_type(&call.func), + "_patcher", + &[KnownModule::UnittestMock], + ) { + 1 + } else if let Some(attribute) = call.func.as_attribute_expr() + && attribute.attr.as_str() == "object" + && is_known_class_instance( + db, + function_definition, + decorators.expression_type(&attribute.value), + "_patcher", + &[KnownModule::UnittestMock], + ) + { + 2 + } else { + return false; + }; + + call.arguments + .find_argument_value("new", new_position) + .is_none_or(|new| { + // Typeshed exposes `DEFAULT` as `Any`, so any dynamic value may enable + // positional injection. + matches!(decorators.expression_type(new), Some(Type::Dynamic(_))) + }) + }) + .count() +} + +/// A decorated fixture function. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(super) struct FixtureDeclaration<'db> { + // The definition for the fixture function. + definition: Definition<'db>, + // The way in which the fixture exposes a name. + name: FixtureName, +} + +/// A fixture made available through one Python binding. +/// +/// For example, consider a fixture re-exported through two aliases: +/// +/// ```python +/// # fixtures.py +/// import pytest +/// +/// @pytest.fixture +/// def resource(): ... # fixture +/// +/// # reexports.py +/// from fixtures import resource as helper # source_binding +/// +/// # plugin.py +/// from reexports import helper as test_resource # local_binding; name = "test_resource" +/// ``` +/// +/// The exposure contributed by `test_resource` points to `helper` as its immediate source and to +/// `resource` as the canonical fixture declaration. +#[derive(Debug, Clone, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub struct FixtureExposure<'db> { + /// The name used to request this fixture (`"test_resource"` in the example above). + name: Name, + /// The local Python binding that exposes the fixture (`test_resource` in the example above). + local_binding: Definition<'db>, + /// The decorated function that declares the fixture (`resource` in the example above). + fixture: Definition<'db>, + /// The immediately preceding binding (`helper` in the example above), if any. + /// + /// This is `None` for a direct fixture declaration. For a stub backed by a runtime + /// implementation, it is also `None` so the two definitions have separate reference families. + source_binding: Option>, +} + +impl<'db> FixtureExposure<'db> { + /// Exposes a declaration under its explicit fixture name or local Python binding name. + fn new( + symbol_name: &Name, + local_binding: Definition<'db>, + declaration: &FixtureDeclaration<'db>, + source_binding: Option>, + ) -> Option { + let name = match &declaration.name { + FixtureName::Default => symbol_name.clone(), + FixtureName::Explicit { name, .. } => name.clone(), + FixtureName::Unknown => return None, + }; + Some(Self { + name, + local_binding, + fixture: declaration.definition, + source_binding, + }) + } + + /// Returns the public name that pytest uses to request this exposure. + pub fn name(&self) -> &Name { + &self.name + } + + /// Returns the local Python binding through which this fixture is exposed. + pub fn local_binding(&self) -> Definition<'db> { + self.local_binding + } + + /// Returns the decorated function that declares the fixture. + pub fn fixture(&self) -> Definition<'db> { + self.fixture + } + + /// Returns the binding from which this exposure was imported, if any. + pub fn source_binding(&self) -> Option> { + self.source_binding + } + + /// Returns the binding or decorator from which this exposure gets its public name. + pub fn name_source(&self, db: &'db dyn Db) -> FixtureNameSource<'db> { + let Some(declaration) = fixture_declaration(db, self.fixture) else { + return FixtureNameSource::Binding(self.local_binding); + }; + + match &declaration.name { + FixtureName::Explicit { range, .. } => FixtureNameSource::Explicit { + fixture: self.fixture, + declaration: range.map(|range| FileRange::new(self.fixture.file(db), range)), + }, + FixtureName::Default | FixtureName::Unknown => { + FixtureNameSource::Binding(self.local_binding) + } + } + } +} + +/// The source from which a fixture obtains its public name. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub enum FixtureNameSource<'db> { + /// The Python binding that supplies the fixture name. + Binding(Definition<'db>), + /// An explicit fixture name supplied by the decorated function. + Explicit { + /// The decorated function that declares the fixture. + fixture: Definition<'db>, + /// The fixture-name literal's file and content range when it is one string literal. + declaration: Option, + }, +} + +/// A possible fixture name and the exposures it contributes to a fixture search scope. +/// +/// Bound names in class scopes are retained even without fixture exposures because they can +/// shadow fixtures inherited from another class in the searched class's MRO. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +struct FixtureNameCandidate<'db> { + name: Name, + exposures: Box<[FixtureExposure<'db>]>, +} + +/// How a fixture decorator determines the fixture's public name. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +enum FixtureName { + /// Uses the Python binding name at the exposure site. + Default, + /// Uses a statically known explicit name. + Explicit { + name: Name, + range: Option, + }, + /// Represents a public name that ty cannot determine statically, such as a + /// dynamically-typed expression or a non-literal `str`. + Unknown, +} + +/// Returns whether `scope` and each enclosing class remain available from their parent scopes. +fn is_available_fixture_search_scope<'db>( + db: &'db dyn Db, + index: &ty_python_core::SemanticIndex<'db>, + scope: FileScopeId, +) -> bool { + match index.scope(scope).kind() { + ScopeKind::Module => true, + ScopeKind::Class => non_type_parameter_parent(index, scope).is_some_and(|parent| { + if !is_available_fixture_search_scope(db, index, parent) { + return false; + } + + let class_ref = index.scope(scope).node().expect_class(); + let definition = index.expect_single_definition(class_ref); + is_available_definition(db, definition) + }), + _ => false, + } +} + +/// A class hierarchy or scope searched when resolving a fixture request. +#[derive(Clone, Copy)] +enum FixtureSearchScope<'db> { + /// Searches a class and its statically known ancestors. + Class(ClassLiteral<'db>), + /// Searches a single scope. + Scope(ScopeId<'db>), +} + +/// Returns the names that may participate in fixture lookup for one fixture search scope. +/// +/// Separating this summary from request resolution lets Salsa reuse scope-specific fixture +/// discovery across parameters. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +fn fixture_name_candidates<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, +) -> Box<[FixtureNameCandidate<'db>]> { + let is_class_scope = scope.node(db).scope_kind() == ScopeKind::Class; + let table = place_table(db, scope); + let mut name_candidates = Vec::new(); + + for (symbol_id, bindings) in use_def_map(db, scope).all_end_of_scope_symbol_bindings() { + let symbol = table.symbol(symbol_id); + let name = symbol.name(); + let resolution = DefinitionResolution::from_bindings(db, bindings); + let exposures = + fixture_exposures_from_symbol_definitions(db, name, resolution.definitions()); + + // Reject names that neither expose a fixture nor bind a runtime class attribute that + // can shadow an inherited fixture. + if exposures.is_empty() + && !(is_class_scope + && symbol.is_bound() + && class_attribute_exists_at_runtime(db, resolution.definitions())) + { + continue; + } + name_candidates.push(FixtureNameCandidate { + name: name.clone(), + exposures: exposures.into_boxed_slice(), + }); + } + + name_candidates.into_boxed_slice() +} + +/// Returns fixture exposures reachable from the resolved definitions for one symbol. +fn fixture_exposures_from_symbol_definitions<'db>( + db: &'db dyn Db, + name: &Name, + definitions: &[Definition<'db>], +) -> Vec> { + let mut exposures = FxIndexSet::default(); + + for definition in definitions.iter().copied() { + exposures.extend(exposures_contributed_by_definition(db, definition, name)); + } + + exposures.into_iter().collect() +} + +/// Returns whether a class attribute has any reachable runtime binding. +fn class_attribute_exists_at_runtime<'db>( + db: &'db dyn Db, + definitions: &[Definition<'db>], +) -> bool { + definitions + .iter() + .any(|definition| may_exist_at_runtime(db, *definition)) +} + +/// Resolves a request against the fixture exposures in `search_scope`. +fn bindings_in_search_scope<'db>( + db: &'db dyn Db, + request: &FixtureRequest<'db>, + search_scope: FixtureSearchScope<'db>, +) -> Box<[FixtureBinding<'db>]> { + let search_scopes = match search_scope { + FixtureSearchScope::Class(class) => Either::Left( + class + .iter_mro(db) + .filter_map(ClassBase::into_class) + .filter(|ancestor| !ancestor.is_object(db)) + .filter_map(|ancestor| ancestor.static_class_literal(db)) + .map(|(ancestor, _)| ancestor.body_scope(db)), + ), + FixtureSearchScope::Scope(scope) => Either::Right(std::iter::once(scope)), + }; + + let mut seen_names = FxHashSet::default(); + let mut winning_name: Option<&Name> = None; + let mut fixtures: FxIndexMap, Vec>> = + FxIndexMap::default(); + + for scope in search_scopes { + for name_candidate in fixture_name_candidates(db, scope) { + let symbol_name = &name_candidate.name; + // A name supplied by an earlier scope shadows the same name here. + if !seen_names.insert(symbol_name) { + continue; + } + + for exposure in &name_candidate.exposures { + // Request must match public name of the fixture + if request.name != exposure.name + // A fixture definition cannot fulfill a request for itself + || request.function_definition == exposure.fixture + { + continue; + } + + // Semantic-index traversal is unordered. Pytest registers fixture attributes in + // sorted `dir()` order and selects the last registration, so retain bindings for + // the lexicographically last matching attribute. Thus, if `first_fixture` and + // `second_fixture` both expose `resource`, `second_fixture` wins. + // + // `dir()` ordering: https://docs.python.org/3/library/functions.html#dir + // Fixture discovery: https://github.com/pytest-dev/pytest/blob/9.0.1/src/_pytest/fixtures.py#L1852-L1880 + // Registration order: https://github.com/pytest-dev/pytest/blob/9.0.1/src/_pytest/fixtures.py#L1788-L1797 + // Fixture selection: https://github.com/pytest-dev/pytest/blob/9.0.1/src/_pytest/fixtures.py#L583-L599 + match winning_name.map(|winner| winner.cmp(symbol_name)) { + Some(Ordering::Greater) => continue, + Some(Ordering::Less) | None => { + winning_name = Some(symbol_name); + fixtures.clear(); + } + Some(Ordering::Equal) => {} + } + let exposures = fixtures.entry(exposure.fixture).or_default(); + if !exposures.contains(exposure) { + exposures.push(exposure.clone()); + } + } + } + } + + fixtures + .into_iter() + .map(|(fixture, exposures)| FixtureBinding { + request: request.parameter_definition, + fixture, + exposures: exposures.into_boxed_slice(), + }) + .collect() +} + +/// Returns applicable `conftest.py` files from nearest to outermost. +fn conftest_files<'db>(db: &'db dyn Db, request_file: ProgramFile<'db>) -> Vec> { + let Some(path) = request_file.file(db).path(db).as_system_path() else { + return Vec::new(); + }; + + let program = request_file.program(db); + let Some(root) = program + .search_paths(db) + .first_party_roots() + .filter(|root| path.starts_with(*root)) + .min_by_key(|root| root.components().count()) + else { + return Vec::new(); + }; + let Some(request_directory) = path.parent() else { + return Vec::new(); + }; + + let start_directory = if path.file_name() == Some("conftest.py") { + // The caller already searched the request file as a module search scope. + // Start in its parent when it is itself a conftest to avoid searching + // the same search scope twice. + request_directory.parent() + } else { + Some(request_directory) + }; + let Some(start_directory) = start_directory else { + return Vec::new(); + }; + + start_directory + .ancestors() + .take_while(|directory| directory.starts_with(root)) + .filter_map(|directory| system_path_to_file(db, directory.join("conftest.py")).ok()) + .map(|file| ProgramFile::new(db, file, program)) + .collect() +} + +/// Returns a fixture declaration for a function with a canonical pytest fixture decorator. +#[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] +pub(super) fn fixture_declaration<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> Option> { + let DefinitionKind::Function(function_ref) = definition.kind(db) else { + return None; + }; + let module = parsed_module(db, definition.python_file(db)).load(db); + let function = function_ref.node(&module); + let first_decorator = &function.decorator_list.first()?.expression; + let inference = function_known_decorators(db, definition); + let expression = if definition.scope(db).node(db).scope_kind() == ScopeKind::Class + && matches!( + inference.expression_type(first_decorator), + Some(Type::ClassLiteral(class)) if class.is_known(db, KnownClass::Staticmethod) + ) { + // Pytest discovers fixtures on plugin classes through class attribute access. For + // example: + // + // ```py + // class LegacyTmpdirPlugin: + // @staticmethod + // @fixture + // def tmpdir(): ... + // ``` + // + // Accessing `LegacyTmpdirPlugin.tmpdir` invokes the `@staticmethod` descriptor and exposes + // the fixture wrapper beneath it. Inspect the inner decorator to match that lookup. + let fixture_decorator = function.decorator_list.get(1)?; + &fixture_decorator.expression + } else { + first_decorator + }; + let (callee, arguments) = match expression { + ast::Expr::Call(call) => (call.func.as_ref(), Some(&call.arguments)), + expression => (expression, None), + }; + let Type::FunctionLiteral(decorator) = inference.expression_type(callee)? else { + return None; + }; + if !matches!( + decorator.known(db), + Some(KnownFunction::PytestFixture | KnownFunction::PytestYieldFixture) + ) { + return None; + } + + let name = arguments.map_or(FixtureName::Default, |arguments| { + fixture_name_from_arguments(db, arguments, &|expression| { + inference.expression_type(expression) + }) + }); + Some(FixtureDeclaration { definition, name }) +} + +/// Returns fixture exposures contributed by a symbol definition. +fn exposures_contributed_by_definition<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + symbol_name: &Name, +) -> Vec> { + if definition.file(db).is_stub(db) + && let Some(source_file) = + stub_file_to_real_module(db, definition.program_file(db).resolver_file(db)) + .and_then(|module| module.file(db)) + { + let source_file = ProgramFile::new(db, source_file, definition.program(db)); + let source_exposures = if definition.scope(db).node(db).scope_kind() == ScopeKind::Class { + fixture_exposures_from_stub_class_definition(db, definition, source_file) + } else { + fixture_exposures_for_name_in_scope( + db, + global_scope(db, source_file), + symbol_name.clone(), + ) + .to_vec() + }; + + // The stub is the binding visible to callers. Keep the runtime fixture as the canonical + // declaration, but don't link the stub's exposure to the runtime binding, so their + // references remain in separate families. This matches reference behavior for ordinary + // Python symbols. + return source_exposures + .into_iter() + .map(|exposure| FixtureExposure { + local_binding: definition, + source_binding: None, + ..exposure + }) + .collect(); + } + + let kind = definition.kind(db); + if !matches!( + &kind, + DefinitionKind::Function(_) | DefinitionKind::ImportFrom(_) | DefinitionKind::StarImport(_) + ) { + return Vec::new(); + } + if !may_exist_at_runtime(db, definition) { + return Vec::new(); + } + + match kind { + DefinitionKind::Function(_) => { + let Some(declaration) = fixture_declaration(db, definition) else { + return Vec::new(); + }; + let Some(exposure) = FixtureExposure::new(symbol_name, definition, declaration, None) + else { + return Vec::new(); + }; + vec![exposure] + } + DefinitionKind::ImportFrom(import) => { + let parsed = parsed_module(db, definition.python_file(db)).load(db); + fixture_exposures_from_import( + db, + definition, + import.import(&parsed), + import.alias(&parsed).name.id(), + symbol_name, + ) + } + DefinitionKind::StarImport(import) => { + let parsed = parsed_module(db, definition.python_file(db)).load(db); + fixture_exposures_from_import( + db, + definition, + import.import(&parsed), + symbol_name, + symbol_name, + ) + } + _ => Vec::new(), + } +} + +/// Returns fixture exposures for a stub class member from its runtime source class. +/// +/// For example, given these corresponding files: +/// +/// ```python +/// # plugin.pyi +/// class Plugin: +/// def resource(self): ... +/// +/// # plugin.py +/// class Plugin: +/// @pytest.fixture +/// def resource(self): ... +/// ``` +/// +/// The stub definition yields the lexical path `["Plugin", "resource"]`. This function finds the +/// `Plugin` scope in the source file, then resolves the visible `resource` definitions in that scope. +fn fixture_exposures_from_stub_class_definition<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + source_file: ProgramFile<'db>, +) -> Vec> { + let Some(path) = lexical_name_path_for_definition(db, definition) else { + return Vec::new(); + }; + let mut path = path; + let Some(member_name) = path.pop() else { + return Vec::new(); + }; + + let mut exposures = FxIndexSet::default(); + for source_scope in source_scopes_for_lexical_path(db, source_file, path.into_boxed_slice()) { + exposures.extend( + fixture_exposures_for_name_in_scope(db, *source_scope, member_name.clone()) + .iter() + .cloned(), + ); + } + + exposures.into_iter().collect() +} + +/// Returns scopes in `source_file` with the given lexical path. +#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)] +fn source_scopes_for_lexical_path<'db>( + db: &'db dyn Db, + source_file: ProgramFile<'db>, + path: Box<[Name]>, +) -> Box<[ScopeId<'db>]> { + let index = semantic_index(db, source_file); + let parsed = parsed_module(db, source_file.python_file(db)).load(db); + let mut scopes = vec![global_scope(db, source_file)]; + let mut next_scopes = FxIndexSet::default(); + + for name in path { + for scope in scopes { + next_scopes.extend( + index + .child_scopes(scope.file_scope_id(db)) + .filter(|(_, child_scope)| { + matches!(child_scope.kind(), ScopeKind::Class | ScopeKind::Function) + }) + .map(|(child_scope_id, _)| child_scope_id.to_scope_id(db, source_file)) + .filter(|child_scope| child_scope.name(db, &parsed) == name.as_str()), + ); + } + if next_scopes.is_empty() { + return Box::default(); + } + scopes = next_scopes.drain(..).collect(); + } + + scopes.into_boxed_slice() +} + +/// Returns fixture exposures supplied by a name's end-of-scope bindings. +#[allow( + clippy::needless_pass_by_value, + reason = "Salsa requires owned query keys, and lint expectations cannot observe diagnostics from the generated function" +)] +#[salsa::tracked( + returns(deref), + cycle_initial=|_, _, _, _| Box::default(), + heap_size=ruff_memory_usage::heap_size +)] +fn fixture_exposures_for_name_in_scope<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + name: Name, +) -> Box<[FixtureExposure<'db>]> { + let Some(symbol) = place_table(db, scope).symbol_id(name.as_str()) else { + return Box::default(); + }; + + let resolution = DefinitionResolution::from_bindings( + db, + use_def_map(db, scope).end_of_scope_symbol_bindings(symbol), + ); + fixture_exposures_from_symbol_definitions(db, &name, resolution.definitions()) + .into_boxed_slice() +} + +/// Follows an import to fixture exposures supplied by its target. +fn fixture_exposures_from_import<'db>( + db: &'db dyn Db, + importing_definition: Definition<'db>, + import: &ast::StmtImportFrom, + imported_name: &Name, + local_name: &Name, +) -> Vec> { + let program_file = importing_definition.program_file(db); + let importing_file = + ImportingFile::File(program_file.file(db), program_file.resolver_environment(db)); + let Some(imported_module) = resolve_module_for_import_from(db, importing_file, import) else { + return Vec::new(); + }; + + let Some(imported_file) = imported_module.file(db) else { + return Vec::new(); + }; + let source_exposures = fixture_exposures_for_name_in_scope( + db, + global_scope( + db, + ProgramFile::new(db, imported_file, program_file.program(db)), + ), + imported_name.clone(), + ); + + source_exposures + .iter() + .filter_map(|source| { + let declaration = fixture_declaration(db, source.fixture).as_ref()?; + FixtureExposure::new( + local_name, + importing_definition, + declaration, + Some(source.local_binding), + ) + }) + .collect() +} + +/// Classifies the `name` argument to a fixture decorator. +fn fixture_name_from_arguments<'db>( + db: &'db dyn Db, + arguments: &ast::Arguments, + expression_type: &impl Fn(&ast::Expr) -> Option>, +) -> FixtureName { + let Some(name_keyword) = arguments.find_keyword("name") else { + return FixtureName::Default; + }; + + let Some(name_type) = expression_type(&name_keyword.value) else { + return FixtureName::Unknown; + }; + if name_type.is_none(db) { + return FixtureName::Default; + } + let Some(name) = name_type.as_string_literal().map(|string| string.value(db)) else { + return FixtureName::Unknown; + }; + if name.is_empty() { + return FixtureName::Default; + } + + FixtureName::Explicit { + name: Name::new(name), + range: fixture_name_literal_range(&name_keyword.value, name), + } +} + +/// Returns the content range when `expression` spells `name` as one string literal. +fn fixture_name_literal_range(expression: &ast::Expr, name: &str) -> Option { + expression + .as_string_literal_expr()? + .as_single_part_string() + .filter(|literal| literal.as_str() == name) + .map(ast::StringLiteral::content_range) +} + +/// Returns a scope's lexical parent, skipping an intervening type-parameter scope. +fn non_type_parameter_parent( + index: &ty_python_core::SemanticIndex<'_>, + scope: FileScopeId, +) -> Option { + let parent = index.parent_scope_id(scope)?; + if index.scope(parent).kind() == ScopeKind::TypeParams { + index.parent_scope_id(parent) + } else { + Some(parent) + } +} + +/// Returns whether a static mark supplies this parameter directly or cannot be interpreted. +fn mark_excludes_fixture<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + expression: &ast::Expr, + parameter_name: &str, + expression_type: impl Fn(&ast::Expr) -> Option>, +) -> bool { + let Some(call) = expression.as_call_expr() else { + return false; + }; + if !expression_type(&call.func) + .is_some_and(|ty| ty.is_instance_of(db, KnownClass::PytestParametrizeMarkDecorator)) + { + return false; + } + + let Some(names) = call + .arguments + .find_argument_value("argnames", 0) + .and_then(|argnames| { + statically_known_parametrize_names(db, definition, argnames, &expression_type) + }) + else { + return true; + }; + if !names.contains(¶meter_name) { + return false; + } + + is_indirect( + db, + definition, + &call.arguments, + parameter_name, + &expression_type, + ) != Some(true) +} + +/// Returns whether a type is an instance of `class_name` from one of `modules`. +fn is_known_class_instance( + db: &dyn Db, + definition: Definition<'_>, + ty: Option>, + class_name: &str, + modules: &[KnownModule], +) -> bool { + let Some(Type::NominalInstance(instance)) = ty else { + return false; + }; + let environment = ProgramEnvironment::from_file(definition.program_file(db)); + let Some(class) = instance + .class(db, &environment) + .class_literal(db) + .as_static() + else { + return false; + }; + + class.name(db) == class_name + && file_to_module(db, class.program_file(db).resolver_file(db)) + .and_then(|module| module.known(db)) + .is_some_and(|module| modules.contains(&module)) +} + +/// Returns how `parameter_name` is configured by the `indirect` argument. +/// +/// `Some(true)` means the parameter is definitely indirect, `Some(false)` means it is definitely +/// direct, and `None` preserves uncertainty when the argument cannot be interpreted statically. +fn is_indirect<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + arguments: &ast::Arguments, + parameter_name: &str, + expression_type: &impl Fn(&ast::Expr) -> Option>, +) -> Option { + let Some(expression) = arguments.find_argument_value("indirect", 2) else { + return Some(false); + }; + let ty = expression_type(expression)?; + if ty == Type::bool_literal(true) { + return Some(true); + } + if ty == Type::bool_literal(false) { + return Some(false); + } + statically_known_parametrize_names(db, definition, expression, expression_type) + .map(|names| names.contains(¶meter_name)) +} + +/// Returns statically known pytest parametrization names from a string or fixed-length iterable. +fn statically_known_parametrize_names<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + expression: &ast::Expr, + expression_type: &impl Fn(&ast::Expr) -> Option>, +) -> Option> { + let ty = expression_type(expression)?; + if let Some(string) = ty.as_string_literal() { + return Some( + string + .value(db) + .split(|character: char| character == ',' || character.is_whitespace()) + .filter(|name| !name.is_empty()) + .collect(), + ); + } + + let environment = ProgramEnvironment::from_file(definition.program_file(db)); + extract_fixed_length_iterable_element_types(db, &environment, expression, |element| { + expression_type(element).unwrap_or_else(Type::unknown) + })? + .iter() + .map(|element| element.as_string_literal().map(|string| string.value(db))) + .collect() +} + +/// Returns the statically known string sequence bound to a module symbol. +/// +/// For example, given pytest-style plugin registration: +/// +/// ```py +/// essential_plugins = ("mark", "main") +/// default_plugins = (*essential_plugins, "fixtures") +/// ``` +/// +/// Looking up `default_plugins` returns `['mark', 'main', 'fixtures']`. +fn static_string_sequence_for_module_symbol( + db: &dyn Db, + file: ProgramFile<'_>, + name: &str, +) -> Option> { + let definition = end_of_scope_definition(db, file, name)?; + let module = parsed_module(db, file.python_file(db)).load(db); + let expression = match definition.kind(db) { + DefinitionKind::Assignment(assignment) => Some(assignment.value(&module)), + DefinitionKind::AnnotatedAssignment(assignment) => assignment.value(&module), + _ => None, + }?; + static_string_sequence_from_expression(db, definition, expression) +} + +/// Evaluates a static string sequence while preserving the order of tuple concatenation. +/// +/// Some supported pytest releases use tuple concatenation to define their plugin registration +/// order: +/// +/// ```python +/// essential_plugins = ("mark", "main") +/// # Inferred: tuple[Literal["mark"], Literal["main"]] +/// +/// default_plugins = essential_plugins + ("fixtures",) +/// # Inferred through tuple.__add__: tuple[Literal["mark", "main", "fixtures"], ...] +/// ``` +/// +/// The `tuple.__add__` return annotation produces a variable-length tuple whose element type is the +/// union of these literals. Although ty currently displays the union members in first-occurrence +/// order, the type neither (1) requires every member to occur nor (2) records their order. +/// Evaluating the operands separately preserves the concrete sequence. +fn static_string_sequence_from_expression<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + expression: &ast::Expr, +) -> Option> { + if let ast::Expr::BinOp(binary) = expression + && binary.op == ast::Operator::Add + { + let mut strings = static_string_sequence_from_expression(db, definition, &binary.left)?; + strings.extend(static_string_sequence_from_expression( + db, + definition, + &binary.right, + )?); + return Some(strings); + } + + let environment = ProgramEnvironment::from_definition(definition); + extract_fixed_length_iterable_element_types(db, &environment, expression, |element| { + definition_expression_type(db, definition, element) + })? + .iter() + .map(|element| { + element + .as_string_literal() + .map(|string| string.value(db).to_owned()) + }) + .collect() +} + +/// Returns the sole definition bound to a module symbol at the end of its scope. +fn end_of_scope_definition<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + name: &str, +) -> Option> { + let scope = global_scope(db, file); + let symbol = place_table(db, scope).symbol_id(name)?; + let mut definitions = use_def_map(db, scope) + .end_of_scope_symbol_bindings(symbol) + .filter_map(|binding| binding.binding.definition()); + let definition = definitions.next()?; + definitions.next().is_none().then_some(definition) +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use ruff_db::diagnostic::{ + Annotation, Diagnostic, DiagnosticId, DisplayDiagnosticConfig, DisplayDiagnostics, + Severity, SubDiagnostic, SubDiagnosticSeverity, + }; + use ruff_db::files::system_path_to_file; + use ruff_db::parsed::parsed_module; + use ruff_db::system::{DbWithWritableSystem, SystemPathBuf}; + use ruff_python_ast as ast; + use ruff_text_size::Ranged; + use ty_python_core::definition::Definition; + use ty_python_core::semantic_index; + + use super::{ + FixtureExposure, FixtureNameSource, end_of_scope_definition, + fixture_bindings_for_parameter, fixture_exposures_for_definition, + pytest_global_plugin_files, + }; + use crate::Db as _; + use crate::db::tests::{TestDb, TestDbBuilder}; + + #[test] + fn resolves_same_file_fixture_declarations_and_dependencies() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest +from pytest import fixture as make_fixture, yield_fixture + +@pytest.fixture +def database(): ... + +@make_fixture() +@pytest.mark.parametrize("database", [1]) +def service(database): ... + +@yield_fixture() +def legacy_cache(): ... + +def test_use(database, service, legacy_cache): ... + +def wrapper(function): return lambda: function() + +@wrapper +@pytest.fixture +def wrapped(): ... + +def test_wrapped(wrapped): ... + +@staticmethod +@pytest.fixture +def module_staticmethod(): ... + +def test_module_staticmethod(module_staticmethod): ... +"#, + ); + + let service = test.function("service"); + let test_use = test.function("test_use"); + let test_wrapped = test.function("test_wrapped"); + + assert_snapshot!(service.fixture_resolution("database"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:10:13 + | + 10 | def service(database): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def database(): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("database"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:15:14 + | + 15 | def test_use(database, service, legacy_cache): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def database(): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("service"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:15:24 + | + 15 | def test_use(database, service, legacy_cache): ... + | ^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:10:5 + | + 10 | def service(database): ... + | ------- + "); + + assert_snapshot!(test_use.fixture_resolution("legacy_cache"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:15:33 + | + 15 | def test_use(database, service, legacy_cache): ... + | ^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:13:5 + | + 13 | def legacy_cache(): ... + | ------------ + "); + + assert_snapshot!(test_wrapped.fixture_resolution("wrapped"), @"No fixture resolved for parameter `wrapped`"); + assert_snapshot!( + test.function("test_module_staticmethod") + .fixture_resolution("module_staticmethod"), + @"No fixture resolved for parameter `module_staticmethod`" + ); + } + + #[test] + fn honors_static_names_and_ignores_dynamic_names() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +def fixture_name() -> str: ... + +@pytest.fixture(name="public_name") +def implementation(): ... + +@pytest.fixture(name="public_" + "name") +def later_implementation(): ... + +@pytest.fixture(name=fixture_name()) +def dynamic_implementation(): ... + +def test_use(public_name, implementation, dynamic): ... +"#, + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("public_name"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:15:14 + | + 15 | def test_use(public_name, implementation, dynamic): ... + | ^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:10:5 + | + 10 | def later_implementation(): ... + | -------------------- + "); + + assert_snapshot!(test_use.fixture_resolution("implementation"), @"No fixture resolved for parameter `implementation`"); + assert_snapshot!(test_use.fixture_resolution("dynamic"), @"No fixture resolved for parameter `dynamic`"); + } + + #[test] + fn prefers_class_fixtures_and_skips_method_receivers() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +class TestExample: + @pytest.fixture + def value(self): ... + + @pytest.fixture + def dependent(self, value): ... + + def test_use(self, value, dependent): ... +"#, + ); + + let test_use = test.function("TestExample.test_use"); + let dependent = test.function("TestExample.dependent"); + + assert_snapshot!(test_use.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:14:24 + | + 14 | def test_use(self, value, dependent): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:9:9 + | + 9 | def value(self): ... + | ----- + "); + + assert_snapshot!(dependent.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:12:25 + | + 12 | def dependent(self, value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:9:9 + | + 9 | def value(self): ... + | ----- + "); + + assert_snapshot!(test_use.fixture_resolution("self"), @"No fixture resolved for parameter `self`"); + } + + #[test] + fn uses_module_fixture_for_same_name_class_override() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +class TestExample: + @pytest.fixture + def value(self, value): ... +"#, + ); + + let class_fixture = test.function("TestExample.value"); + + assert_snapshot!(class_fixture.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:9:21 + | + 9 | def value(self, value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:5:5 + | + 5 | def value(): ... + | ----- + "); + } + + #[test] + fn uses_lexical_context_for_fixture_dependencies() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def dependency(): ... + +@pytest.fixture +def consumer(dependency): ... + +class TestExample: + @pytest.fixture + def dependency(self): ... + + def test_use(self, consumer): ... +"#, + ); + + let consumer = test.function("consumer"); + + assert_snapshot!(consumer.fixture_resolution("dependency"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:8:14 + | + 8 | def consumer(dependency): ... + | ^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:5:5 + | + 5 | def dependency(): ... + | ---------- + "); + } + + #[test] + fn resolves_fixtures_in_test_class_bases() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +class Base: + @pytest.fixture + def inherited(self): ... + +class TestExample(Base): + def test_use(self, inherited): ... + +class TestShadowed(Base): + inherited = None + def test_use(self, inherited): ... + +class TestAnnotated(Base): + inherited: object + def test_use(self, inherited): ... +"#, + ); + + let test_use = test.function("TestExample.test_use"); + let shadowed = test.function("TestShadowed.test_use"); + let annotated = test.function("TestAnnotated.test_use"); + + assert_snapshot!(test_use.fixture_resolution("inherited"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:9:24 + | + 9 | def test_use(self, inherited): ... + | ^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:9 + | + 6 | def inherited(self): ... + | --------- + "); + + assert_snapshot!(shadowed.fixture_resolution("inherited"), @"No fixture resolved for parameter `inherited`"); + + assert_snapshot!(annotated.fixture_resolution("inherited"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:17:24 + | + 17 | def test_use(self, inherited): ... + | ^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:9 + | + 6 | def inherited(self): ... + | --------- + "); + } + + #[test] + fn follows_test_class_mro() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +class First: + @pytest.fixture(name="resource") + def first_fixture(self): ... + +class Second: + @pytest.fixture(name="resource") + def second_fixture(self): ... + +class TestExample(First, Second): + def test_use(self, resource): ... +"#, + ); + + let test_use = test.function("TestExample.test_use"); + + assert_snapshot!(test_use.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:13:24 + | + 13 | def test_use(self, resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:10:9 + | + 10 | def second_fixture(self): ... + | -------------- + "); + } + + #[test] + fn classifies_only_supported_fixture_requests() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +def helper(value): ... + +def test_defaults(positional_only, /, value=None, *args, **kwargs): ... + +class Example: + def test_method(value): ... +"#, + ); + + let helper = test.function("helper"); + let test_defaults = test.function("test_defaults"); + let example_method = test.function("Example.test_method"); + + assert_snapshot!(helper.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + assert_snapshot!(test_defaults.fixture_resolution("positional_only"), @"No fixture resolved for parameter `positional_only`"); + assert_snapshot!(test_defaults.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + assert_snapshot!(test_defaults.fixture_resolution("args"), @"No fixture resolved for parameter `args`"); + assert_snapshot!(test_defaults.fixture_resolution("kwargs"), @"No fixture resolved for parameter `kwargs`"); + assert_snapshot!(example_method.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + } + + #[test] + fn excludes_mock_patch_and_unittest_parameters() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import unittest +from unittest import mock + +import pytest + +@pytest.fixture +def patched(): ... + +@pytest.fixture +def value(): ... + +@mock.patch("module.target") +def test_patched(patched, value): ... + +class TestUnit(unittest.TestCase): + def test_method(self, value): ... + +@mock.patch.multiple("module", value=mock.DEFAULT) +def test_patch_multiple(value): ... + +@mock.patch("module.target") +def test_legacy_patch(__patched, value): ... + +@mock.patch.object(object, "attribute") +def test_patch_object(patched, value): ... + +@mock.patch("module.target", new=mock.DEFAULT) +def test_explicit_default(patched, value): ... +"#, + ); + + let patched = test.function("test_patched"); + let unittest_method = test.function("TestUnit.test_method"); + let patch_multiple = test.function("test_patch_multiple"); + let legacy_patch = test.function("test_legacy_patch"); + let patch_object = test.function("test_patch_object"); + let explicit_default = test.function("test_explicit_default"); + + assert_snapshot!(patched.fixture_resolution("patched"), @"No fixture resolved for parameter `patched`"); + assert_snapshot!(patch_object.fixture_resolution("patched"), @"No fixture resolved for parameter `patched`"); + assert_snapshot!(explicit_default.fixture_resolution("patched"), @"No fixture resolved for parameter `patched`"); + + assert_snapshot!(patched.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:14:27 + | + 14 | def test_patched(patched, value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:11:5 + | + 11 | def value(): ... + | ----- + "); + + assert_snapshot!(unittest_method.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + + assert_snapshot!(patch_multiple.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:20:25 + | + 20 | def test_patch_multiple(value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:11:5 + | + 11 | def value(): ... + | ----- + "); + + assert_snapshot!(legacy_patch.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:23:34 + | + 23 | def test_legacy_patch(__patched, value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:11:5 + | + 11 | def value(): ... + | ----- + "); + } + + #[test] + fn resolves_fixtures_for_nested_test_classes() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +class TestOuter: + @pytest.fixture + def outer(self): ... + + class TestInner: + def test_method(self, value, outer): ... +"#, + ); + + let nested_method = test.function("TestOuter.TestInner.test_method"); + + assert_snapshot!(nested_method.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:12:31 + | + 12 | def test_method(self, value, outer): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:5:5 + | + 5 | def value(): ... + | ----- + "); + + assert_snapshot!(nested_method.fixture_resolution("outer"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:12:38 + | + 12 | def test_method(self, value, outer): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:9:9 + | + 9 | def outer(self): ... + | ----- + "); + } + + #[test] + fn resolves_fixture_after_positional_only_method_receiver() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +class TestExample: + def test_method(self, /, value): ... +"#, + ); + + let test_method = test.function("TestExample.test_method"); + + assert_snapshot!(test_method.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:8:30 + | + 8 | def test_method(self, /, value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:5:5 + | + 5 | def value(): ... + | ----- + "); + } + + #[test] + fn excludes_direct_parameters_and_keeps_indirect_parameters() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest +from pytest import mark as aliased_mark + +@pytest.fixture +def value(): ... + +@pytest.fixture +def other(): ... + +@pytest.mark.parametrize("value", [1]) +def test_direct(value): ... + +@pytest.mark.parametrize("value", [1], True) +def test_indirect(value): ... + +@pytest.mark.parametrize("value, other", [(1, 2)], indirect=["value"]) +def test_mixed(value, other): ... + +@aliased_mark.parametrize("value", [1]) +def test_aliased_direct(value): ... + +@aliased_mark.parametrize("value", [1], indirect=True) +def test_aliased_indirect(value): ... + +parametrize = pytest.mark.parametrize + +@parametrize("value", [1]) +def test_bare_aliased_direct(value): ... + +@pytest.mark.parametrize("value", [1]) +class TestParametrized: + def test_value(self, value): ... + +@pytest.mark.parametrize("value", [1]) +class TestOuter: + class TestInner: + def test_value(self, value): ... +"#, + ); + + let test_direct = test.function("test_direct"); + let test_indirect = test.function("test_indirect"); + let test_mixed = test.function("test_mixed"); + let test_aliased_direct = test.function("test_aliased_direct"); + let test_aliased_indirect = test.function("test_aliased_indirect"); + let test_bare_aliased_direct = test.function("test_bare_aliased_direct"); + let test_class_parametrized = test.function("TestParametrized.test_value"); + let test_outer_class_parametrized = test.function("TestOuter.TestInner.test_value"); + + assert_snapshot!(test_direct.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + + assert_snapshot!(test_indirect.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:15:19 + | + 15 | def test_indirect(value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def value(): ... + | ----- + "); + + assert_snapshot!(test_mixed.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:18:16 + | + 18 | def test_mixed(value, other): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def value(): ... + | ----- + "); + + assert_snapshot!(test_mixed.fixture_resolution("other"), @"No fixture resolved for parameter `other`"); + assert_snapshot!(test_aliased_direct.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + + assert_snapshot!(test_bare_aliased_direct.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + + assert_snapshot!(test_aliased_indirect.fixture_resolution("value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:24:27 + | + 24 | def test_aliased_indirect(value): ... + | ^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def value(): ... + | ----- + "); + + assert_snapshot!(test_class_parametrized.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + assert_snapshot!(test_outer_class_parametrized.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + } + + #[test] + fn requires_a_default_pytest_test_module_name() { + let test = PytestTestCase::new( + "/src/example.py", + r#" +import pytest + +@pytest.fixture +def value(): ... + +def test_use(value): ... +"#, + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("value"), @"No fixture resolved for parameter `value`"); + } + + #[test] + fn resolves_imported_fixture_exposures() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/fixtures.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... + +@pytest.fixture(name="public_name") +def implementation(): ... +"#, + ), + ( + "/src/reexports.py", + r#" +from fixtures import resource as middle +"#, + ), + ( + "/src/star_fixtures.py", + r#" +import pytest + +@pytest.fixture +def star_fixture(): ... +"#, + ), + // Import the same explicitly named fixture twice to verify that its exposures are + // deduplicated. + ( + "/src/test_example.py", + r#" +from fixtures import implementation, implementation as second_exposure +from reexports import middle as chained +from star_fixtures import * + +def test_use( + chained, + public_name, + resource, + star_fixture, +): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("chained"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:7:5 + | + 7 | chained, + | ^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/fixtures.py:5:5 + | + 5 | def resource(): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("public_name"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:8:5 + | + 8 | public_name, + | ^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/fixtures.py:8:5 + | + 8 | def implementation(): ... + | -------------- + "); + + assert_snapshot!(test_use.fixture_resolution("star_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:10:5 + | + 10 | star_fixture, + | ^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/star_fixtures.py:5:5 + | + 5 | def star_fixture(): ... + | ------------ + "); + + assert_snapshot!(test_use.fixture_resolution("resource"), @"No fixture resolved for parameter `resource`"); + } + + #[test] + fn resolves_fixture_alongside_cyclic_reexport() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/a.py", + r#" +import pytest + +flag: bool +if flag: + from b import resource +else: + @pytest.fixture + def resource(): ... +"#, + ), + ( + "/src/b.py", + r#" +from a import resource +"#, + ), + ( + "/src/test_example.py", + r#" +from a import resource + +def test_use(resource): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:4:14 + | + 4 | def test_use(resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/a.py:9:9 + | + 9 | def resource(): ... + | -------- + "); + } + + #[test] + fn resolves_imported_fixture_declarations_from_source_when_available() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + // Use the same symbol and fixture name at module and class scope so + // stub mapping must preserve the class path. + ( + "/src/fixtures.py", + r#" +import pytest + +@pytest.fixture(name="public_name") +def implementation(): ... + +class Base: + @pytest.fixture(name="public_name") + def implementation(self): ... +"#, + ), + // Expose `implementation` only through a synthetic lazy binding in the stub. + // The binding must survive long enough for fixture discovery to inspect the source. + ( + "/src/fixtures.pyi", + r#" +def initialize() -> None: + global implementation + implementation = ... + +class Base: + def implementation(self): ... +"#, + ), + ( + "/src/test_example.py", + r#" +from fixtures import Base, implementation + +def test_use(public_name): ... + +class TestExample(Base): + def test_inherited(self, public_name): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("public_name"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:4:14 + | + 4 | def test_use(public_name): ... + | ^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/fixtures.py:5:5 + | + 5 | def implementation(): ... + | -------------- + "); + + let test_inherited = test.function("TestExample.test_inherited"); + + assert_snapshot!(test_inherited.fixture_resolution("public_name"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:7:30 + | + 7 | def test_inherited(self, public_name): ... + | ^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/fixtures.py:9:9 + | + 9 | def implementation(self): ... + | -------------- + "); + + let fixture = test.function_definition("/src/fixtures.py", "implementation"); + let stub = test.global_definition("/src/fixtures.pyi", "implementation"); + let stub_exposures = fixture_exposures_for_definition(&test.db, stub); + assert_single_exposure(&stub_exposures, "public_name", stub, fixture, None); + } + + #[test] + fn resolves_fixture_declarations_from_stub_only_classes() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/plugin.pyi", + r#" +import pytest + +class Plugin: + @pytest.fixture + def resource(self): ... +"#, + ), + ( + "/src/test_example.py", + r#" +from plugin import Plugin + +class TestExample(Plugin): + def test_use(self, resource): ... +"#, + ), + ], + ); + + let test_use = test.function("TestExample.test_use"); + + assert_snapshot!(test_use.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:5:24 + | + 5 | def test_use(self, resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/plugin.pyi:6:9 + | + 6 | def resource(self): ... + | -------- + "); + } + + #[test] + fn resolves_fixture_reexports_through_stub_only_modules() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/origin.pyi", + r#" +import pytest + +@pytest.fixture +def module_fixture(): ... + +@pytest.fixture +def class_fixture(): ... + +@pytest.fixture +def star_fixture(): ... +"#, + ), + ( + "/src/plugin.pyi", + r#" +from origin import module_fixture as module_fixture +from origin import * + +class Plugin: + from origin import class_fixture as class_fixture +"#, + ), + ( + "/src/test_example.py", + r#" +from plugin import Plugin, module_fixture, star_fixture + +def test_module(module_fixture, star_fixture): ... + +class TestExample(Plugin): + def test_use(self, class_fixture): ... +"#, + ), + ], + ); + + let test_module = test.function("test_module"); + let test_use = test.function("TestExample.test_use"); + + assert_snapshot!(test_module.fixture_resolution("module_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:4:17 + | + 4 | def test_module(module_fixture, star_fixture): ... + | ^^^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/origin.pyi:5:5 + | + 5 | def module_fixture(): ... + | -------------- + "); + assert_snapshot!(test_module.fixture_resolution("star_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:4:33 + | + 4 | def test_module(module_fixture, star_fixture): ... + | ^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/origin.pyi:11:5 + | + 11 | def star_fixture(): ... + | ------------ + "); + assert_snapshot!(test_use.fixture_resolution("class_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:7:24 + | + 7 | def test_use(self, class_fixture): ... + | ^^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/origin.pyi:8:5 + | + 8 | def class_fixture(): ... + | ------------- + "); + } + + #[test] + fn ignores_overwritten_imported_fixtures() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/origin.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... +"#, + ), + ( + "/src/provider.py", + r#" +from origin import resource + +resource = object() +"#, + ), + ( + "/src/test_example.py", + r#" +from origin import resource as local_resource +from provider import resource + +local_resource = object() + +def test_use(local_resource, resource): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("local_resource"), @"No fixture resolved for parameter `local_resource`"); + assert_snapshot!(test_use.fixture_resolution("resource"), @"No fixture resolved for parameter `resource`"); + } + + #[test] + fn preserves_conditional_imported_fixture_definitions() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/first.py", + r#" +import pytest + +@pytest.fixture +def first(): ... +"#, + ), + ( + "/src/second.py", + r#" +import pytest + +@pytest.fixture +def second(): ... +"#, + ), + ( + "/src/test_example.py", + r#" +flag: bool + +if flag: + from first import first as resource +else: + from second import second as resource + +def test_use(resource): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:9:14 + | + 9 | def test_use(resource): ... + | ^^^^^^^^ fixture requested here + info: Found 2 fixtures + --> src/first.py:5:5 + | + 5 | def first(): ... + | ----- + | + ::: src/second.py:5:5 + | + 5 | def second(): ... + | ------ + "); + } + + #[test] + fn ignores_local_fixture_declarations_unavailable_at_runtime() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +from typing import TYPE_CHECKING +import pytest + +if TYPE_CHECKING: + @pytest.fixture + def typing_only(): ... + +class Base: + @pytest.fixture + def resource(self): ... + +class TestDerived(Base): + if TYPE_CHECKING: + @pytest.fixture + def resource(self): ... + + def test_inherited(self, resource): ... + +def test_use(typing_only): ... +"#, + ); + + let test_inherited = test.function("TestDerived.test_inherited"); + let test_use = test.function("test_use"); + + assert_snapshot!(test_inherited.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:18:30 + | + 18 | def test_inherited(self, resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:11:9 + | + 11 | def resource(self): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("typing_only"), @"No fixture resolved for parameter `typing_only`"); + } + + #[test] + fn resolves_dependencies_for_fixture_declarations_unavailable_at_runtime() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +from typing import type_check_only +import pytest + +@pytest.fixture +def dependency(): ... + +@pytest.fixture +@type_check_only +def hidden(dependency): ... +"#, + ); + + let hidden = test.function("hidden"); + + assert_snapshot!(hidden.fixture_resolution("dependency"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:10:12 + | + 10 | def hidden(dependency): ... + | ^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:6:5 + | + 6 | def dependency(): ... + | ---------- + "); + } + + #[test] + fn ignores_imported_fixture_exposures_unavailable_at_runtime() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/fixtures.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... +"#, + ), + ( + "/src/provider.py", + r#" +from typing import TYPE_CHECKING +import pytest + +if TYPE_CHECKING: + from fixtures import resource as typing_only_reexport + + @pytest.fixture + def typing_only_declaration(): ... +"#, + ), + ( + "/src/test_example.py", + r#" +from typing import TYPE_CHECKING + +from provider import * + +if False: + from fixtures import resource as unreachable + +if TYPE_CHECKING: + from fixtures import resource as typing_only + +def test_use(unreachable, typing_only, typing_only_declaration, typing_only_reexport): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("unreachable"), @"No fixture resolved for parameter `unreachable`"); + assert_snapshot!(test_use.fixture_resolution("typing_only"), @"No fixture resolved for parameter `typing_only`"); + assert_snapshot!(test_use.fixture_resolution("typing_only_declaration"), @"No fixture resolved for parameter `typing_only_declaration`"); + assert_snapshot!(test_use.fixture_resolution("typing_only_reexport"), @"No fixture resolved for parameter `typing_only_reexport`"); + } + + #[test] + fn resolves_conftest_fixtures_from_request_directory_to_first_party_root() { + let test = PytestTestCase::with_files( + "/src/tests/test_example.py", + &[ + ( + "/conftest.py", + r#" +import pytest + +@pytest.fixture +def outside_root(): ... +"#, + ), + ( + "/src/conftest.py", + r#" +import pytest + +@pytest.fixture +def root_fixture(): ... + +@pytest.fixture +def shadowed(): ... +"#, + ), + ( + "/src/tests/conftest.py", + r#" +import pytest + +@pytest.fixture +def shadowed(): ... +"#, + ), + ( + "/src/sibling/conftest.py", + r#" +import pytest + +@pytest.fixture +def sibling_fixture(): ... +"#, + ), + ( + "/src/tests/test_example.py", + r#" +def test_use( + root_fixture, + shadowed, + outside_root, + sibling_fixture, +): ... +"#, + ), + ], + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("root_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/tests/test_example.py:3:5 + | + 3 | root_fixture, + | ^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/conftest.py:5:5 + | + 5 | def root_fixture(): ... + | ------------ + "); + + assert_snapshot!(test_use.fixture_resolution("shadowed"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/tests/test_example.py:4:5 + | + 4 | shadowed, + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/tests/conftest.py:5:5 + | + 5 | def shadowed(): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("outside_root"), @"No fixture resolved for parameter `outside_root`"); + + assert_snapshot!(test_use.fixture_resolution("sibling_fixture"), @"No fixture resolved for parameter `sibling_fixture`"); + } + + #[test] + fn resolves_conftest_providers_from_outermost_matching_first_party_root() { + let test = PytestTestCase::with_files_and_src_roots( + "/src/tests/test_example.py", + &[ + ( + "/conftest.py", + r#" +import pytest + +@pytest.fixture +def outer_fixture(): ... +"#, + ), + ( + "/src/tests/test_example.py", + r#" +def test_use(outer_fixture): ... +"#, + ), + ], + // The relative environment roots `["src", "."]` resolve to these absolute paths. + vec![SystemPathBuf::from("/src"), SystemPathBuf::from("/")], + ); + + assert_snapshot!(test.function("test_use").fixture_resolution("outer_fixture"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/tests/test_example.py:2:14 + | + 2 | def test_use(outer_fixture): ... + | ^^^^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> conftest.py:5:5 + | + 5 | def outer_fixture(): ... + | ------------- + "); + } + + #[test] + fn resolves_conftest_fixture_dependency_from_parent_conftest() { + let test = PytestTestCase::with_files( + "/src/project/conftest.py", + &[ + ( + "/src/conftest.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... +"#, + ), + ( + "/src/project/conftest.py", + r#" +import pytest + +@pytest.fixture +def consumer(resource): ... +"#, + ), + ], + ); + + let fixture = test.function("consumer"); + + assert_snapshot!(fixture.fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/project/conftest.py:5:14 + | + 5 | def consumer(resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/conftest.py:5:5 + | + 5 | def resource(): ... + | -------- + "); + } + + #[test] + fn creating_conftest_updates_fixture_resolution() { + let mut test = PytestTestCase::new( + "/src/project/test_example.py", + r#" +def test_use(resource): ... +"#, + ); + + assert_snapshot!(test.function("test_use").fixture_resolution("resource"), @"No fixture resolved for parameter `resource`"); + + test.write_file( + "/src/project/conftest.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... +"#, + ); + assert_snapshot!(test.function("test_use").fixture_resolution("resource"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/project/test_example.py:2:14 + | + 2 | def test_use(resource): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/project/conftest.py:5:5 + | + 5 | def resource(): ... + | -------- + "); + } + + #[test] + fn resolves_installed_core_plugins_in_registration_order() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +def test_use(core_value, tmp_path, tmpdir, unused_fixture, request): ... +"#, + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("core_value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:2:14 + | + 2 | def test_use(core_value, tmp_path, tmpdir, unused_fixture, request): ... + | ^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> .venv/lib/python3.13/site-packages/_pytest/override.py:5:5 + | + 5 | def core_value(): ... + | ---------- + "); + + assert_snapshot!(test_use.fixture_resolution("tmp_path"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:2:26 + | + 2 | def test_use(core_value, tmp_path, tmpdir, unused_fixture, request): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> .venv/lib/python3.13/site-packages/_pytest/tmpdir.py:5:5 + | + 5 | def tmp_path(): ... + | -------- + "); + + assert_snapshot!(test_use.fixture_resolution("tmpdir"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:2:36 + | + 2 | def test_use(core_value, tmp_path, tmpdir, unused_fixture, request): ... + | ^^^^^^ fixture requested here + info: Found 1 fixture + --> .venv/lib/python3.13/site-packages/_pytest/legacypath.py:7:9 + | + 7 | def tmpdir(): ... + | ------ + "); + + assert_snapshot!(test_use.fixture_resolution("unused_fixture"), @"No fixture resolved for parameter `unused_fixture`"); + assert_snapshot!(test_use.fixture_resolution("request"), @"No fixture resolved for parameter `request`"); + assert_eq!( + test.global_plugin_files(), + [ + "/.venv/lib/python3.13/site-packages/_pytest/baseplugin.py", + "/.venv/lib/python3.13/site-packages/_pytest/legacypath.py", + "/.venv/lib/python3.13/site-packages/_pytest/tmpdir.py", + "/.venv/lib/python3.13/site-packages/_pytest/override.py", + ] + ); + } + + #[test] + fn updating_core_plugin_registry_updates_fixture_resolution() { + let mut test = PytestTestCase::with_config( + "/src/test_example.py", + &[( + "/src/test_example.py", + r#" +def test_use(core_value, tmp_path): ... +"#, + )], + r#" +essential_plugins = ("baseplugin",) +additional_plugins = () +default_plugins = essential_plugins + additional_plugins +"#, + ); + + assert_snapshot!(test.function("test_use").fixture_resolution("core_value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:2:14 + | + 2 | def test_use(core_value, tmp_path): ... + | ^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> .venv/lib/python3.13/site-packages/_pytest/baseplugin.py:5:5 + | + 5 | def core_value(): ... + | ---------- + "); + + test.write_file( + "/.venv/lib/python3.13/site-packages/_pytest/config/__init__.py", + r#" +default_plugins = ("tmpdir",) +"#, + ); + + assert_snapshot!(test.function("test_use").fixture_resolution("core_value"), @"No fixture resolved for parameter `core_value`"); + assert_snapshot!(test.function("test_use").fixture_resolution("tmp_path"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:2:26 + | + 2 | def test_use(core_value, tmp_path): ... + | ^^^^^^^^ fixture requested here + info: Found 1 fixture + --> .venv/lib/python3.13/site-packages/_pytest/tmpdir.py:5:5 + | + 5 | def tmp_path(): ... + | -------- + "); + } + + #[test] + fn project_fixtures_shadow_installed_core_plugins() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def core_value(): ... + +def test_use(core_value): ... +"#, + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("core_value"), @" + info[pytest-fixture]: Resolve fixture for parameter + --> src/test_example.py:7:14 + | + 7 | def test_use(core_value): ... + | ^^^^^^^^^^ fixture requested here + info: Found 1 fixture + --> src/test_example.py:5:5 + | + 5 | def core_value(): ... + | ---------- + "); + } + + #[test] + fn declines_dynamic_core_plugin_registries() { + let test = PytestTestCase::with_config( + "/src/test_example.py", + &[( + "/src/test_example.py", + r#" +def test_use(core_value): ... +"#, + )], + r#" +def plugins() -> tuple[str, ...]: + return ("baseplugin",) + +default_plugins = plugins() +"#, + ); + + let test_use = test.function("test_use"); + + assert_snapshot!(test_use.fixture_resolution("core_value"), @"No fixture resolved for parameter `core_value`"); + } + + #[test] + fn skips_invalid_core_plugin_names() { + let test = PytestTestCase::with_config( + "/src/test_example.py", + &[( + "/src/test_example.py", + r#" +"#, + )], + r#" +default_plugins = ("not-valid", "baseplugin") +"#, + ); + + assert_eq!( + test.global_plugin_files(), + ["/.venv/lib/python3.13/site-packages/_pytest/baseplugin.py"] + ); + } + + #[test] + fn preserves_fixture_exposure_provenance_across_imports() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/fixtures.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... +"#, + ), + ( + "/src/reexports.py", + r#" +from fixtures import resource as helper +"#, + ), + ( + "/src/test_example.py", + r#" +from reexports import helper + +def test_use(helper): ... +"#, + ), + ], + ); + + let fixture = test.function_definition("/src/fixtures.py", "resource"); + let alias = test.global_definition("/src/reexports.py", "helper"); + let imported_alias = test.global_definition("/src/test_example.py", "helper"); + + let fixture_exposures = fixture_exposures_for_definition(&test.db, fixture); + let fixture_exposure = + assert_single_exposure(&fixture_exposures, "resource", fixture, fixture, None); + assert_eq!( + fixture_exposure.name_source(&test.db), + FixtureNameSource::Binding(fixture) + ); + + let alias_exposures = fixture_exposures_for_definition(&test.db, alias); + assert_single_exposure(&alias_exposures, "helper", alias, fixture, Some(fixture)); + + let imported_exposures = fixture_exposures_for_definition(&test.db, imported_alias); + assert_single_exposure( + &imported_exposures, + "helper", + imported_alias, + fixture, + Some(alias), + ); + + let test_use = test.function("test_use"); + let request = test_use.parameter_definition("helper"); + let bindings = fixture_bindings_for_parameter(&test.db, request); + let [binding] = bindings else { + panic!("fixture request should have one binding"); + }; + assert_eq!(binding.fixture(), fixture); + assert_eq!(binding.exposures(), imported_exposures); + } + + #[test] + fn preserves_explicit_fixture_name_declarations() { + let test = PytestTestCase::with_files( + "/src/test_example.py", + &[ + ( + "/src/fixtures.py", + r#" +import pytest + +@pytest.fixture(name="resource") +def implementation(): ... +"#, + ), + ( + "/src/test_example.py", + r#" +from fixtures import implementation as helper + +def test_use(resource): ... +"#, + ), + ], + ); + + let fixture = test.function_definition("/src/fixtures.py", "implementation"); + let alias = test.global_definition("/src/test_example.py", "helper"); + let alias_exposures = fixture_exposures_for_definition(&test.db, alias); + let alias_exposure = + assert_single_exposure(&alias_exposures, "resource", alias, fixture, Some(fixture)); + + let FixtureNameSource::Explicit { + fixture: declaring_fixture, + declaration: Some(declaration), + } = alias_exposure.name_source(&test.db) + else { + panic!("literal fixture name should retain its declaration range"); + }; + assert_eq!(declaring_fixture, fixture); + let source = ruff_db::source::source_text(&test.db, declaration.file()); + assert_eq!(&source[declaration.range()], "resource"); + + let test_use = test.function("test_use"); + let request = test_use.parameter_definition("resource"); + let bindings = fixture_bindings_for_parameter(&test.db, request); + let [binding] = bindings else { + panic!("explicit fixture request should have one binding"); + }; + assert_eq!(binding.exposures(), alias_exposures); + } + + #[test] + fn excludes_unavailable_definitions_from_fixture_exposures() { + let test = PytestTestCase::new( + "/src/test_example.py", + r#" +import pytest + +@pytest.fixture +def resource(): ... + +resource = None +"#, + ); + let fixture = test.function_definition("/src/test_example.py", "resource"); + + assert!(fixture_exposures_for_definition(&test.db, fixture).is_empty()); + } + + struct PytestTestCase { + db: TestDb, + path: &'static str, + } + + impl PytestTestCase { + fn new(path: &'static str, source: &'static str) -> Self { + Self { + db: pytest_db(path, source), + path, + } + } + + fn with_files(path: &'static str, files: &[(&'static str, &'static str)]) -> Self { + Self { + db: pytest_db_with_files(files), + path, + } + } + + fn with_files_and_src_roots( + path: &'static str, + files: &[(&'static str, &'static str)], + src_roots: Vec, + ) -> Self { + Self { + db: pytest_db_with_files_and_src_roots(files, src_roots), + path, + } + } + + fn with_config( + path: &'static str, + files: &[(&'static str, &'static str)], + config: &'static str, + ) -> Self { + Self { + db: pytest_db_with_config(files, config), + path, + } + } + + fn write_file(&mut self, path: &'static str, source: &'static str) { + self.db + .write_file(path, source) + .expect("valid pytest test file update"); + } + + fn function<'test>(&'test self, name: &str) -> PytestTestFunction<'test> { + PytestTestFunction { + test: self, + name: name.to_owned(), + } + } + + fn global_plugin_files(&self) -> Vec { + let file = system_path_to_file(&self.db, self.path).expect("test file exists"); + let file = self.db.program_file(file); + pytest_global_plugin_files(&self.db, file.program(&self.db)) + .iter() + .map(|file| { + file.file(&self.db) + .path(&self.db) + .to_string() + .replace('\\', "/") + }) + .collect() + } + + fn function_definition<'db>(&'db self, path: &str, name: &str) -> Definition<'db> { + let file = system_path_to_file(&self.db, path).expect("test file exists"); + let file = self.db.program_file(file); + let module = parsed_module(&self.db, file.python_file(&self.db)).load(&self.db); + let function = find_function(module.suite(), name).expect("function exists"); + semantic_index(&self.db, file).expect_single_definition(function) + } + + fn global_definition<'db>(&'db self, path: &str, name: &str) -> Definition<'db> { + let file = system_path_to_file(&self.db, path).expect("test file exists"); + let file = self.db.program_file(file); + end_of_scope_definition(&self.db, file, name).expect("global definition exists") + } + } + + struct PytestTestFunction<'test> { + test: &'test PytestTestCase, + name: String, + } + + impl PytestTestFunction<'_> { + fn fixture_resolution(&self, parameter_name: &str) -> String { + let db = &self.test.db; + let parameter = self.parameter_definition(parameter_name); + let fixtures = fixture_bindings_for_parameter(db, parameter); + if fixtures.is_empty() { + return format!("No fixture resolved for parameter `{parameter_name}`"); + } + + let parameter_module = parsed_module(db, parameter.python_file(db)).load(db); + let mut diagnostic = Diagnostic::new( + DiagnosticId::lint("pytest-fixture"), + Severity::Info, + "Resolve fixture for parameter", + ); + diagnostic.annotate( + Annotation::primary(parameter.focus_range(db, ¶meter_module).into()) + .message("fixture requested here"), + ); + + let mut resolved = SubDiagnostic::new( + SubDiagnosticSeverity::Info, + format_args!( + "Found {} fixture{}", + fixtures.len(), + if fixtures.len() == 1 { "" } else { "s" } + ), + ); + for binding in fixtures { + let fixture = binding.fixture(); + let module = parsed_module(db, fixture.python_file(db)).load(db); + resolved.annotate(Annotation::secondary( + fixture.focus_range(db, &module).into(), + )); + } + diagnostic.sub(resolved); + + DisplayDiagnostics::new( + db, + &DisplayDiagnosticConfig::new("ty").context(0), + &[diagnostic], + ) + .to_string() + .replace('\\', "/") + } + + fn parameter_definition<'db>(&'db self, parameter_name: &str) -> Definition<'db> { + let db = &self.test.db; + let file = system_path_to_file(db, self.test.path).expect("test file exists"); + let file = db.program_file(file); + let module = parsed_module(db, file.python_file(db)).load(db); + let function = find_function(module.suite(), &self.name).expect("test function exists"); + let index = semantic_index(db, file); + let parameter = function + .parameters + .iter() + .find(|candidate| candidate.name().as_str() == parameter_name) + .expect("test parameter exists"); + match parameter { + ast::AnyParameterRef::Variadic(parameter) => { + index.expect_single_definition(parameter) + } + ast::AnyParameterRef::NonVariadic(parameter) => { + index.expect_single_definition(parameter) + } + } + } + } + + fn assert_single_exposure<'a, 'db>( + exposures: &'a [FixtureExposure<'db>], + name: &str, + local_binding: Definition<'db>, + fixture: Definition<'db>, + source_binding: Option>, + ) -> &'a FixtureExposure<'db> { + let [exposure] = exposures else { + panic!("expected exactly one fixture exposure, got {exposures:#?}"); + }; + assert_eq!(exposure.name(), name); + assert_eq!(exposure.local_binding(), local_binding); + assert_eq!(exposure.fixture(), fixture); + assert_eq!(exposure.source_binding(), source_binding); + exposure + } + + fn find_function<'ast>( + statements: &'ast [ast::Stmt], + selector: &str, + ) -> Option<&'ast ast::StmtFunctionDef> { + if let Some((class_name, nested)) = selector.split_once('.') { + return statements.iter().find_map(|statement| { + let class = statement.as_class_def_stmt()?; + (class.name.as_str() == class_name) + .then(|| find_function(&class.body, nested)) + .flatten() + }); + } + + statements.iter().find_map(|statement| { + statement + .as_function_def_stmt() + .filter(|function| function.name.as_str() == selector) + }) + } + + fn pytest_db(path: &'static str, source: &'static str) -> TestDb { + pytest_db_with_files(&[(path, source)]) + } + + fn pytest_db_with_files(files: &[(&'static str, &'static str)]) -> TestDb { + pytest_db_with_files_and_src_roots(files, vec![SystemPathBuf::from("/src")]) + } + + fn pytest_db_with_files_and_src_roots( + files: &[(&'static str, &'static str)], + src_roots: Vec, + ) -> TestDb { + pytest_db_with_config_and_src_roots( + files, + src_roots, + r#" +essential_plugins = ("baseplugin",) +default_plugins = ( + *essential_plugins, + "legacypath", + "tmpdir", + "_pytest.tmpdir", + "override", + "_pytest.override", +) +"#, + ) + } + + fn pytest_db_with_config( + files: &[(&'static str, &'static str)], + config: &'static str, + ) -> TestDb { + pytest_db_with_config_and_src_roots(files, vec![SystemPathBuf::from("/src")], config) + } + + fn pytest_db_with_config_and_src_roots( + files: &[(&'static str, &'static str)], + src_roots: Vec, + config: &'static str, + ) -> TestDb { + let mut builder = TestDbBuilder::new() + .with_src_roots(src_roots) + .with_third_party_packages() + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/__init__.py", + r#" +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/__init__.pyi", + r#" +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/config/__init__.py", + config, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/mark/__init__.pyi", + "", + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/mark/structures.pyi", + r#" +class MarkDecorator: + def __call__(self, *args: object, **kwargs: object) -> object: ... + +class _ParametrizeMarkDecorator(MarkDecorator): ... + +class MarkGenerator: + parametrize: _ParametrizeMarkDecorator +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/fixtures.pyi", + r#" +from typing import Any, Callable + +def fixture( + function: Callable[..., Any] | None = ..., + *, + name: str | None = ..., +) -> Any: ... + +def yield_fixture( + function: Callable[..., Any] | None = ..., + *, + name: str | None = ..., +) -> Any: ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/pytest/__init__.pyi", + r#" +from _pytest.fixtures import fixture as fixture, yield_fixture as yield_fixture +from _pytest.mark.structures import MarkGenerator + +mark: MarkGenerator +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/baseplugin.py", + r#" +from _pytest.fixtures import fixture + +@fixture +def core_value(): ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/legacypath.py", + r#" +from _pytest.fixtures import fixture + +class LegacyTmpdirPlugin: + @staticmethod + @fixture + def tmpdir(): ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/tmpdir.py", + r#" +from _pytest.fixtures import fixture + +@fixture +def tmp_path(): ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/override.py", + r#" +from _pytest.fixtures import fixture + +@fixture +def core_value(): ... +"#, + ) + .with_file( + "/.venv/lib/python3.13/site-packages/_pytest/unused.py", + r#" +from _pytest.fixtures import fixture + +@fixture +def unused_fixture(): ... +"#, + ); + for (path, source) in files { + builder = builder.with_file(*path, source); + } + builder.build().expect("valid pytest test database") + } +} diff --git a/crates/ty_python_semantic/src/types/dedicated/role.rs b/crates/ty_python_semantic/src/types/dedicated/role.rs index ab663cb8ba..49beadb9cb 100644 --- a/crates/ty_python_semantic/src/types/dedicated/role.rs +++ b/crates/ty_python_semantic/src/types/dedicated/role.rs @@ -71,7 +71,7 @@ pub fn class_body_annotation_is_semantic<'db>(db: &'db dyn Db, class: ClassLiter /// the kind of pytest function whose parameters pytest fills from the fixture /// registry — the parallel of [`FrameworkRole`] for function-level frameworks #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::SalsaValue, get_size2::GetSize)] -pub enum FunctionFrameworkRole { +pub(crate) enum FunctionFrameworkRole { /// a pytest fixture — a function decorated with `@pytest.fixture` PytestFixture, /// a pytest test — a `test*` function in a collected test file diff --git a/crates/ty_python_semantic/src/types/deferred.rs b/crates/ty_python_semantic/src/types/deferred.rs index 595d86bf40..a636e5d5ea 100644 --- a/crates/ty_python_semantic/src/types/deferred.rs +++ b/crates/ty_python_semantic/src/types/deferred.rs @@ -39,6 +39,7 @@ use ruff_python_ast as ast; use ruff_python_ast::name::Name; use super::Type; +use super::infer::builder::binary_expressions::BinaryInferenceState; use super::infer::{ deferred_comparison, fold_tuple_concat, fold_tuple_repeat, literal_binary_op, literal_unary_op, }; @@ -114,7 +115,7 @@ impl DeferredOperation { /// the bound's member before specialization, a `type def` reduces to its declared return /// type — the annotation its author wrote to make it checkable — and a match type /// reduces to a gradual type because which case applies is the whole question. - pub(crate) const fn is_checked(&self) -> bool { + const fn is_checked(&self) -> bool { self.is_checked_arithmetic() || matches!(self, DeferredOperation::Call) } @@ -508,17 +509,30 @@ fn evaluate<'db>( match *operation { DeferredOperation::Binary(op) => { let [left, right] = operands else { return None }; - literal_binary_op(db, env, *left, *right, op, true) - // the same tuple folds the value inferrer applies: without them - // `(X,) * Dim` would re-evaluate through typeshed's `tuple.__mul__` and - // widen to `tuple[X, ...]`, throwing away the length the fold just learned - .or_else(|| match op { - ast::Operator::Mult => fold_tuple_repeat(db, env, *left, *right) - .or_else(|| fold_tuple_repeat(db, env, *right, *left)), - ast::Operator::Add => fold_tuple_concat(db, env, *left, *right), - _ => None, - }) - .or_else(|| Type::try_call_bin_op_return_type(db, env, *left, op, *right)) + // a deferred fold has no expression to hang a deprecation diagnostic on, so + // whatever the dunder fallback records here is discarded + literal_binary_op( + db, + env, + *left, + *right, + op, + true, + &mut BinaryInferenceState::default(), + ) + // the same tuple folds the value inferrer applies: without them + // `(X,) * Dim` would re-evaluate through typeshed's `tuple.__mul__` and + // widen to `tuple[X, ...]`, throwing away the length the fold just learned + .or_else(|| match op { + ast::Operator::Mult => fold_tuple_repeat(db, env, *left, *right) + .or_else(|| fold_tuple_repeat(db, env, *right, *left)), + ast::Operator::Add => fold_tuple_concat(db, env, *left, *right), + _ => None, + }) + .or_else(|| { + Type::try_call_bin_op_result(db, env, *left, op, *right) + .map(|result| result.return_type) + }) } DeferredOperation::Attribute(ref name) => { let [receiver] = operands else { return None }; diff --git a/crates/ty_python_semantic/src/types/definition.rs b/crates/ty_python_semantic/src/types/definition.rs index 000eb3d00a..65f750012e 100644 --- a/crates/ty_python_semantic/src/types/definition.rs +++ b/crates/ty_python_semantic/src/types/definition.rs @@ -76,7 +76,7 @@ impl TypeDefinition<'_> { } impl<'db> TypeDefinition<'db> { - pub fn definition<'a>(&'a self) -> Option<&'a Definition<'db>> { + pub fn definition(&self) -> Option> { match self { Self::Module(_) => None, Self::StaticClass(definition) @@ -86,7 +86,7 @@ impl<'db> TypeDefinition<'db> { | Self::TypeAlias(definition) | Self::SpecialForm(definition) | Self::NewType(definition) - | Self::EnumMember(definition) => Some(definition), + | Self::EnumMember(definition) => Some(*definition), } } } diff --git a/crates/ty_python_semantic/src/types/definition_resolution.rs b/crates/ty_python_semantic/src/types/definition_resolution.rs new file mode 100644 index 0000000000..14ade00177 --- /dev/null +++ b/crates/ty_python_semantic/src/types/definition_resolution.rs @@ -0,0 +1,912 @@ +//! Source-definition resolution shared by type inference and IDE features. +//! +//! Name lookup reads binding and declaration information from the semantic index. Member +//! lookup takes an already-inferred receiver type. Neither lookup obtains use-site types +//! through `SemanticModel` or requests completed inference of the caller's scope. + +use std::collections::VecDeque; + +use indexmap::IndexSet; +use itertools::Either; +use ruff_db::files::FileRange; +use ruff_db::parsed::parsed_module; +use ruff_python_ast as ast; +use ruff_text_size::TextRange; +use rustc_hash::FxHashSet; +use ty_module_resolver::{ + ImportingFile, ModuleName, resolve_module, resolve_module_for_import_from, +}; +use ty_python_core::definition::{ + Definition, DefinitionCategory, DefinitionKind, NestedBindingExecution, +}; +use ty_python_core::scope::ScopeId; +use ty_python_core::{ + ProgramFile, attribute_scopes, global_scope, place_table, semantic_index, use_def_map, +}; + +use crate::place::implicit_builtins_symbol_scope; +use crate::types::{ClassBase, ClassLiteral, ClassType, SubclassOfInner, Type, binding_type}; +use crate::{Db, FxIndexSet, ProgramEnvironment, module_docstring}; + +/// Controls whether local import aliases should be resolved to their targets or returned as-is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportAliasResolution { + /// Resolve import aliases to their original definitions + ResolveAliases, + /// Keep import aliases as-is, don't resolve to original definitions + PreserveAliases, +} + +/// Represents the result of resolving an import to either a specific definition or +/// a specific range within a file. +/// This enum helps distinguish between cases where an import resolves to: +/// - A specific definition within a module (e.g., `from os import path` -> definition of `path`) +/// - A specific range within a file, sometimes an empty range at the top of the file +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedDefinition<'db> { + /// The import resolved to a specific definition within a module + Definition(Definition<'db>), + /// The import resolved to an entire module + Module(ProgramFile<'db>), + /// The import resolved to a file with a specific range + FileWithRange(FileRange), +} + +impl<'db> ResolvedDefinition<'db> { + pub fn focus_range(&self, db: &dyn Db) -> FileRange { + match self { + ResolvedDefinition::Definition(definition) => { + let parsed = parsed_module(db, definition.python_file(db)).load(db); + definition.focus_range(db, &parsed) + } + // For modules, navigate to the start of the file + ResolvedDefinition::Module(module) => { + FileRange::new(module.file(db), TextRange::default()) + } + ResolvedDefinition::FileWithRange(file_range) => *file_range, + } + } + + pub(crate) fn category(&self, db: &dyn Db) -> DefinitionCategory { + match self { + ResolvedDefinition::Definition(definition) => { + let file = definition.file(db); + let parsed = parsed_module(db, definition.python_file(db)).load(db); + definition.kind(db).category(file.is_stub(db), &parsed) + } + ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => { + DefinitionCategory::DeclarationAndBinding + } + } + } + + pub fn definition(&self) -> Option> { + match self { + ResolvedDefinition::Definition(definition) => Some(*definition), + ResolvedDefinition::Module(_) => None, + ResolvedDefinition::FileWithRange(_) => None, + } + } + + pub(crate) fn program_file(&self, db: &'db dyn Db) -> Option> { + match *self { + ResolvedDefinition::Definition(definition) => Some(definition.program_file(db)), + ResolvedDefinition::Module(file) => Some(file), + ResolvedDefinition::FileWithRange(_) => None, + } + } + + pub fn docstring(&self, db: &'db dyn Db) -> Option { + match self { + ResolvedDefinition::Definition(definition) => definition.docstring(db), + ResolvedDefinition::Module(file) => module_docstring(db, file.python_file(db)), + ResolvedDefinition::FileWithRange(_) => None, + } + } + + pub fn implementation_docstring(&self, db: &'db dyn Db) -> Option { + match self { + ResolvedDefinition::Definition(definition) => implementation_docstring(db, *definition), + ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => None, + } + } +} + +// Overload declarations often omit docstrings, while the runtime +// implementation appears as the last sibling binding for the same symbol. +// Fall back to that binding's docstring when the resolved overload has none. +// +// Uses type-aware matching: resolves each end-of-scope binding's type to a +// function literal, then checks whether that function's overloads contain the +// current definition. This correctly handles version-conditional branches and +// avoids picking up unrelated reassignments of the same name. +fn implementation_docstring<'db>(db: &'db dyn Db, definition: Definition<'db>) -> Option { + let DefinitionKind::Function(_) = definition.kind(db) else { + return None; + }; + + let name = definition.name(db)?; + let scope = definition.scope(db); + let symbol_id = place_table(db, scope).symbol_id(&name)?; + let use_def = use_def_map(db, scope); + + let current_overload = binding_type(db, definition) + .as_function_literal()? + .literal(db) + .last_definition; + + // Find the last end-of-scope binding whose function type contains this overload. + let implementation = use_def + .end_of_scope_symbol_bindings(symbol_id) + .filter_map(|binding| { + let ty = binding_type(db, binding.binding.definition()?).as_function_literal()?; + ty.iter_overloads_and_implementation(db) + .any(|overload| overload == current_overload) + .then_some(ty) + }) + .last()?; + + implementation.definition(db).docstring(db) +} + +/// Resolves a name's source definitions in `scope` and its visible ancestors, falling back to +/// implicit builtins if none are found. +/// +/// This function reads bindings and declarations from the semantic index without asking +/// `SemanticModel` to infer the name expression's type. It can therefore be used during inference +/// of the enclosing scope. Otherwise, requesting the expression's type through `SemanticModel` +/// could require that same scope's inference to finish, creating an inference cycle. +/// +/// Python's numeric compatibility rules mean that a `float` annotation accepts `int` values, +/// and a `complex` annotation accepts both `int` and `float` values. For these builtin names, +/// this function returns only the named class's definition. For editor navigation, +/// [`ide_support::definitions_for_name`](super::ide_support::definitions_for_name) uses the +/// inferred expression type to recognize these annotations and include the additional numeric classes +/// as navigation targets. +pub(crate) fn definitions_for_name<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + name: &str, + alias_resolution: ImportAliasResolution, +) -> Vec> { + let definitions = scoped_definitions_for_name(db, scope, name, alias_resolution); + if !definitions.is_empty() { + return definitions; + } + let env = ProgramEnvironment::from_scope(scope); + implicit_builtins_symbol_scope(db, &env, name) + .map(|scope| definitions_for_builtin(db, scope, name)) + .unwrap_or_default() +} + +/// Resolves definitions in visible scopes, without falling back to implicit builtins. +pub(crate) fn scoped_definitions_for_name<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + name_str: &str, + alias_resolution: ImportAliasResolution, +) -> Vec> { + let env = ProgramEnvironment::from_scope(scope); + let file = scope.program_file(db); + let index = semantic_index(db, file); + let file_scope = scope.file_scope_id(db); + + let mut all_definitions = FxIndexSet::default(); + + // Search through the scope hierarchy: start from the current scope and + // traverse up through parent scopes to find definitions + for (scope_id, _scope) in index.visible_ancestor_scopes(file_scope) { + let place_table = index.place_table(scope_id); + + let Some(symbol_id) = place_table.symbol_id(name_str) else { + continue; // Name not found in this scope, try parent scope + }; + + let use_def_map = index.use_def_map(scope_id); + + // Check if this place is marked as global or nonlocal + let place_expr = place_table.symbol(symbol_id); + let is_global = place_expr.is_global(); + let is_nonlocal = place_expr.is_nonlocal(); + + if is_global || is_nonlocal { + // Assignments in a forwarding scope remain valid navigation targets, including eager + // walrus bindings exported from comprehensions. + all_definitions.extend(user_visible_definitions( + db, + use_def_map + .reachable_symbol_bindings(symbol_id) + .filter_map(|binding| binding.binding.definition()) + .filter(|definition| match definition.kind(db) { + DefinitionKind::NamedExpression(_) => true, + DefinitionKind::NestedBindings(nested) => { + nested.execution == NestedBindingExecution::Eager + } + _ => false, + }), + )); + } + + // TODO: The current algorithm doesn't return definitions or bindings + // for other scopes that are outside of this scope hierarchy that target + // this name using a nonlocal or global binding. The semantic analyzer + // doesn't appear to track these in a way that we can easily access + // them from here without walking all scopes in the module. + + // If marked as global, skip to global scope + if is_global { + let global_scope_id = global_scope(db, file); + let global_place_table = ty_python_core::place_table(db, global_scope_id); + + if let Some(global_symbol_id) = global_place_table.symbol_id(name_str) { + let global_use_def_map = ty_python_core::use_def_map(db, global_scope_id); + all_definitions.extend(user_visible_definitions( + db, + global_use_def_map + .reachable_symbol_bindings(global_symbol_id) + .filter_map(|binding| binding.binding.definition()) + .chain( + global_use_def_map + .reachable_symbol_declarations(global_symbol_id) + .filter_map(|declaration| declaration.declaration.definition()), + ), + )); + } + break; + } + + // If marked as nonlocal, skip current scope and search in ancestor scopes + if is_nonlocal { + // Continue searching in parent scopes, but skip the current scope + continue; + } + + // Get all definitions (both bindings and declarations) for this place + all_definitions.extend(user_visible_definitions( + db, + use_def_map + .reachable_symbol_bindings(symbol_id) + .filter_map(|binding| binding.binding.definition()) + .chain( + use_def_map + .reachable_symbol_declarations(symbol_id) + .filter_map(|declaration| declaration.declaration.definition()), + ), + )); + + // If we found definitions in this scope, we can stop searching + if !all_definitions.is_empty() { + break; + } + } + + // Resolve import definitions to their targets + let mut resolved_definitions = Vec::new(); + + for definition in &all_definitions { + let resolved = resolve_definition(db, &env, *definition, Some(name_str), alias_resolution); + resolved_definitions.extend(resolved); + } + + resolved_definitions +} + +/// Resolves a symbol in an implicit builtins scope. +pub(crate) fn definitions_for_builtin<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + name: &str, +) -> Vec> { + let env = ProgramEnvironment::from_scope(scope); + find_symbol_in_scope(db, scope, name) + .into_iter() + .filter(|def| def.is_reexported(db)) + .flat_map(|def| { + resolve_definition( + db, + &env, + def, + Some(name), + ImportAliasResolution::ResolveAliases, + ) + }) + .collect() +} + +/// Returns source definitions for a member of an already-inferred receiver type. +/// +/// During type inference, a caller may already know the type of `obj` in `obj.attr` while +/// inference of the enclosing scope is still in progress. Asking `SemanticModel` for `obj`'s +/// type here could request inference of that same scope again, creating an inference cycle. +/// Accepting the receiver type directly lets the caller reuse its existing result without +/// introducing that dependency. +/// +/// This function duplicates much of the functionality in the semantic +/// analyzer, but it has somewhat different behavior so we've decided +/// to keep it separate for now. One key difference is that this function +/// doesn't model the descriptor protocol when accessing attributes. +/// For "go to definition", we want to get the type of the descriptor object +/// rather than "invoking" its `__get__` or `__set__` method. +/// If this becomes a maintenance burden in the future, it may be worth +/// changing the corresponding logic in the semantic analyzer to conditionally +/// handle this case through the use of mode flags. +pub(crate) fn definitions_for_attribute<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + lhs_ty: Type<'db>, + name_str: &str, +) -> Vec> { + let mut resolved = Vec::new(); + + // A structural protocol meta-type still uses its nominal protocol declaration as the source + // location for go-to-definition, even though the origin is not a nominal upper bound. + let subclass_origin = |subclass_of: SubclassOfInner<'db>| { + let class = match subclass_of { + SubclassOfInner::Protocol(protocol) => protocol.class_origin(db).map(|origin| *origin), + subclass_of => subclass_of.into_class(db, env), + }?; + class + .static_class_literal(db) + .map(|(literal, _)| ClassLiteral::Static(literal)) + }; + + let tys = match lhs_ty { + Type::Union(union) => union.elements(db), + _ => std::slice::from_ref(&lhs_ty), + }; + + // Expand intersections for each subtype into their components + let expanded_tys = tys + .iter() + .flat_map(|ty| match ty { + Type::Intersection(intersection) => Either::Left(intersection.positive(db).iter()), + _ => Either::Right(std::iter::once(ty)), + }) + .copied(); + + for ty in expanded_tys { + // Handle modules + if let Type::ModuleLiteral(module_literal) = ty { + if let Some(module_file) = module_literal + .module(db) + .file(db) + .map(|file| ProgramFile::new(db, file, env.program(db))) + { + let module_scope = global_scope(db, module_file); + for def in find_symbol_in_scope(db, module_scope, name_str) { + resolved.extend(resolve_definition( + db, + env, + def, + Some(name_str), + ImportAliasResolution::ResolveAliases, + )); + } + } + continue; + } + + // Prevent lookup on BoundSuper proxy object + if matches!(ty, Type::BoundSuper(_)) { + continue; + } + + let meta_type = ty.to_meta_type(db, env); + + // Look up the attribute first on the meta-type, unless it's already a class-like type. + let lookup_type = match ty { + Type::ClassLiteral(_) | Type::SubclassOf(_) | Type::GenericAlias(_) => ty, + _ => meta_type, + }; + + let class_literal = match lookup_type { + Type::ClassLiteral(class_literal) => class_literal, + Type::SubclassOf(subclass) => { + let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { + continue; + }; + class_literal + } + _ => continue, + }; + + resolved.extend(definitions_for_attribute_in_class_hierarchy( + db, + env, + &class_literal, + name_str, + )); + + // The metaclass of a derived class must be a subclass of the metaclasses of all of + // its base classes. This is why we only have to look at the metaclass of the + // class_literal. + // Only look up definitions on the metaclass if the type is a class object to begin with in + // order to prevent looking up instance members on the class metaclass + if resolved.is_empty() && meta_type != lookup_type { + let class_literal = match meta_type { + Type::ClassLiteral(class_literal) => class_literal, + Type::SubclassOf(subclass) => { + let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { + continue; + }; + class_literal + } + _ => continue, + }; + + resolved.extend(definitions_for_attribute_in_class_hierarchy( + db, + env, + &class_literal, + name_str, + )); + } + } + + resolved +} + +pub(crate) fn definitions_for_attribute_in_class_hierarchy<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + class_literal: &ClassLiteral<'db>, + attribute_name: &str, +) -> Vec> { + let mut resolved = Vec::new(); + 'scopes: for ancestor in class_literal + .iter_mro(db) + .filter_map(ClassBase::into_class) + .filter_map(|cls: ClassType<'db>| cls.static_class_literal(db).map(|(lit, _)| lit)) + { + let class_scope = ancestor.body_scope(db); + let class_place_table = ty_python_core::place_table(db, class_scope); + + // Look for class-level declarations and bindings + if let Some(place_id) = class_place_table.symbol_id(attribute_name) { + let use_def = use_def_map(db, class_scope); + let resolved_in_scope = resolve_reachable_definitions( + db, + env, + attribute_name, + use_def + .reachable_symbol_declarations(place_id) + .filter_map(|declaration| declaration.declaration.definition()) + .chain( + use_def + .reachable_symbol_bindings(place_id) + .filter_map(|binding| binding.binding.definition()), + ), + ); + if !resolved_in_scope.is_empty() { + resolved.extend(resolved_in_scope); + break 'scopes; + } + } + + // Look for instance attributes in method scopes (e.g., self.x = 1) + let index = semantic_index(db, class_scope.program_file(db)); + + for function_scope_id in attribute_scopes(db, class_scope) { + if let Some(place_id) = index + .place_table(function_scope_id) + .member_id_by_instance_attribute_name(attribute_name) + { + let use_def = index.use_def_map(function_scope_id); + let resolved_in_scope = resolve_reachable_definitions( + db, + env, + attribute_name, + use_def + .reachable_member_declarations(place_id) + .filter_map(|declaration| declaration.declaration.definition()) + .chain( + use_def + .reachable_member_bindings(place_id) + .filter_map(|binding| binding.binding.definition()), + ), + ); + if !resolved_in_scope.is_empty() { + resolved.extend(resolved_in_scope); + break 'scopes; + } + } + } + } + + resolved +} + +/// Returns the user-visible definitions represented by a use-def binding. +/// +/// Comprehension walruses are represented in the containing scope by synthetic eager bindings: +/// +/// ```python +/// [(last := item) for item in items] +/// print(last) # Go to definition should select `last := item` above. +/// ``` +/// +/// The binding for the use in `print` is synthetic, so follow it into the comprehension's +/// end-of-scope bindings. Nested comprehensions can produce a chain of these proxies. Only +/// follow sources that resolve to the same variable, so `global` and `nonlocal` writes do not +/// become definitions of each other. +pub(super) fn user_visible_definitions<'db>( + db: &'db dyn Db, + definitions: impl IntoIterator>, +) -> FxIndexSet> { + let mut pending = definitions.into_iter().collect::>(); + let mut seen = FxHashSet::default(); + let mut result = FxIndexSet::default(); + + while let Some(definition) = pending.pop_front() { + if !seen.insert(definition) { + continue; + } + + match definition.kind(db) { + DefinitionKind::NestedBindings(nested) => { + let index = semantic_index(db, definition.program_file(db)); + let sources = nested + .visible_binding_sources(index, definition.file_scope(db)) + .flatten() + .filter_map(|binding| binding.binding.definition()); + // A lazy function proxy can lead to an eager comprehension proxy. Follow that + // proxy-only chain without exposing ordinary lazy nested assignments. + pending.extend(sources.filter(|source| { + nested.execution == NestedBindingExecution::Eager + || matches!(source.kind(db), DefinitionKind::NestedBindings(_)) + })); + } + kind if kind.is_user_visible() => { + result.insert(definition); + } + _ => {} + } + } + + result +} + +fn resolve_reachable_definitions<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + symbol_name: &str, + definitions: impl IntoIterator>, +) -> Vec> { + user_visible_definitions(db, definitions) + .into_iter() + .flat_map(|definition| { + resolve_definition( + db, + env, + definition, + Some(symbol_name), + ImportAliasResolution::ResolveAliases, + ) + }) + .collect() +} + +/// Resolve import definitions to their targets. +/// Returns resolved definitions which can be either specific definitions or module files. +/// For non-import definitions, returns the definition wrapped in `ResolvedDefinition::Definition`. +/// Always returns at least the original definition as a fallback if resolution fails. +pub(crate) fn resolve_definition<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + definition: Definition<'db>, + symbol_name: Option<&str>, + alias_resolution: ImportAliasResolution, +) -> Vec> { + let mut visited = FxHashSet::default(); + let resolved = resolve_definition_recursive( + db, + env, + definition, + &mut visited, + symbol_name, + alias_resolution, + ); + + // If resolution failed, return the original definition as fallback + if resolved.is_empty() { + vec![ResolvedDefinition::Definition(definition)] + } else { + resolved + } +} + +/// Helper function to resolve import definitions recursively. +fn resolve_definition_recursive<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + definition: Definition<'db>, + visited: &mut FxHashSet>, + symbol_name: Option<&str>, + alias_resolution: ImportAliasResolution, +) -> Vec> { + // Prevent infinite recursion if there are circular imports + if visited.contains(&definition) { + return Vec::new(); // Return empty list for circular imports + } + visited.insert(definition); + + let kind = definition.kind(db); + + match kind { + DefinitionKind::Import(import_def) => { + let file = definition.program_file(db); + let module = parsed_module(db, file.python_file(db)).load(db); + let alias = import_def.alias(&module); + + if alias.asname.is_some() && alias_resolution == ImportAliasResolution::PreserveAliases + { + return vec![ResolvedDefinition::Definition(definition)]; + } + + // Get the full module name being imported + let Some(module_name) = ModuleName::new(&alias.name) else { + return Vec::new(); // Invalid module name, return empty list + }; + + // Resolve the module to its file + let importing_file = ImportingFile::File(file.file(db), env.resolver_environment(db)); + let Some(resolved_module) = resolve_module(db, importing_file, &module_name) else { + return Vec::new(); // Module not found, return empty list + }; + + let Some(module_file) = resolved_module.file(db) else { + return Vec::new(); // No file for module, return empty list + }; + let module_file = ProgramFile::new(db, module_file, env.program(db)); + + // For simple imports like "import os", we want to navigate to the module itself. + // Return the module file directly instead of trying to find definitions within it. + vec![ResolvedDefinition::Module(module_file)] + } + + DefinitionKind::ImportFrom(import_from_def) => { + let file = definition.program_file(db); + let module = parsed_module(db, file.python_file(db)).load(db); + let import_node = import_from_def.import(&module); + let alias = import_from_def.alias(&module); + + if alias.asname.is_some() && alias_resolution == ImportAliasResolution::PreserveAliases + { + return vec![ResolvedDefinition::Definition(definition)]; + } + + // For `ImportFrom`, we need to resolve the original imported symbol name + // (alias.name), not the local alias (symbol_name) + resolve_from_import_definitions( + db, + env, + ImportingFile::File(file.file(db), env.resolver_environment(db)), + import_node, + &alias.name, + visited, + alias_resolution, + ) + } + + // For star imports, try to resolve to the specific symbol being accessed + DefinitionKind::StarImport(star_import_def) => { + let file = definition.program_file(db); + let module = parsed_module(db, file.python_file(db)).load(db); + let import_node = star_import_def.import(&module); + + // If we have a symbol name, use the helper to resolve it in the target module + if let Some(symbol_name) = symbol_name { + resolve_from_import_definitions( + db, + env, + ImportingFile::File(file.file(db), env.resolver_environment(db)), + import_node, + symbol_name, + visited, + alias_resolution, + ) + } else { + // No symbol context provided, can't resolve star import + Vec::new() + } + } + + // For non-import definitions, return the definition as is + _ => vec![ResolvedDefinition::Definition(definition)], + } +} + +/// Helper function to resolve import definitions for `ImportFrom` and `StarImport` cases. +pub(crate) fn resolve_from_import_definitions<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + importing_file: ImportingFile<'db>, + import_node: &ast::StmtImportFrom, + symbol_name: &str, + visited: &mut FxHashSet>, + alias_resolution: ImportAliasResolution, +) -> Vec> { + if alias_resolution == ImportAliasResolution::PreserveAliases { + for alias in &import_node.names { + if let Some(asname) = &alias.asname { + if asname.as_str() == symbol_name { + return vec![ResolvedDefinition::FileWithRange(FileRange::new( + importing_file.file(db), + asname.range, + ))]; + } + } + } + } + + let Some(resolved_module) = resolve_module_for_import_from(db, importing_file, import_node) + else { + return Vec::new(); + }; + + // Resolve the target module file + let module_file = resolved_module + .file(db) + .map(|file| ProgramFile::new(db, file, env.program(db))); + + let Some(module_file) = module_file else { + // No file means this is a namespace package, try to import the submodule + return Vec::from_iter(resolve_from_import_submodule_definitions( + db, + env, + importing_file, + symbol_name, + resolved_module.name(db), + )); + }; + + // Find the definition of this symbol in the imported module's global scope + let global_scope = global_scope(db, module_file); + let definitions_in_module = find_symbol_in_scope(db, global_scope, symbol_name); + + // Recursively resolve any import definitions found in the target module + let mut resolved_definitions = Vec::new(); + for def in definitions_in_module { + let resolved = resolve_definition_recursive( + db, + env, + def, + visited, + Some(symbol_name), + alias_resolution, + ); + resolved_definitions.extend(resolved); + } + + if resolved_definitions.is_empty() { + // In `pkg/__init__.py`, `from . import child` resolves `.` to + // `pkg/__init__.py`. Looking up `child` there can find an import definition + // that recursively resolves back here (possibly through `from . import *`), + // so recursive resolution bottoms out before reaching the `pkg.child` + // submodule target. Fall back to the same submodule candidate we use when + // `child` has no binding in `pkg/__init__.py`. + Vec::from_iter(resolve_from_import_submodule_definitions( + db, + env, + importing_file, + symbol_name, + resolved_module.name(db), + )) + } else { + resolved_definitions + } +} + +// Helper to resolve `from x.y import z` assuming `x.y.z` is a module. +fn resolve_from_import_submodule_definitions<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + importing_file: ImportingFile<'db>, + symbol_name: &str, + module_name: &ModuleName, +) -> Option> { + let submodule_name = ModuleName::new(symbol_name)?; + let mut full_submodule_name = module_name.clone(); + full_submodule_name.extend(&submodule_name); + let module = resolve_module(db, importing_file, &full_submodule_name)?; + let file = ProgramFile::new(db, module.file(db)?, env.program(db)); + + Some(ResolvedDefinition::Module(file)) +} + +/// Find definitions for a symbol name in a specific scope. +pub(crate) fn find_symbol_in_scope<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + symbol_name: &str, +) -> IndexSet> { + let place_table = place_table(db, scope); + let Some(symbol_id) = place_table.symbol_id(symbol_name) else { + return IndexSet::new(); + }; + + let use_def_map = use_def_map(db, scope); + let mut definitions = IndexSet::new(); + + // Get all definitions (both bindings and declarations) for this place + let bindings = use_def_map.reachable_symbol_bindings(symbol_id); + let declarations = use_def_map.reachable_symbol_declarations(symbol_id); + + for binding in bindings { + if let Some(def) = binding.binding.definition() { + definitions.insert(def); + } + } + + for declaration in declarations { + if let Some(def) = declaration.declaration.definition() { + definitions.insert(def); + } + } + + user_visible_definitions(db, definitions) + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use ruff_db::files::system_path_to_file; + use ruff_db::testing::assert_function_query_was_not_run_by_name; + + use super::*; + use crate::db::tests::TestDbBuilder; + + #[test] + fn builtin_names_do_not_infer_scope() -> anyhow::Result<()> { + for name in ["isinstance", "float", "complex"] { + let mut db = TestDbBuilder::new() + .with_file("/src/foo.py", name) + .build()?; + let file = system_path_to_file(&db, "/src/foo.py")?; + let file = ProgramFile::new(&db, file, db.program_environment().program(&db)); + let definitions = definitions_for_name( + &db, + global_scope(&db, file), + name, + ImportAliasResolution::ResolveAliases, + ); + let [ResolvedDefinition::Definition(definition)] = definitions.as_slice() else { + anyhow::bail!("expected one definition for {name}"); + }; + assert_eq!(definition.name(&db).as_deref(), Some(name)); + + let events = db.take_salsa_events(); + assert_function_query_was_not_run_by_name(&db, "infer_scope_types_impl", None, &events); + } + Ok(()) + } + + #[test] + fn attribute_lookup_does_not_infer_scope() -> anyhow::Result<()> { + let mut db = TestDbBuilder::new() + .with_file("/src/foo.py", "class C:\n flag = (1, 2)\n") + .build()?; + let file = system_path_to_file(&db, "/src/foo.py")?; + let file = ProgramFile::new(&db, file, db.program_environment().program(&db)); + let parsed = parsed_module(&db, file.python_file(&db)).load(&db); + let class = parsed + .suite() + .first() + .and_then(ast::Stmt::as_class_def_stmt) + .context("expected a class definition")?; + let definition = semantic_index(&db, file).expect_single_definition(class); + let receiver = binding_type(&db, definition); + let definitions = + definitions_for_attribute(&db, &db.program_environment(), receiver, "flag"); + let [ResolvedDefinition::Definition(definition)] = definitions.as_slice() else { + anyhow::bail!("expected one definition for C.flag"); + }; + assert_eq!(definition.name(&db).as_deref(), Some("flag")); + + let events = db.take_salsa_events(); + assert_function_query_was_not_run_by_name(&db, "infer_scope_types_impl", None, &events); + Ok(()) + } +} diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index e6e147b391..e7e70f0c5e 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -1,15 +1,20 @@ +// The doc-comments for structs in this file are user-facing rule documentation, +// not intended for rustdoc to render. +#![expect(clippy::doc_link_with_quotes, clippy::doc_overindented_list_items)] + use super::call::CallErrorKind; use super::context::InferContext; use super::mro::DuplicateBaseError; use super::{ CallArguments, CallDunderError, ClassBase, ClassLiteral, GenericAlias, KnownClass, - StaticClassLiteral, add_inferred_python_version_hint_to_diagnostic, + ModuleLiteralType, StaticClassLiteral, add_inferred_python_version_hint_to_diagnostic, }; use crate::diagnostic::{did_you_mean, format_enumeration}; use crate::lint::{Level, LintRegistryBuilder, LintStatus, TyCompat}; use crate::place::{DefinedPlace, Place, place_from_bindings}; use crate::suppression::FileSuppressionId; -use crate::types::call::{CallDiagnosticOverride, CallError}; +use crate::types::call::bind::CallableDescription; +use crate::types::call::{Bindings, CallDiagnosticOverride, CallError}; use crate::types::class::{ CodeGeneratorKind, DisjointBase, DisjointBaseKind, ExpandedClassBaseEntry, MethodDecorator, }; @@ -26,6 +31,7 @@ use crate::types::string_annotation::{ use crate::types::tuple::TupleSpec; use crate::types::typed_dict::TypedDictSchema; use crate::types::typevar::TypeVarInstance; +use crate::types::unpacker::{starred_assignment_values, unpacked_assignment_value}; use crate::types::{ BoundTypeVarInstance, ClassType, DynamicType, ErrorContextTree, LintDiagnosticGuard, SpecialFormType, SubclassOfInner, Type, TypeContext, TypeVarVariance, binding_type, @@ -35,7 +41,7 @@ use crate::types::{ KnownInstanceType, LiteralValueTypeKind, MemberLookupPolicy, TypeVarKind, TypedDictType, UnionType, }; -use crate::{Db, DisplaySettings, FxIndexMap, ProgramEnvironment, declare_lint}; +use crate::{Db, DisplaySettings, FxIndexMap, ProgramEnvironment, SemanticModel, declare_lint}; use itertools::Itertools; use ruff_db::source::source_text; use ruff_db::{ @@ -51,8 +57,8 @@ use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; use std::fmt::{self, Formatter}; -use ty_module_resolver::{KnownModule, Module, ModuleName, file_to_module}; -use ty_python_core::definition::{Definition, DefinitionKind}; +use ty_module_resolver::{KnownModule, Module, ModuleName, SearchPath, file_to_module}; +use ty_python_core::definition::{Definition, DefinitionKind, ParameterDefinitionNodeKind}; use ty_python_core::place::{PlaceTable, ScopedPlaceId}; use ty_python_core::predicate::CaseNamePredicateKind; use ty_python_core::{ProgramFile, global_scope, place_table, use_def_map}; @@ -74,6 +80,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&CYCLIC_TYPE_ALIAS_DEFINITION); registry.register_lint(&DEPRECATED); registry.register_lint(&DIVISION_BY_ZERO); + registry.register_lint(&DYNAMIC_FUNCTION_DECORATOR_RETURN); registry.register_lint(&DUPLICATE_BASE); registry.register_lint(&DUPLICATE_KW_ONLY); registry.register_lint(&DATACLASS_FIELD_ORDER); @@ -95,6 +102,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&IMPLICIT_DECLARATION); registry.register_lint(&REFUTABLE_UNPACKING); registry.register_lint(&ITERATION_OVER_CHARACTER); + registry.register_lint(&UNSOUND_ASSIGNMENT); registry.register_lint(&INVALID_AWAIT); registry.register_lint(&INVALID_BASE); registry.register_lint(&INVALID_CONTEXT_MANAGER); @@ -103,6 +111,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_ENUM_MEMBER_ANNOTATION); registry.register_lint(&INVALID_GENERIC_ENUM); registry.register_lint(&INVALID_GENERIC_CLASS); + registry.register_lint(&INVALID_MODULE_GETATTR_CALL); registry.register_lint(&INVALID_LEGACY_TYPE_VARIABLE); registry.register_lint(&INVALID_PARAMSPEC); registry.register_lint(&INVALID_TYPE_ALIAS_TYPE); @@ -129,6 +138,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_TYPE_VARIABLE_DEFAULT); registry.register_lint(&UNBOUND_TYPE_VARIABLE); registry.register_lint(&MISSING_ARGUMENT); + registry.register_lint(&MISSING_DIRECT_DEPENDENCY); registry.register_lint(&MISSING_TYPE_ARGUMENT); registry.register_lint(&NO_MATCHING_OVERLOAD); registry.register_lint(&NON_CALLABLE_INIT_SUBCLASS); @@ -208,6 +218,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&UNDECLARED_DEPENDENCY); registry.register_lint(&MISPLACED_DEPENDENCY); registry.register_lint(&UNRESOLVED_ATTRIBUTE); + registry.register_lint(&MISSING_SLOT); registry.register_lint(&UNRESOLVED_IMPORT); registry.register_lint(&UNRESOLVED_REFERENCE); registry.register_lint(&UNSUPPORTED_BASE); @@ -218,6 +229,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&ZERO_STEPSIZE_IN_SLICE); registry.register_lint(&STATIC_ASSERT_ERROR); registry.register_lint(&INVALID_ATTRIBUTE_ACCESS); + registry.register_lint(&DISJOINT_CAST); registry.register_lint(&REDUNDANT_CAST); registry.register_lint(&REDUNDANT_FINAL_CLASSVAR); registry.register_lint(&UNRESOLVED_GLOBAL); @@ -377,7 +389,6 @@ declare_lint! { } declare_lint! { - #[expect(clippy::doc_overindented_list_items)] #[doc = include_str!("../../resources/lint_docs/invalid-dataclass.md")] pub(crate) static INVALID_DATACLASS = { summary: "detects invalid `@dataclass` applications", @@ -497,7 +508,15 @@ declare_lint! { } declare_lint! { - #[expect(clippy::doc_link_with_quotes)] + #[doc = include_str!("../../resources/lint_docs/dynamic-function-decorator-return.md")] + pub(crate) static DYNAMIC_FUNCTION_DECORATOR_RETURN = { + summary: "detects decorators that replace a function with a dynamic type such as `Any`", + status: LintStatus::stable("0.0.73"), + default_level: Level::Ignore, + } +} + +declare_lint! { #[doc = include_str!("../../resources/lint_docs/unsound-return-statement.md")] pub(crate) static UNSOUND_RETURN_STATEMENT = { summary: "detects return statements that unsoundly return a type that is not a subtype of the function's annotated return type", @@ -571,6 +590,15 @@ declare_lint! { } } +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/unsound-assignment.md")] + pub(crate) static UNSOUND_ASSIGNMENT = { + summary: "detects assignments that unsoundly assign a type that is not a subtype of the declared type", + status: LintStatus::stable("0.0.73"), + default_level: Level::Ignore, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/invalid-await.md")] pub(crate) static INVALID_AWAIT = { @@ -662,6 +690,15 @@ declare_lint! { } } +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/invalid-module-getattr-call.md")] + pub(crate) static INVALID_MODULE_GETATTR_CALL = { + summary: "detects imports that fail while calling module-level `__getattr__`", + status: LintStatus::stable("0.0.72"), + default_level: Level::Error, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/non-callable-init-subclass.md")] pub(crate) static NON_CALLABLE_INIT_SUBCLASS = { @@ -2881,6 +2918,15 @@ declare_lint! { } } +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/missing-slot.md")] + pub(crate) static MISSING_SLOT = { + summary: "detects assignments to declared attributes without instance storage", + status: LintStatus::stable("0.0.75"), + default_level: Level::Error, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/unresolved-import.md")] pub static UNRESOLVED_IMPORT = { @@ -2890,6 +2936,19 @@ declare_lint! { } } +declare_lint! { + #[allow( + rustdoc::invalid_codeblock_attributes, + reason = "`data-mdtest` is an mdtest-specific code-block attribute" + )] + #[doc = include_str!("../../resources/lint_docs/missing-direct-dependency.md")] + pub(crate) static MISSING_DIRECT_DEPENDENCY = { + summary: "detects imports of dependencies that are not declared directly", + status: LintStatus::preview("0.0.76"), + default_level: Level::Ignore, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/unresolved-reference.md")] pub static UNRESOLVED_REFERENCE = { @@ -2944,6 +3003,15 @@ declare_lint! { } } +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/disjoint-cast.md")] + pub(crate) static DISJOINT_CAST = { + summary: "detects `cast` calls between disjoint types", + status: LintStatus::stable("0.0.78"), + default_level: Level::Ignore, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/redundant-cast.md")] pub(crate) static REDUNDANT_CAST = { @@ -3026,7 +3094,6 @@ declare_lint! { } declare_lint! { - #[expect(clippy::doc_overindented_list_items)] #[doc = include_str!("../../resources/lint_docs/invalid-method-override.md")] pub(crate) static INVALID_METHOD_OVERRIDE = { summary: "detects method definitions that violate the Liskov Substitution Principle", @@ -3556,13 +3623,14 @@ pub(super) fn report_slice_step_size_zero(context: &InferContext, node: AnyNodeR // We avoid emitting invalid assignment diagnostic for literal assignments to a `TypedDict`, as // they can only occur if we already failed to validate the dict (and emitted some diagnostic). -pub(crate) fn is_invalid_typed_dict_literal( - db: &dyn Db, - target_ty: Type, +pub(crate) fn is_invalid_typed_dict_literal<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + target_ty: Type<'db>, source: AnyNodeRef<'_>, ) -> bool { target_ty - .filter_union(db, Type::is_typed_dict) + .filter_union(db, env, Type::is_typed_dict) .as_typed_dict() .is_some() && matches!(source, AnyNodeRef::ExprDict(_)) @@ -3716,6 +3784,310 @@ pub(super) fn add_invariant_generic_hints<'db>( ); } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum DeclarationKind { + VariadicParameter, + KeywordVariadicParameter, + Regular, +} + +struct AssignmentDeclarationAnnotation { + range: TextRange, + declaration_kind: DeclarationKind, +} + +impl AssignmentDeclarationAnnotation { + fn into_annotation( + self, + context: &InferContext, + target_type_display: impl fmt::Display, + ordinary_message: impl fmt::Display, + ) -> Annotation { + let annotation = context.secondary(self.range); + + match self.declaration_kind { + DeclarationKind::KeywordVariadicParameter => annotation.message(format_args!( + "Keyword-variadic parameter annotation declares the type as `{target_type_display}`" + )), + DeclarationKind::VariadicParameter => annotation.message(format_args!( + "Variadic parameter annotation declares the type as `{target_type_display}`" + )), + DeclarationKind::Regular => annotation.message(ordinary_message), + } + } +} + +/// Locate the annotation that uniquely declares an assignment target's type. +fn assignment_declaration_annotation<'db>( + context: &InferContext<'db, '_>, + definition_kind: &DefinitionKind<'db>, + declaration: Option>, +) -> Option { + let db = context.db(); + let declaration_definition_kind = + if matches!(definition_kind, DefinitionKind::AnnotatedAssignment(_)) { + definition_kind + } else { + declaration?.kind(db) + }; + + let (annotation, declaration_kind) = match declaration_definition_kind { + DefinitionKind::AnnotatedAssignment(assignment) => Some(( + assignment.annotation(context.module()), + DeclarationKind::Regular, + )), + DefinitionKind::Parameter(ParameterDefinitionNodeKind::Parameter(parameter)) => parameter + .node(context.module()) + .parameter + .annotation + .as_deref() + .map(|annotation| (annotation, DeclarationKind::Regular)), + DefinitionKind::Parameter(ParameterDefinitionNodeKind::VariadicPositionalParameter( + parameter, + )) => parameter + .node(context.module()) + .annotation + .as_deref() + .map(|annotation| (annotation, DeclarationKind::VariadicParameter)), + DefinitionKind::Parameter(ParameterDefinitionNodeKind::VariadicKeywordParameter( + parameter, + )) => parameter + .node(context.module()) + .annotation + .as_deref() + .map(|annotation| (annotation, DeclarationKind::KeywordVariadicParameter)), + DefinitionKind::Import(_) + | DefinitionKind::ImportFrom(_) + | DefinitionKind::ImportFromSubmodule(_) + | DefinitionKind::StarImport(_) + | DefinitionKind::Function(_) + | DefinitionKind::Class(_) + | DefinitionKind::TypeAlias(_) + | DefinitionKind::NamedExpression(_) + | DefinitionKind::Assignment(_) + | DefinitionKind::AugmentedAssignment(_) + | DefinitionKind::DictKeyAssignment(_) + | DefinitionKind::For(_) + | DefinitionKind::Comprehension(_) + | DefinitionKind::LambdaParameter(_) + | DefinitionKind::WithItem(_) + | DefinitionKind::MatchPattern(_) + | DefinitionKind::ExceptHandler(_) + | DefinitionKind::TypeVar(_) + | DefinitionKind::ParamSpec(_) + | DefinitionKind::TypeVarTuple(_) + | DefinitionKind::LoopHeader(_) + | DefinitionKind::StatementExpressionValue(_) + | DefinitionKind::TypeMatchCapture(_) + | DefinitionKind::NestedBindings(_) => None, + }?; + + Some(AssignmentDeclarationAnnotation { + range: annotation.range(), + declaration_kind, + }) +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum AssignmentDiagnosticKind { + Invalid, + Unsound, +} + +/// Return the expression assigned by an ordinary or named assignment. +fn assignment_value_node<'db, 'ast>( + context: &InferContext<'db, 'ast>, + definition_kind: &DefinitionKind<'db>, +) -> Option<&'ast ast::Expr> { + match definition_kind { + DefinitionKind::Assignment(assignment) if let Some(unpack) = assignment.unpack() => { + let module = context.module(); + let value = assignment.value(module); + + Some( + unpacked_assignment_value( + unpack.target(context.db(), module), + value, + assignment.target(module), + ) + .unwrap_or(value), + ) + } + DefinitionKind::Assignment(_) | DefinitionKind::AnnotatedAssignment(_) => { + definition_kind.value(context.module()) + } + DefinitionKind::NamedExpression(assignment) => { + Some(&*assignment.node(context.module()).value) + } + DefinitionKind::Import(_) + | DefinitionKind::ImportFrom(_) + | DefinitionKind::ImportFromSubmodule(_) + | DefinitionKind::StarImport(_) + | DefinitionKind::Function(_) + | DefinitionKind::Class(_) + | DefinitionKind::TypeAlias(_) + | DefinitionKind::AugmentedAssignment(_) + | DefinitionKind::DictKeyAssignment(_) + | DefinitionKind::For(_) + | DefinitionKind::Comprehension(_) + | DefinitionKind::Parameter(_) + | DefinitionKind::LambdaParameter(_) + | DefinitionKind::WithItem(_) + | DefinitionKind::MatchPattern(_) + | DefinitionKind::ExceptHandler(_) + | DefinitionKind::TypeVar(_) + | DefinitionKind::ParamSpec(_) + | DefinitionKind::TypeVarTuple(_) + | DefinitionKind::LoopHeader(_) + | DefinitionKind::StatementExpressionValue(_) + | DefinitionKind::TypeMatchCapture(_) + | DefinitionKind::NestedBindings(_) => None, + } +} + +/// Return the range of an assignment's value, including any surrounding parentheses. +fn assignment_diagnostic_range( + context: &InferContext, + target_node: AnyNodeRef, + value_node: Option<&ast::Expr>, + starred_element: Option<&StarredAssignmentElement>, +) -> TextRange { + if let Some(starred_element) = starred_element { + return starred_element.collected_range; + } + + value_node + .map(|value_node| { + // Expand the range to include parentheses around the value, if any. This allows + // assignment diagnostics to be suppressed on the opening or closing parenthesis: + // ```py + // x: str = ( # ty: ignore <- here + // 1 + 2 + 3 + // ) # ty: ignore <- or here + // ``` + parentheses_iterator(value_node.into(), None, context.module().tokens()) + .last() + .unwrap_or(value_node.range()) + }) + .unwrap_or_else(|| target_node.range()) +} + +/// The incompatible element type and source range collected into a starred unpacking target. +#[derive(Debug)] +struct StarredAssignmentElement<'db> { + collected_range: TextRange, + actual_type: Type<'db>, + expected_type: Type<'db>, +} + +fn assignment_display_settings<'db>( + context: &InferContext<'db, '_>, + target_type: Type<'db>, + value_type: Type<'db>, + starred_element: Option<&StarredAssignmentElement<'db>>, +) -> DisplaySettings<'db> { + DisplaySettings::from_possibly_ambiguous_types( + context.db(), + context.program_environment(), + starred_element + .into_iter() + .flat_map(|element| [element.actual_type, element.expected_type]) + .chain([target_type, value_type]), + ) +} + +fn starred_assignment_element<'db>( + context: &InferContext<'db, '_>, + definition_kind: &DefinitionKind<'db>, + target_type: Type<'db>, + value_type: Type<'db>, + diagnostic_kind: AssignmentDiagnosticKind, +) -> Option> { + let DefinitionKind::Assignment(assignment) = definition_kind else { + return None; + }; + let unpack = assignment.unpack()?; + let db = context.db(); + let env = context.program_environment(); + let module = context.module(); + let collected = starred_assignment_values( + unpack.target(db, module), + assignment.value(module), + assignment.target(module), + )?; + let expected_type = target_type + .try_iterate(db, env) + .ok()? + .homogeneous_element_type(db, env); + let actual_type = value_type + .try_iterate(db, env) + .ok()? + .homogeneous_element_type(db, env); + + let compatible = match diagnostic_kind { + AssignmentDiagnosticKind::Invalid => actual_type.is_assignable_to(db, env, expected_type), + AssignmentDiagnosticKind::Unsound => { + actual_type.is_pure_redundant_with(db, env, expected_type) + } + }; + if compatible { + return None; + } + + Some(StarredAssignmentElement { + collected_range: collected.first()?.range().cover(collected.last()?.range()), + actual_type, + expected_type, + }) +} + +fn annotate_unpacked_assignment_target( + context: &InferContext, + diagnostic: &mut LintDiagnosticGuard, + target: AnyNodeRef, + definition_kind: &DefinitionKind, +) { + if let DefinitionKind::Assignment(assignment) = definition_kind + && assignment.unpack().is_some() + { + diagnostic.annotate( + context + .secondary(target) + .message("Assigned to this variable"), + ); + } +} + +/// Set an assignment's primary message and return whether a message was added. +fn set_assignment_primary_annotation( + diagnostic: &mut LintDiagnosticGuard, + definition_kind: &DefinitionKind, + value_node: Option<&ast::Expr>, + value_type_display: impl fmt::Display, + diagnostic_kind: AssignmentDiagnosticKind, +) -> bool { + match (value_node, definition_kind, diagnostic_kind) { + (None, DefinitionKind::AugmentedAssignment(_), _) => { + diagnostic.set_primary_annotation_message(format_args!( + "Augmented assignment produces a value of type `{value_type_display}`" + )); + true + } + (Some(_), _, AssignmentDiagnosticKind::Invalid) => { + diagnostic.set_primary_annotation_message(format_args!( + "Incompatible value of type `{value_type_display}`" + )); + true + } + (_, _, AssignmentDiagnosticKind::Unsound) => { + diagnostic + .set_primary_annotation_message(format_args!("Inferred as `{value_type_display}`")); + true + } + (None, _, AssignmentDiagnosticKind::Invalid) => false, + } +} + /// The numeric types a `bool` reaches by way of `int`: `int` itself, which it /// subclasses, plus the two the language promotes `int` to. const NUMERIC_SUPERTYPES_OF_BOOL: [KnownClass; 3] = @@ -3846,16 +4218,23 @@ pub(super) fn report_invalid_assignment<'db>( context: &InferContext<'db, '_>, target_node: AnyNodeRef, definition: Definition<'db>, + declaration: Option>, target_ty: Type, value_ty: Type<'db>, ) { let env = context.program_environment(); let db = context.db(); let definition_kind = definition.kind(context.db()); - let value_node = assigned_value_node(context, definition); + let value_node = assignment_value_node(context, definition_kind); + let original_value_node = definition_kind.value(context.module()).or(value_node); - if let Some(value_node) = value_node - && is_invalid_typed_dict_literal(db, target_ty, value_node.into()) + if let Some(value_node) = original_value_node + && is_invalid_typed_dict_literal( + db, + context.program_environment(), + target_ty, + value_node.into(), + ) { return; } @@ -3885,31 +4264,25 @@ pub(super) fn report_invalid_assignment<'db>( } let env = &context.program_environment(); - let settings = DisplaySettings::from_possibly_ambiguous_types(db, env, [target_ty, value_ty]); - - let diagnostic_range = if let Some(value_node) = value_node { - // Expand the range to include parentheses around the value, if any. This allows - // invalid-assignment diagnostics to be suppressed on the opening or closing parenthesis: - // ```py - // x: str = ( # ty: ignore <- here - // 1 + 2 + 3 - // ) # ty: ignore <- or here - // ``` - - parentheses_iterator(value_node.into(), None, context.module().tokens()) - .last() - .unwrap_or(value_node.range()) - } else { - target_node.range() - }; + let invalid_element = starred_assignment_element( + context, + definition_kind, + target_ty, + value_ty, + AssignmentDiagnosticKind::Invalid, + ); + let settings = + assignment_display_settings(context, target_ty, value_ty, invalid_element.as_ref()); + let diagnostic_range = + assignment_diagnostic_range(context, target_node, value_node, invalid_element.as_ref()); let Some(mut diag) = report_invalid_assignment_with_message( context, diagnostic_range, format_args!( "Object of type `{}` is not assignable to `{}`", value_ty.display_with(db, env, settings.clone()), - target_ty.display_with(db, env, settings) + target_ty.display_with(db, env, settings.clone()) ), ) else { return; @@ -3935,33 +4308,47 @@ pub(super) fn report_invalid_assignment<'db>( } } - if value_node.is_some() { - match definition_kind { - DefinitionKind::AnnotatedAssignment(assignment) => { - // For annotated assignments, just point to the annotation in the source code. - diag.annotate( - context - .secondary(assignment.annotation(context.module())) - .message("Declared type"), - ); - } - _ => { - // Otherwise, annotate the target with its declared type. - diag.annotate(context.secondary(target_node).message(format_args!( - "Declared type `{}`", - target_ty.display(db, env) - ))); - } - } + if let Some(declaration_annotation) = + assignment_declaration_annotation(context, definition_kind, declaration) + { + diag.annotate(declaration_annotation.into_annotation( + context, + target_ty.display_with(db, env, settings.clone()), + "Declared type", + )); + annotate_unpacked_assignment_target(context, &mut diag, target_node, definition_kind); + } else if value_node.is_some() { + diag.annotate(context.secondary(target_node).message(format_args!( + "Declared type `{}`", + target_ty.display_with(db, env, settings.clone()) + ))); + } + let has_primary_annotation = if let Some(element) = invalid_element { diag.set_primary_annotation_message(format_args!( - "Incompatible value of type `{}`", - value_ty.display(db, env), + "Incompatible iterable element of type `{}` (expected `{}`)", + element.actual_type.display_with(db, env, settings.clone()), + element + .expected_type + .display_with(db, env, settings.clone()), )); + true + } else { + set_assignment_primary_annotation( + &mut diag, + definition_kind, + value_node, + value_ty.display_with(db, env, settings), + AssignmentDiagnosticKind::Invalid, + ) + }; + if value_node.is_some() { let error_context = value_ty.assignability_error_context(db, env, target_ty); error_context.attach_to(db, env, &mut diag); + } + if has_primary_annotation { // Overwrite the concise message to avoid showing the value type twice let message = diag.headline_message().to_string(); diag.set_concise_message(message); @@ -3972,6 +4359,95 @@ pub(super) fn report_invalid_assignment<'db>( add_invariant_generic_hints(db, env, &mut diag, target_ty, value_ty); } +/// Report an assignment whose value is not a subtype of its declared type. +pub(super) fn report_unsound_assignment<'db>( + context: &InferContext<'db, '_>, + target_node: AnyNodeRef, + definition: Definition<'db>, + declaration: Option>, + target_ty: Type<'db>, + value_ty: Type<'db>, + expression_type: impl FnOnce(&ast::Expr) -> Type<'db>, +) { + let db = context.db(); + let env = context.program_environment(); + let definition_kind = definition.kind(db); + let (target_node, value_node) = + if let DefinitionKind::AugmentedAssignment(assignment) = definition_kind { + let assignment = assignment.node(context.module()); + + if expression_type(&assignment.value).is_equivalent_to(db, env, value_ty) { + (target_node, Some(assignment.value.as_ref())) + } else { + (assignment.into(), None) + } + } else { + (target_node, assignment_value_node(context, definition_kind)) + }; + + let unsound_element = starred_assignment_element( + context, + definition_kind, + target_ty, + value_ty, + AssignmentDiagnosticKind::Unsound, + ); + let diagnostic_range = + assignment_diagnostic_range(context, target_node, value_node, unsound_element.as_ref()); + + let Some(builder) = context.report_lint(&UNSOUND_ASSIGNMENT, diagnostic_range) else { + return; + }; + + let settings = + assignment_display_settings(context, target_ty, value_ty, unsound_element.as_ref()); + let actual_display = value_ty.display_with(db, env, settings.clone()); + let expected_display = target_ty.display_with(db, env, settings.clone()); + + let mut diagnostic = builder.into_diagnostic("Unsound assignment"); + diagnostic.set_concise_message(format_args!( + "Unsound assignment: `{actual_display}` is not a subtype of `{expected_display}`" + )); + if let Some(element) = unsound_element { + diagnostic.set_primary_annotation_message(format_args!( + "Iterable element inferred as `{}` (expected a subtype of `{}`)", + element.actual_type.display_with(db, env, settings.clone()), + element.expected_type.display_with(db, env, settings), + )); + } else { + set_assignment_primary_annotation( + &mut diagnostic, + definition_kind, + value_node, + &actual_display, + AssignmentDiagnosticKind::Unsound, + ); + } + + if let Some(declaration_annotation) = + assignment_declaration_annotation(context, definition_kind, declaration) + { + diagnostic.annotate(declaration_annotation.into_annotation( + context, + &expected_display, + format_args!("Expected a subtype of `{expected_display}` because of this annotation"), + )); + annotate_unpacked_assignment_target(context, &mut diagnostic, target_node, definition_kind); + } else if value_node.is_some() { + diagnostic.annotate(context.secondary(target_node).message(format_args!( + "Expected a subtype of `{expected_display}` because of its declared type" + ))); + } + + diagnostic.info(format_args!( + "`{actual_display}` is assignable to `{expected_display}`, \ + but not a subtype of `{expected_display}`" + )); + let error_context = value_ty.pure_redundancy_error_context(db, env, target_ty); + error_context.attach_to(db, env, &mut diagnostic); + diagnostic.help("Consider using an `assert` to narrow the type before assigning it"); +} + pub(super) fn report_invalid_attribute_assignment( context: &InferContext, range: TextRange, @@ -3986,13 +4462,14 @@ pub(super) fn report_invalid_attribute_assignment( // diagnostic being emitted here. let env = &context.program_environment(); + let settings = DisplaySettings::from_possibly_ambiguous_types(db, env, [source_ty, target_ty]); let Some(mut diag) = report_invalid_assignment_with_message( context, range, format_args!( "Object of type `{}` is not assignable to attribute `{attribute_name}` of type `{}`", - source_ty.display(db, env), - target_ty.display(db, env), + source_ty.display_with(db, env, settings.clone()), + target_ty.display_with(db, env, settings), ), ) else { return; @@ -4112,6 +4589,35 @@ pub(super) fn report_bad_attribute_access_call<'db>( ); } +/// Reports an import that fails while implicitly calling module-level `__getattr__`. +/// +/// ```python +/// from package import missing # Calls package.__getattr__("missing"). +/// ``` +pub(super) fn report_bad_import_call<'db>( + context: &InferContext<'db, '_>, + failure: &CallError<'db>, + module: ModuleLiteralType<'db>, + target: &ast::Alias, + name: &str, +) { + let db = context.db(); + + failure.report_diagnostics_with_override( + context, + target.into(), + &CallDiagnosticOverride { + lint: &INVALID_MODULE_GETATTR_CALL, + message: format!( + "Cannot import `{name}` from module `{}`", + module.module(db).name(db), + ), + info: "This import implicitly calls a module-level `__getattr__` function", + argument_ranges: &[target.range()], + }, + ); +} + pub(super) fn report_bad_dunder_set_call<'db>( context: &InferContext<'db, '_>, dunder_set_failure: &CallError<'db>, @@ -4241,6 +4747,167 @@ pub(super) fn report_bad_dunder_delattr_call( } } +pub(super) fn report_dynamic_function_decorator_return<'db>( + context: &InferContext<'db, '_>, + decorator: &ast::Decorator, + decorated_ty: Type<'db>, + decorator_bindings: &Bindings<'db>, + decorated_function: &ast::StmtFunctionDef, + return_ty: Type<'db>, +) { + // basedpython: a modifier keyword (`private def f()`, and a `context def f()` the parser has + // already rejected) parses as a synthetic decorator that decorates nothing. it resolves to + // `Unknown` because it refers to nothing, which is not a decorator losing a type + if matches!(&decorator.expression, ast::Expr::Name(name) if name.ctx.is_invalid()) { + return; + } + + let Some(builder) = context.report_lint(&DYNAMIC_FUNCTION_DECORATOR_RETURN, decorator) else { + return; + }; + + let db = context.db(); + let env = context.program_environment(); + let returned = return_ty.display(db, env); + + let mut diagnostic = builder.into_diagnostic(format_args!("Decorator returns `{returned}`")); + + let mut secondary_annotation = context.secondary(&decorated_function.name); + + // A function literal is already known to be callable. Resolving its signature here can + // create a cycle if an annotation refers back to the decorated name, as in + // `def f(x: lambda: f): ...`. + secondary_annotation = if decorated_ty.is_function_literal() + || decorated_ty.try_upcast_to_callable(db, env).is_some() + { + secondary_annotation.message(format_args!( + "Signature of `{}` will be obscured by the decorator", + decorated_function.name.id + )) + } else { + secondary_annotation.message(format_args!( + "Previous type of `{}` will be obscured by the decorator", + decorated_function.name.id + )) + }; + + diagnostic.annotate(secondary_annotation); + + // Union and intersection bindings can refer to different callables, so there is no single + // decorator definition or set of overloads that can be safely highlighted. + let Some(decorator_binding) = decorator_bindings.single_element() else { + return; + }; + + let decorator_function = match decorator_binding.signature_type { + Type::FunctionLiteral(function) => function, + Type::BoundMethod(method) => method.function(db), + _ => return, + }; + + let decorator_definition = decorator_function.definition(db); + let (overloads, _) = decorator_function.overloads_and_implementation(db); + + let mut matching_overloads = + decorator_binding + .matching_overloads() + .filter_map(|(overload_index, binding)| { + let overload_index = binding + .signature + .source_overload_index() + .unwrap_or(overload_index); + overloads.get(overload_index).copied() + }); + + let matched_overload = matching_overloads.next(); + let next_matching_overload = matching_overloads.next(); + let has_multiple_matching_overloads = next_matching_overload.is_some(); + + let definition_span = match (overloads, has_multiple_matching_overloads) { + ([first, .., last], true) => { + let first_span = first.spans(db).decorators_and_header; + let last_span = last.spans(db).decorators_and_header; + match (first_span.range(), last_span.range()) { + (Some(first_range), Some(last_range)) => { + first_span.with_range(first_range.cover(last_range)) + } + _ => decorator_function.spans(db).signature, + } + } + _ => matched_overload + .map(|overload| overload.spans(db).signature) + .unwrap_or_else(|| decorator_function.spans(db).signature), + }; + + let definition_annotation = Annotation::secondary(definition_span); + + let missing_return_annotations = if let Some(matched_overload) = matched_overload { + !matched_overload.has_explicit_return_annotation(db) + || next_matching_overload + .into_iter() + .chain(matching_overloads) + .any(|overload| !overload.has_explicit_return_annotation(db)) + } else { + !decorator_function.has_explicit_return_annotation(db) + }; + + let should_add_hint = missing_return_annotations + && (decorator_definition.file(db) == context.file() + || file_to_module(db, decorator_definition.program_file(db).resolver_file(db)) + .and_then(|module| module.search_path(db)) + .is_some_and(SearchPath::is_first_party)); + + match decorator_definition.name(db) { + Some(name) => { + let decorator_description = + CallableDescription::new(db, Type::FunctionLiteral(decorator_function)); + let name = decorator_description + .as_ref() + .map(CallableDescription::name) + .unwrap_or_else(|| name.as_str()); + + if has_multiple_matching_overloads { + diagnostic.annotate( + definition_annotation + .message(format_args!("Overloads of `{name}` defined here")), + ); + } else if matched_overload.is_some() { + diagnostic + .annotate(definition_annotation.message("Matching overload defined here")); + } else { + diagnostic + .annotate(definition_annotation.message(format_args!("`{name}` defined here"))); + } + if should_add_hint { + if has_multiple_matching_overloads { + diagnostic.help(format_args!( + "Ensure all `{name}` overloads have a return annotation" + )); + } else { + diagnostic.help(format_args!("Add a return type annotation to `{name}`")); + } + } + } + None => { + diagnostic.annotate(definition_annotation.message( + if has_multiple_matching_overloads { + "Decorator overloads defined here" + } else { + "Decorator defined here" + }, + )); + + if should_add_hint { + diagnostic.help(if has_multiple_matching_overloads { + "Ensure all overloads have a return annotation" + } else { + "Add a return type annotation to the decorator" + }); + } + } + } +} + pub(super) fn report_invalid_return_type( context: &InferContext, object_range: impl Ranged, @@ -4620,6 +5287,24 @@ pub(super) fn report_possibly_missing_attribute( }; } +/// Add an autofix to `diagnostic` that replaces the given node with `NotImplementedError` +/// iff `NotImplementedError` definitely has a builtin binding from the given scope. +pub(crate) fn autofix_with_notimplementederror( + context: &InferContext, + diagnostic: &mut Diagnostic, + node: &ast::Expr, +) { + if SemanticModel::new(context.db(), context.program_file()) + .definitely_has_builtin_binding("NotImplementedError", node.into()) + { + diagnostic.help("Use `NotImplementedError` instead"); + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( + "NotImplementedError".to_string(), + node.range(), + ))); + } +} + pub(super) fn report_invalid_exception_tuple_caught<'db, 'ast>( context: &InferContext<'db, 'ast>, node: &'ast ast::ExprTuple, @@ -4648,6 +5333,7 @@ pub(super) fn report_invalid_exception_tuple_caught<'db, 'ast>( diagnostic.annotate( Annotation::secondary(span).message("Did you mean `NotImplementedError`?"), ); + autofix_with_notimplementederror(context, &mut diagnostic, sub_node); } } @@ -4667,6 +5353,7 @@ pub(super) fn report_invalid_exception_caught(context: &InferContext, node: &ast let mut diag = builder.into_diagnostic("Cannot catch `NotImplemented` in an exception handler"); diag.set_primary_annotation_message("Did you mean `NotImplementedError`?"); + autofix_with_notimplementederror(context, &mut diag, node); diag } else { let mut diag = builder.into_diagnostic(format_args!( @@ -4703,6 +5390,7 @@ pub(crate) fn report_invalid_exception_raised( let mut diagnostic = builder.into_diagnostic(format_args!("Cannot raise `NotImplemented`")); diagnostic.set_primary_annotation_message("Did you mean `NotImplementedError`?"); diagnostic.info("Can only raise an instance or subclass of `BaseException`"); + autofix_with_notimplementederror(context, &mut diagnostic, raised_node); } else { let mut diagnostic = builder.into_diagnostic(format_args!( "Cannot raise object of type `{}`", @@ -4723,6 +5411,7 @@ pub(crate) fn report_invalid_exception_cause(context: &InferContext, node: &ast: "Cannot use `NotImplemented` as an exception cause", )); diag.set_primary_annotation_message("Did you mean `NotImplementedError`?"); + autofix_with_notimplementederror(context, &mut diag, node); diag } else { builder.into_diagnostic(format_args!( @@ -6781,20 +7470,23 @@ pub(super) fn report_incompatible_base_method<'db>( let (selected_owner, selected_definition, selected_decorator) = selected; let (contract_owner, contract_definition, contract_decorator) = contract; - let (selected_name, contract_name) = if selected_owner.name(db) == contract_owner.name(db) { - ( - selected_owner.qualified_name(db).to_string(), - contract_owner.qualified_name(db).to_string(), - ) - } else { - ( - selected_owner.name(db).to_string(), - contract_owner.name(db).to_string(), - ) - }; + let types = [ + Type::from(class), + Type::from(selected_owner), + Type::from(contract_owner), + ]; + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, context.program_environment(), types); + let env = context.program_environment(); + let class_name = ClassLiteral::Static(class).display_with(db, env, settings.clone()); + let selected_name = selected_owner + .class_literal(db) + .display_with(db, env, settings.clone()); + let contract_name = contract_owner + .class_literal(db) + .display_with(db, env, settings); let mut diagnostic = builder.into_diagnostic(format_args!( - "Base classes for class `{}` define method `{member}` incompatibly", - class.name(db) + "Base classes for class `{class_name}` define method `{member}` incompatibly", )); diagnostic.set_primary_annotation_message(format_args!( "`{selected_name}.{member}` is incompatible with `{contract_name}.{member}`" diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 9e81efed72..6e74b900e9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -36,10 +36,10 @@ use crate::types::visitor::TypeVisitor; use crate::types::{ CallableType, DeferredOperation, DeferredType, DynamicType, IntersectionType, KnownBoundMethodType, KnownClass, KnownInstanceType, KnownUnion, LiteralValueType, - LiteralValueTypeKind, MaterializationKind, ParamSpecAttrKind, PropertyInstanceType, Protocol, - SpecialFormType, StringLiteralType, SubclassOfInner, SubclassOfType, Type, TypeAliasType, - TypeGuardLike, TypedDictModule, TypedDictType, UnionType, WrapperDescriptorKind, - template::TemplatePart, visitor, + LiteralValueTypeKind, MaterializationKind, ParamSpecAttrKind, PropertyInstanceClass, + PropertyInstanceType, Protocol, SpecialFormType, StringLiteralType, SubclassOfInner, + SubclassOfType, Type, TypeAliasType, TypeGuardLike, TypedDictType, TypingModule, UnionType, + WrapperDescriptorKind, template::TemplatePart, visitor, }; use ty_python_core::ProgramFile; use ty_python_core::definition::Definition; @@ -154,18 +154,18 @@ pub struct DisplaySettings<'db> { /// basedpython: whether the caller has already written the `def ` this signature /// belongs to, as the bound-method display does. Such a signature is a *declaration*, so it /// leaves out a `None` return the way the source may. - pub name_already_written: bool, + name_already_written: bool, /// basedpython: whether a specialization names the type parameter each of /// its arguments fills (`A[Key=str, Value=int]`), the way a keyword /// subscript writes it. Only ever set for `.by` output — python's subscript /// grammar has no keyword form. - pub name_type_arguments: bool, + name_type_arguments: bool, /// basedpython: whether a symbolic arithmetic operation is shown as the type it /// reduces to (`int`) rather than as the expression it stands for (`I + 1`). Only /// ever set by the transpiler: an expression reads better everywhere a human sees /// it, but emitting one as python would evaluate `_I + 1` on a `TypeVar` object at /// import time. - pub reduce_symbolic_operations: bool, + reduce_symbolic_operations: bool, } impl<'db> DisplaySettings<'db> { @@ -189,7 +189,7 @@ impl<'db> DisplaySettings<'db> { } #[must_use] - pub fn multiline(&self) -> Self { + fn multiline(&self) -> Self { Self { multiline: true, ..self.clone() @@ -229,7 +229,7 @@ impl<'db> DisplaySettings<'db> { } #[must_use] - pub(crate) fn preserve_long_unions(self) -> Self { + fn preserve_long_unions(self) -> Self { Self { preserve_full_unions: true, ..self @@ -265,7 +265,7 @@ impl<'db> DisplaySettings<'db> { } #[must_use] - pub(crate) fn hide_return_type(&self) -> Self { + fn hide_return_type(&self) -> Self { Self { hide_return_type: true, ..self.clone() @@ -781,7 +781,7 @@ impl<'db> Type<'db> { /// An expression that is not one of those — a list display, a call, a name — is left behind. /// basedpython re-evaluates such a default on every call, so what it stands for is the /// expression rather than any one value, and there is nothing to carry. - pub fn display_default_value( + pub(crate) fn display_default_value( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -916,7 +916,7 @@ thread_local! { static BASEDPYTHON_DISPLAY: std::cell::Cell = const { std::cell::Cell::new(false) }; } -pub(crate) fn basedpython_display_enabled() -> bool { +fn basedpython_display_enabled() -> bool { BASEDPYTHON_DISPLAY.with(std::cell::Cell::get) } @@ -1144,7 +1144,7 @@ fn importable_module_of<'db>( /// `Iterator`, …) are defined; `collections.abc` is nothing but `from _collections_abc import *`, /// and `typing` re-exports the same classes again. Naming the private module in a diagnostic /// would point at a spelling nobody writes. -pub(super) fn public_module_name<'db>(db: &'db dyn Db, module: &Module<'db>) -> &'db str { +fn public_module_name<'db>(db: &'db dyn Db, module: &Module<'db>) -> &'db str { if module.known(db) == Some(KnownModule::CollectionsAbcInternal) { KnownModule::CollectionsAbc.as_str() } else { @@ -1201,7 +1201,7 @@ pub(super) fn qualified_name_components_from_scope( } impl<'db> ClassLiteral<'db> { - fn display_with<'env>( + pub(crate) fn display_with<'env>( self, db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, @@ -1216,7 +1216,7 @@ impl<'db> ClassLiteral<'db> { } } -struct ClassDisplay<'env, 'db> { +pub(crate) struct ClassDisplay<'env, 'db> { db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, class: ClassLiteral<'db>, @@ -1543,11 +1543,11 @@ struct DisplayRepresentation<'env, 'db> { settings: DisplaySettings<'db>, } -fn property_display_name(db: &dyn Db, property: PropertyInstanceType<'_>) -> &'static str { - if property.instance_class(db) == KnownClass::EnumProperty { - "enum.property" - } else { - "property" +fn property_display_name<'db>(db: &'db dyn Db, property: PropertyInstanceType<'db>) -> &'db str { + match property.instance_class(db) { + PropertyInstanceClass::Builtin => "property", + PropertyInstanceClass::Enum => "enum.property", + PropertyInstanceClass::Subclass(class) => class.name(db), } } @@ -1672,9 +1672,19 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { f.write_char('>') } }, + Type::PropertyInstance(property) + if let PropertyInstanceClass::Subclass(class) = property.instance_class(db) => + { + Type::instance(db, self.env, class) + .display_with(db, self.env, self.settings.clone()) + .fmt_detailed(f) + } Type::PropertyInstance(property) => f .with_type(self.ty) .write_str(property_display_name(db, property)), + Type::SlotDescriptor(_) => f + .with_type(self.ty) + .write_str(KnownClass::MemberDescriptorType.name(self.env.python_version(db))), Type::ModuleLiteral(module) => { f.set_invalid_type_annotation(); f.write_char('<')?; @@ -1768,6 +1778,7 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { Type::BoundMethod(bound_method) => { let function = bound_method.function(self.db); let self_ty = bound_method.self_instance(self.db); + let receiver_ty = bound_method.signature_receiver(self.db); let write_prefix = |f: &mut TypeWriter<'_, '_, 'db>| { f.set_invalid_type_annotation(); @@ -1779,6 +1790,16 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { settings: self.settings.singleline(), } .fmt_detailed(f)?; + if self_ty != receiver_ty { + f.write_str(" when ")?; + DisplayMaybeParenthesizedType { + ty: receiver_ty, + db: self.db, + env: self.env, + settings: self.settings.singleline(), + } + .fmt_detailed(f)?; + } f.write_char('.')?; f.with_type(self.ty).write_str(function.name(self.db)) }; @@ -1836,23 +1857,23 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { } Type::KnownBoundMethod(method_type) => { f.set_invalid_type_annotation(); - let (cls, member_name, cls_name, ty, ty_name) = match method_type { + let (class_ty, member_name, cls_name, ty, ty_name) = match method_type { KnownBoundMethodType::FunctionTypeDunderGet(function) => ( - KnownClass::FunctionType, + KnownClass::FunctionType.to_class_literal(db, self.env), "__get__", "function", Type::FunctionLiteral(function), Some(&**function.name(db)), ), KnownBoundMethodType::FunctionTypeDunderCall(function) => ( - KnownClass::FunctionType, + KnownClass::FunctionType.to_class_literal(db, self.env), "__call__", "function", Type::FunctionLiteral(function), Some(&**function.name(db)), ), KnownBoundMethodType::PropertyDunderGet(property) => ( - property.instance_class(db), + property.instance_class(db).to_class_literal(db, self.env), "__get__", property_display_name(db, property), Type::PropertyInstance(property), @@ -1862,7 +1883,7 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { .map(|getter| &**getter.name(db)), ), KnownBoundMethodType::PropertyDunderSet(property) => ( - property.instance_class(db), + property.instance_class(db).to_class_literal(db, self.env), "__set__", property_display_name(db, property), Type::PropertyInstance(property), @@ -1872,7 +1893,7 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { .map(|setter| &**setter.name(db)), ), KnownBoundMethodType::PropertyDunderDelete(property) => ( - property.instance_class(db), + property.instance_class(db).to_class_literal(db, self.env), "__delete__", property_display_name(db, property), Type::PropertyInstance(property), @@ -1882,7 +1903,7 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { .map(|deleter| &**deleter.name(db)), ), KnownBoundMethodType::StrStartswith(literal) => ( - KnownClass::Property, + KnownClass::Property.to_class_literal(db, self.env), "startswith", "string", Type::LiteralValue(LiteralValueType::promotable( @@ -1920,10 +1941,6 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { KnownBoundMethodType::ConstraintSetForAll(_) => { return f.write_str("bound method `ConstraintSet.for_all`"); } - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) => { - return f - .write_str("bound method `ConstraintSet.satisfied_by_all_typevars`"); - } KnownBoundMethodType::ConstraintSetSolutionsFor(_) => { return f.write_str("bound method `ConstraintSet.solutions_for`"); } @@ -1935,7 +1952,6 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { } }; - let class_ty = cls.to_class_literal(db, self.env); f.write_char('<')?; f.with_type(KnownClass::MethodWrapperType.to_class_literal(db, self.env)) .write_str("method-wrapper")?; @@ -2230,14 +2246,14 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { } Type::TypedDict(typed_dict) if typed_dict.is_top(self.db) => f .with_type(Type::SpecialForm(SpecialFormType::TypedDict( - TypedDictModule::Typing, + TypingModule::Typing, ))) .write_str("TypedDict"), Type::TypedDict(TypedDictType::Synthesized(synthesized)) => { f.set_invalid_type_annotation(); f.write_char('<')?; f.with_type(Type::SpecialForm(SpecialFormType::TypedDict( - TypedDictModule::Typing, + TypingModule::Typing, ))) .write_str("TypedDict")?; f.write_str(" with items ")?; @@ -2283,55 +2299,37 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'_, 'db> { impl<'db> BoundTypeVarIdentity<'db> { pub(crate) fn display(self, db: &'db dyn Db) -> impl Display { - DisplayBoundTypeVarIdentity { - bound_typevar_identity: self, - db, - settings: DisplaySettings::default(), - } + self.display_with(db, DisplaySettings::default()) } fn display_with(self, db: &'db dyn Db, settings: DisplaySettings<'db>) -> impl Display { - DisplayBoundTypeVarIdentity { - bound_typevar_identity: self, - db, - settings, - } - } -} - -struct DisplayBoundTypeVarIdentity<'db> { - bound_typevar_identity: BoundTypeVarIdentity<'db>, - db: &'db dyn Db, - settings: DisplaySettings<'db>, -} - -impl Display for DisplayBoundTypeVarIdentity<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let paramspec_attr = self.bound_typevar_identity.paramspec_attr; - // basedpython unpacks a parameter pack's two halves with stars — `*P` and `**P` — - // rather than naming them as attributes of the type variable - if basedpython_display_enabled() - && let Some(attr) = paramspec_attr - { - f.write_str(match attr { - ParamSpecAttrKind::Args => "*", - ParamSpecAttrKind::Kwargs => "**", - })?; - } - f.write_str(self.bound_typevar_identity.identity.name(self.db))?; - let binding_context = self.bound_typevar_identity.binding_context; - if let Some(binding_context_name) = binding_context.name(self.db) - && let Some(definition) = binding_context.definition() - && !self.settings.active_scopes.contains(&definition) - { - write!(f, "@{binding_context_name}")?; - } - if !basedpython_display_enabled() - && let Some(attr) = paramspec_attr - { - write!(f, ".{attr}")?; - } - Ok(()) + std::fmt::from_fn(move |f| { + let paramspec_attr = self.paramspec_attr; + // basedpython unpacks a parameter pack's two halves with stars — `*P` and `**P` — + // rather than naming them as attributes of the type variable + if basedpython_display_enabled() + && let Some(attr) = paramspec_attr + { + f.write_str(match attr { + ParamSpecAttrKind::Args => "*", + ParamSpecAttrKind::Kwargs => "**", + })?; + } + f.write_str(self.identity.name(db))?; + let binding_context = self.binding_context; + if let Some(binding_context_name) = binding_context.name(db) + && let Some(definition) = binding_context.definition() + && !settings.active_scopes.contains(&definition) + { + write!(f, "@{binding_context_name}")?; + } + if !basedpython_display_enabled() + && let Some(attr) = paramspec_attr + { + write!(f, ".{attr}")?; + } + Ok(()) + }) } } @@ -3214,14 +3212,6 @@ impl TupleSpecialization { } impl<'db> CallableType<'db> { - fn display<'a>( - &'a self, - db: &'db dyn Db, - env: &'a ProgramEnvironment<'db>, - ) -> DisplayCallableType<'a, 'db> { - Self::display_with(self, db, env, DisplaySettings::default()) - } - fn display_with<'a>( &'a self, db: &'db dyn Db, @@ -3595,6 +3585,7 @@ impl<'db> FmtDetailed<'db> for DisplayParameters<'_, 'db> { .fmt_detailed(&mut f.with_detail(TypeDetail::Parameter(param_name)))?; after_synthetic_unpack |= is_synthetic_unpack; + star_added |= parameter.is_variadic(); first = false; } @@ -3702,6 +3693,16 @@ impl<'db> Parameter<'db> { } } +/// Whether a type writes its own leading star, as a parameter pack's half does in basedpython. +/// +/// `*args: *P` is the pack's positional half, and the star belongs to the *type*: the annotation +/// renders it. A parameter that also wrote one for its starred annotation would spell `**P` there, +/// which is the other half. +fn writes_own_pack_star<'db>(db: &'db dyn Db, ty: Type<'db>) -> bool { + basedpython_display_enabled() + && matches!(ty, Type::TypeVar(typevar) if typevar.paramspec_attr(db).is_some()) +} + struct DisplayParameter<'a, 'db> { param: &'a Parameter<'db>, db: &'db dyn Db, @@ -3717,7 +3718,9 @@ impl<'db> FmtDetailed<'db> for DisplayParameter<'_, 'db> { && self.param.is_variadic() && self.param.has_starred_annotation() { - f.write_str("*")?; + if !writes_own_pack_star(db, self.param.annotated_type()) { + f.write_str("*")?; + } self.param .annotated_type() .display_with(db, self.env, self.settings.clone()) @@ -3742,6 +3745,12 @@ impl<'db> FmtDetailed<'db> for DisplayParameter<'_, 'db> { Some(SomeHoleBound::Unbounded) => {} None => { f.write_str(": ")?; + if self.param.is_variadic() + && self.param.has_starred_annotation() + && !writes_own_pack_star(self.db, annotated_type) + { + f.write_char('*')?; + } annotated_type .display_with(self.db, env, self.settings.clone()) .fmt_detailed(f)?; @@ -3749,7 +3758,7 @@ impl<'db> FmtDetailed<'db> for DisplayParameter<'_, 'db> { } } // Default value can only be specified if `name` is given. - if let Some(default_type) = self.param.default_type() { + if let Some(default_type) = self.param.default_type(db) { if self.param.should_annotation_be_displayed() { f.write_str(" = ")?; } else { @@ -4543,29 +4552,19 @@ impl Display for DisplayTypeArray<'_, '_> { } impl<'db> StringLiteralType<'db> { - fn display(self, db: &'db dyn Db) -> DisplayStringLiteralType<'db> { - DisplayStringLiteralType { - string: self.value(db), - } - } -} - -struct DisplayStringLiteralType<'db> { - string: &'db str, -} - -impl Display for DisplayStringLiteralType<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.write_char('"')?; - for ch in self.string.chars() { - match ch { - // `escape_debug` will escape even single quotes, which is not necessary for our - // use case as we are already using double quotes to wrap the string. - '\'' => f.write_char('\''), - _ => ch.escape_debug().fmt(f), - }?; - } - f.write_char('"') + fn display(self, db: &'db dyn Db) -> impl std::fmt::Display { + std::fmt::from_fn(move |f| { + f.write_char('"')?; + for ch in self.value(db).chars() { + match ch { + // `escape_debug` will escape even single quotes, which is not necessary for our + // use case as we are already using double quotes to wrap the string. + '\'' => f.write_char('\''), + _ => ch.escape_debug().fmt(f), + }?; + } + f.write_char('"') + }) } } @@ -4801,7 +4800,9 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'_, 'db> { f.with_type(Type::SpecialForm(SpecialFormType::TypingCallable)) .write_str("Callable")?; f.write_str(" special-form '")?; - callable.display(db, self.env).fmt_detailed(f)?; + callable + .display_with(db, self.env, self.settings.clone()) + .fmt_detailed(f)?; f.write_str("'>") } KnownInstanceType::TypeGenericAlias(inner) => { @@ -5107,7 +5108,7 @@ mod tests { ], Some(KnownClass::Bytes.to_instance(db, &env)) ), - @"(a, b: int, c=1, d: int = 2, /, e=3, f: int = 4, *args: object, *, g=5, h: int = 6, **kwargs: str) -> bytes" + @"(a, b: int, c=1, d: int = 2, /, e=3, f: int = 4, *args: object, g=5, h: int = 6, **kwargs: str) -> bytes" ); } @@ -5273,7 +5274,6 @@ mod tests { e=3, f: int = 4, *args: object, - *, g=5, h: int = 6, **kwargs: str diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index 276e3d7084..bf273f4f35 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -367,9 +367,8 @@ pub struct EnumClassLiteral<'db> { pub(super) aliases_are_known: bool, /// Whether the canonical members exhaust the runtime values of this enum class. /// - /// `Flag` classes, transforming metaclasses, and enums with a custom `_missing_` method can - /// create runtime members beyond those declared in the class body, so their declared members - /// are not a closed value set. + /// `Flag` classes and transforming metaclasses can create runtime members beyond those + /// declared in the class body, so their declared members are not a closed value set. #[returns(copy)] pub(crate) members_are_exhaustive: bool, } @@ -411,7 +410,6 @@ fn enum_class_literal<'db>( &env, KnownClass::Flag.to_subclass_of(db, &env), ) - && !enum_has_custom_missing(db, class) && !class.as_static().is_some_and(|static_class| { crate::types::class::based_enum_has_payload_variants(db, static_class) }); @@ -426,20 +424,6 @@ fn enum_class_literal<'db>( )) } -/// Return whether enum construction may create pseudo-members through a custom `_missing_` method. -fn enum_has_custom_missing<'db>(db: &'db dyn Db, class: ClassLiteral<'db>) -> bool { - let ClassLiteral::Static(class) = class else { - return false; - }; - - class - .iter_mro(db, None) - .filter_map(ClassBase::into_class) - .take_while(|base| base.known(db) != Some(KnownClass::Enum)) - .filter_map(|base| base.class_literal(db).as_static()) - .any(|base| custom_enum_method(db, base.body_scope(db), "_missing_").is_some()) -} - impl<'db> EnumClassLiteral<'db> { pub(crate) fn member_count(self, db: &'db dyn Db) -> usize { self.members(db).len() @@ -1662,9 +1646,27 @@ fn inherited_user_defined_mixin_new<'db>( .iter_mro(db, None) .skip(1) .filter_map(ClassBase::into_class) - .filter_map(|class| class.class_literal(db).as_static()) - .filter(|base| base.known(db).is_none()) - .find_map(|base| custom_enum_method(db, base.body_scope(db), "__new__")) + .find_map(|class_type| { + let (base, specialization) = class_type.static_class_literal(db)?; + if base.known(db).is_some() { + return None; + } + let binding = custom_enum_method(db, base.body_scope(db), "__new__")?; + // The mixin may be inherited as a specialized generic alias (`Mixin[str]`). Apply that + // specialization, so that members are checked against the specialized `__new__` + // signature instead of one with free typevars. + let EnumMethodBinding::Function(function) = binding else { + return Some(EnumMethodBinding::Opaque); + }; + Some( + match Type::FunctionLiteral(function) + .apply_optional_owner_specialization_to_member(db, specialization) + { + Type::FunctionLiteral(function) => EnumMethodBinding::Function(function), + _ => EnumMethodBinding::Opaque, + }, + ) + }) } /// Looks up a resolvable method inherited from a known enum class. diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index 18972e7013..799b9ed907 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -1170,7 +1170,13 @@ fn evaluate_target_union<'db>( let Some(mut narrowed) = narrowed else { continue; }; - if let Some(removed) = removed { + // A surviving alternative that is disjoint from every rejected alternative already + // satisfies their exclusions. Constructing those redundant exclusions can exponentially + // expand intersections such as `Any & Literal["a"]` when the rejected alternatives are + // similarly shaped intersections with other string literals. + if let Some(removed) = removed + && !narrowed.is_disjoint_from(db, env, removed) + { narrowed = IntersectionBuilder::new(db, env) .add_positive(narrowed) .add_negative(removed) @@ -1516,7 +1522,7 @@ fn compare_literal_to_other<'db>( if matches!(literal, LiteralValueTypeKind::LiteralString) { return match evaluator.comparison_semantics(other, operator) { Some(KnownComparisonSemantics::Str) => ComparisonResult::Ambiguous, - Some(_) => ComparisonResult::from_bool(operator == ComparisonOperator::Inequality), + Some(_) => compare_different_semantics(db, &env, literal_type, other, operator), None => ComparisonResult::Ambiguous, }; } @@ -1527,17 +1533,16 @@ fn compare_literal_to_other<'db>( }; let condition_expects_equality = operator.condition_expects_equality(branch); - // Treat broad builtin types as if they exclude subclasses with custom equality. This is - // intentionally unsafe: an instance of such a subclass can compare equal to the literal - // without inhabiting its literal type. Explicitly typed subclasses do not take this path. + // Treat broad builtin types as if only the literal itself can compare equal. This is + // intentionally unsafe: subclasses, including `bool` for `int`, can compare equal without + // inhabiting the literal type. Explicitly typed subclasses do not take this path. if evaluator.soundness_policy.allow_unsafe_equality && condition_expects_equality && literal_operand == LiteralOperand::Other - && let Some(equal_to_literal) = builtin_literals_equal_to(db, &env, literal_type, literal) && let Some(other_semantics) = unsafe_narrowable_builtin_semantics(db, other) { return if literal_semantics == other_semantics { - ComparisonResult::CanNarrow(equal_to_literal) + ComparisonResult::CanNarrow(literal_type) } else { operator.result_from_equality(false) }; @@ -1545,7 +1550,7 @@ fn compare_literal_to_other<'db>( match evaluator.comparison_semantics(other, operator) { Some(other_semantics) if literal_semantics != other_semantics => { - ComparisonResult::from_bool(operator == ComparisonOperator::Inequality) + compare_different_semantics(db, &env, literal_type, other, operator) } // Object equality compares identity. `NewType` operands are evaluated using their concrete // base before reaching this arm, so erased identities cannot make these types appear @@ -1576,6 +1581,49 @@ fn compare_literal_to_other<'db>( } } +/// Compare types that inherit different builtin comparison implementations. +/// +/// A base-class annotation can contain instances of a known subclass with a different +/// implementation. For example, `Sequence[object]` can contain tuples, so its inherited +/// `object.__eq__` cannot rule out equality with `tuple[()]`. Only consider known inheritance +/// here: hypothetical multiple-inheritance subclasses should not prevent the default equality +/// semantics from narrowing unrelated classes. +fn compare_different_semantics<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + left: Type<'db>, + right: Type<'db>, + operator: ComparisonOperator, +) -> ComparisonResult<'db> { + // NoneType is final and inherits only from object. This common case does not need + // ancestry checks, which would otherwise be repeated for each member of an optional enum. + if left.is_none(db) || right.is_none(db) { + return operator.result_from_equality(false); + } + + match (left, right) { + (Type::Intersection(intersection), other) | (other, Type::Intersection(intersection)) => { + // An intersection can only compare equal if all of its positive elements can. + intersection + .positive(db) + .iter() + .map(|&element| compare_different_semantics(db, env, element, other, operator)) + .find(|result| *result != ComparisonResult::Ambiguous) + .unwrap_or(ComparisonResult::Ambiguous) + } + (left, right) + if let (Some(left_class), Some(right_class)) = + (left.nominal_class(db, env), right.nominal_class(db, env)) + && (left_class.is_subtype_of_class_literal(db, right_class.class_literal(db)) + || right_class + .is_subtype_of_class_literal(db, left_class.class_literal(db))) => + { + ComparisonResult::Ambiguous + } + _ => operator.result_from_equality(false), + } +} + /// Compare nominal instances when their inherited comparison implementations are known. /// /// The result is definite only when the implementations cannot compare equal, or when both types @@ -1597,10 +1645,11 @@ fn compare_nominal_instances<'db>( return ComparisonResult::Ambiguous; }; - if left_semantics != right_semantics - || (left_semantics == KnownComparisonSemantics::Object - && left.is_disjoint_from(db, env, right)) - { + if left_semantics != right_semantics { + return compare_different_semantics(db, env, left, right, operator); + } + + if left_semantics == KnownComparisonSemantics::Object && left.is_disjoint_from(db, env, right) { return ComparisonResult::from_bool(operator == ComparisonOperator::Inequality); } @@ -1701,8 +1750,10 @@ impl ComparisonOperator { /// A known builtin implementation that determines the runtime behavior of a comparison. /// -/// Two types with different known semantics cannot compare equal. Types with custom or otherwise -/// unknown comparison methods are not assigned a value of this enum. +/// Runtime values with different known semantics cannot compare equal. The implementation inferred +/// for a static type may differ from that of a known subclass; see +/// [`compare_different_semantics`]. Types with custom or otherwise unknown comparison methods are not +/// assigned a value of this enum. #[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] enum KnownComparisonSemantics { Object, @@ -1857,14 +1908,16 @@ impl KnownComparisonSemantics { (KnownClass::Tuple, Self::Tuple), (KnownClass::Dict, Self::Dict), ] { - if dunder - == lookup_dunder( + if same_member_implementation( + db, + dunder, + lookup_dunder( db, env, known_class.to_class_literal(db, env), operator.dunder(), - ) - { + ), + ) { return Some(semantics); } } @@ -1907,6 +1960,28 @@ fn has_known_identity_comparison_semantics<'db>( } } +/// Return whether two looked-up members originate from the same implementation. +fn same_member_implementation( + db: &dyn Db, + left: PlaceAndQualifiers<'_>, + right: PlaceAndQualifiers<'_>, +) -> bool { + if left.qualifiers != right.qualifiers { + return false; + } + + match ( + left.ignore_possibly_undefined() + .and_then(Type::as_function_literal), + right + .ignore_possibly_undefined() + .and_then(Type::as_function_literal), + ) { + (Some(left), Some(right)) => left.literal(db) == right.literal(db), + _ => left == right, + } +} + /// Look up a comparison method without falling back to `object`. fn lookup_dunder<'db>( db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/exceptions.rs b/crates/ty_python_semantic/src/types/exceptions.rs index 87374b4d22..bb28ce827f 100644 --- a/crates/ty_python_semantic/src/types/exceptions.rs +++ b/crates/ty_python_semantic/src/types/exceptions.rs @@ -100,7 +100,7 @@ impl ExceptionEffects<'_> { /// The exceptions a call to `overload` can raise: its declared `raises` clause /// when it has one, and otherwise the set inferred from its body. -pub(crate) fn raised_exceptions<'db>(db: &'db dyn Db, overload: OverloadLiteral<'db>) -> Type<'db> { +fn raised_exceptions<'db>(db: &'db dyn Db, overload: OverloadLiteral<'db>) -> Type<'db> { declared_exceptions(db, overload).unwrap_or_else(|| inferred_exceptions(db, overload)) } @@ -143,7 +143,7 @@ pub(crate) fn declared_exceptions<'db>( if !file.source_type(db).is_basedpython() { return None; } - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, overload.python_file(db)).load(db); let raises = overload.node(db, file, &module).raises.as_deref()?; if raises.is_ellipsis_literal_expr() { @@ -191,7 +191,7 @@ pub(crate) fn body_exception_effects<'db>( if !file.source_type(db).is_basedpython() { return ExceptionEffects::default(); } - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, overload.python_file(db)).load(db); let node = overload.node(db, file, &module); let inference = infer_scope_types(db, overload.body_scope(db), TypeContext::default()); @@ -199,7 +199,7 @@ pub(crate) fn body_exception_effects<'db>( } /// Union the exceptions escaping `effects`, following each call into its callee. -pub(crate) fn resolve_effects<'db>( +fn resolve_effects<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, effects: &ExceptionEffects<'db>, @@ -221,7 +221,7 @@ pub(crate) fn resolve_effects<'db>( /// is known: a directly recursive call contributes exactly the set being /// computed, so it is the identity of the union and can be dropped rather than /// re-entered. -pub(crate) fn escaping_sites<'db>( +fn escaping_sites<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, effects: &ExceptionEffects<'db>, @@ -261,7 +261,7 @@ pub(crate) fn escaping_sites<'db>( /// A union is filtered element-wise, so `except TypeError` around code raising /// `TypeError | ValueError` leaves `ValueError` behind rather than nothing or /// everything. -pub(crate) fn escaping<'db>( +fn escaping<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, raised: Type<'db>, @@ -289,7 +289,7 @@ pub(crate) fn escaping<'db>( } /// The members of `ty` when it is a union, and `ty` itself otherwise. -pub(crate) fn union_elements<'db>(db: &'db dyn Db, ty: Type<'db>) -> Vec> { +fn union_elements<'db>(db: &'db dyn Db, ty: Type<'db>) -> Vec> { match ty { Type::Union(union) => union.elements(db).to_vec(), _ => vec![ty], @@ -301,7 +301,7 @@ pub(crate) fn union_elements<'db>(db: &'db dyn Db, ty: Type<'db>) -> Vec( +fn collect_exception_effects<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, body: &[Stmt], diff --git a/crates/ty_python_semantic/src/types/extensions.rs b/crates/ty_python_semantic/src/types/extensions.rs index f77ea02eb3..532965efc1 100644 --- a/crates/ty_python_semantic/src/types/extensions.rs +++ b/crates/ty_python_semantic/src/types/extensions.rs @@ -45,7 +45,7 @@ use crate::types::{MemberLookupPolicy, Type}; use ty_module_resolver::ImportingFile; /// the symbol-name prefix the semantic index gives extension declarations -pub(crate) const EXTENSION_SYMBOL_PREFIX: &str = " Option { +fn prelude_file(db: &dyn Db, from_file: File) -> Option { let name = ModuleName::new_static(PRELUDE_MODULE)?; resolve_module( db, @@ -505,7 +505,7 @@ fn is_conformance<'db>(db: &'db dyn Db, extension: StaticClassLiteral<'db>) -> b /// the attribute fallback. The precedence is the same one every extension /// member follows: a declared dunder wins, and an extension only answers what /// nothing else does. -pub(crate) fn extension_operator<'db>( +fn extension_operator<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, file: File, @@ -700,7 +700,7 @@ pub(crate) fn extension_applies<'db>( /// [`extension_applies`] once the extended class has been located, with the /// specialization the receiver gives it -pub(crate) fn applied_at<'db>( +fn applied_at<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, extension: StaticClassLiteral<'db>, diff --git a/crates/ty_python_semantic/src/types/format.rs b/crates/ty_python_semantic/src/types/format.rs index aa06b49040..c48bd4ba5e 100644 --- a/crates/ty_python_semantic/src/types/format.rs +++ b/crates/ty_python_semantic/src/types/format.rs @@ -139,18 +139,18 @@ fn owner_of<'db>( } /// the format spec written in a replacement field -pub(crate) struct WrittenSpec<'ast> { +struct WrittenSpec<'ast> { /// the spec text, when every part of it is literal. a spec containing a /// nested replacement field (`f"{v:{width}}"`) is only known at runtime - pub(crate) literal: Option<&'ast str>, + literal: Option<&'ast str>, /// the range the spec occupies, for reporting - pub(crate) range: TextRange, + range: TextRange, } impl<'ast> WrittenSpec<'ast> { /// read the spec off a replacement field. a field with no spec at all has /// the empty spec, which is the one every type accepts - pub(crate) fn of(element: &'ast ast::InterpolatedElement) -> Self { + fn of(element: &'ast ast::InterpolatedElement) -> Self { let Some(spec) = &element.format_spec else { // an empty range just past the expression, so a report about the // absent spec still points somewhere sensible @@ -528,7 +528,7 @@ fn malformed_detail(error: &FormatSpecError) -> Option { /// `__repr__` whether or not the runtime class has them — `int` declares /// neither and still prints as a number — so a class from a stub anywhere in /// the MRO makes the answer unknowable and nothing is reported -pub(crate) fn check_implicit_object_repr<'db>( +fn check_implicit_object_repr<'db>( context: &InferContext<'db, '_>, at: TextRange, value_ty: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index b03319333a..8ba0f0c80d 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -61,27 +61,28 @@ use ruff_db::source::source_text; use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::find_node::covering_node; use ruff_python_ast::helpers::{last_bound_parameter, parameter_modifiers}; -use ruff_python_ast::{self as ast, OperatorPrecedence, ParameterWithDefault}; +use ruff_python_ast::{self as ast, ParameterWithDefault}; +use ruff_python_edits::unwrapped_call_argument; use ruff_text_size::Ranged; use salsa::plumbing::AsId; use ty_module_resolver::{ImportingFile, KnownModule, ModuleName, file_to_module, resolve_module}; use crate::place::{DefinedPlace, Definedness, Place, declared_type_at_load, place_from_bindings}; use crate::types::call::{Binding, CallArguments}; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; +use crate::types::callable::CallableTypeKind; use crate::types::constraints::ConstraintSet; use crate::types::context::InferContext; use crate::types::cyclic::ActiveRecursionDetector; use crate::types::diagnostic::{ - ASSERT_TYPE_UNSPELLABLE_SUBTYPE, INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, STATIC_ASSERT_ERROR, - TYPE_ASSERTION_FAILURE, report_bad_argument_to_get_protocol_members, + ASSERT_TYPE_UNSPELLABLE_SUBTYPE, DISJOINT_CAST, INVALID_ARGUMENT_TYPE, REDUNDANT_CAST, + STATIC_ASSERT_ERROR, TYPE_ASSERTION_FAILURE, report_bad_argument_to_get_protocol_members, report_bad_argument_to_protocol_interface, report_invalid_total_ordering_call, report_issubclass_check_against_protocol_with_non_method_members, report_runtime_check_against_non_runtime_checkable_protocol, report_runtime_check_against_typed_dict, }; use crate::types::display::DisplaySettings; -use crate::types::generics::{ApplySpecialization, GenericContext, typing_self}; +use crate::types::generics::{GenericContext, typing_self}; use crate::types::infer::{ function_known_decorators, infer_definition_types, nearest_enclosing_class, original_class_type, }; @@ -95,7 +96,7 @@ use crate::types::signatures::{ CallableSignature, NarrowingGuard, NarrowingGuardKind, ReturnCallableTypeVarScope, Signature, }; use crate::types::tuple::TupleSpec; -use crate::types::variance::{TypeVarVariance, VarianceInferable}; +use crate::types::variance::{VarianceInferable, VarianceOrigin, VarianceTerm}; use crate::types::visitor::{any_over_type, non_any_dynamic_content}; use crate::types::{ ApplyTypeMappingVisitor, BoundMethodType, BoundTypeVarIdentity, BoundTypeVarInstance, @@ -105,9 +106,9 @@ use crate::types::{ TypeVarBoundOrConstraints, UnionBuilder, UnionType, binding_type, definition_expression_type, walk_signature, }; -use crate::{Db, FxOrderSet, ProgramEnvironment}; +use crate::{Db, FxIndexMap, FxOrderSet, ProgramEnvironment}; use ty_python_core::ast_ids::HasScopedUseId; -use ty_python_core::definition::Definition; +use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::scope::ScopeId; use ty_python_core::{FileScopeId, ProgramFile, SemanticIndex, semantic_index}; @@ -434,7 +435,11 @@ pub(crate) struct CallbackParameterModifiers { #[salsa::tracked] impl<'db> OverloadLiteral<'db> { - fn with_deprecated(self, db: &'db dyn Db, deprecated: DeprecatedInstance<'db>) -> Self { + pub(super) fn with_deprecated( + self, + db: &'db dyn Db, + deprecated: DeprecatedInstance<'db>, + ) -> Self { Self::new( db, self.name(db), @@ -550,7 +555,12 @@ impl<'db> OverloadLiteral<'db> { if !source_type.is_basedpython() { return Box::default(); } - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + // the module has to come from *this* overload's python file, not from + // `db.program_file(file)`. those differ for a vendored stub reached from a + // pep 723 script: the script's program checks it at the script's version, + // while a file with no system path falls back to the project's, and the node + // below belongs to whichever one the body scope was built for + let module = parsed_module(db, self.python_file(db)).load(db); let node = self.body_scope(db).node(db).expect_function().node(&module); let source = source_text(db, file); crate::reified::reified_type_param_names(source.as_str(), source_type, node) @@ -571,8 +581,7 @@ impl<'db> OverloadLiteral<'db> { if reified.is_empty() { return Box::default(); } - let file = self.file(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, self.python_file(db)).load(db); let node = self.body_scope(db).node(db).expect_function().node(&module); let Some(type_params) = node.type_params.as_deref() else { return Box::default(); @@ -599,7 +608,7 @@ impl<'db> OverloadLiteral<'db> { db: &'db dyn Db, ) -> CallbackParameterModifiers { let file = self.file(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, self.python_file(db)).load(db); let parameters = &self.node(db, file, &module).parameters; let source = source_text(db, file); @@ -681,7 +690,7 @@ impl<'db> OverloadLiteral<'db> { /// basedpython: whether this overload is the getter of a `static let` property. /// Its implicit first parameter is the owning class, like a classmethod's, but /// the member it decorates is a descriptor rather than a bound method. - pub(crate) fn takes_implicit_class_receiver(self, db: &dyn Db) -> bool { + fn takes_implicit_class_receiver(self, db: &dyn Db) -> bool { self.is_classmethod(db) || self.has_known_decorator(db, FunctionDecorators::BY_STATIC_PROPERTY) } @@ -698,7 +707,7 @@ impl<'db> OverloadLiteral<'db> { /// [`Signature::add_implicit_self_annotation`] leaves its `cls` alone — but construction /// still binds the class there, and nothing a call site writes lands in it. A parameter no /// caller fills is not a hole for one to fill. - pub(crate) fn binds_first_parameter(self, db: &'db dyn Db) -> bool { + fn binds_first_parameter(self, db: &'db dyn Db) -> bool { self.body_scope(db).is_method_scope(db) && (!self.is_staticmethod(db) || is_implicit_staticmethod(self.name(db))) } @@ -871,7 +880,7 @@ impl<'db> OverloadLiteral<'db> { } /// Returns the effective signatures of this overload after applying decorators. - pub(crate) fn decorated_signatures( + fn decorated_signatures( self, db: &'db dyn Db, ) -> impl Iterator> + Clone + 'db { @@ -1282,7 +1291,7 @@ impl<'db> OverloadLiteral<'db> { // the parameter is often declared optional — `property`'s `fget` is // `Callable[[Any], Any] | None` — and what a decoration passes it is the callable expected => expected - .filter_union(db, Type::is_callable_type) + .filter_union(db, env, Type::is_callable_type) .as_callable()?, }; let [expected_signature] = expected.signatures(db).overloads.as_slice() else { @@ -1997,7 +2006,7 @@ pub(super) fn walk_function_type<'db, V: super::visitor::TypeVisitor<'db> + ?Siz #[salsa::tracked] impl<'db> FunctionType<'db> { - fn updated_signature(self, db: &'db dyn Db) -> Option<&'db CallableSignature<'db>> { + pub(super) fn updated_signature(self, db: &'db dyn Db) -> Option<&'db CallableSignature<'db>> { self.updated_signatures(db) .as_deref() .and_then(|updated| updated.signature.as_ref()) @@ -2061,13 +2070,11 @@ impl<'db> FunctionType<'db> { self.implementation_callables(db) .iter() .map(|callable| { - CallableType::new( + callable.with_signatures( db, callable .signatures(db) .with_inherited_generic_context(db, inherited_generic_context), - callable.kind(db), - callable.provenance(db), ) }) .collect() @@ -2119,13 +2126,9 @@ impl<'db> FunctionType<'db> { let literal = self.literal(db); let (updated_signature, updated_implementation_callables) = if matches!( type_mapping, - TypeMapping::ApplySpecialization( - ApplySpecialization::ReturnCallables(_) | ApplySpecialization::TypeAlias(_) - ) | TypeMapping::ApplySpecializationWithMaterialization { - specialization: ApplySpecialization::ReturnCallables(_) - | ApplySpecialization::TypeAlias(_), - .. - } + TypeMapping::ApplySpecialization(specialization) + | TypeMapping::ApplySpecializationWithMaterialization { specialization, .. } + if specialization.preserves_lazy_signatures() ) { ( self.updated_signature(db).map(|signature| { @@ -2291,18 +2294,26 @@ impl<'db> FunctionType<'db> { self.updated_signature(db).is_none() && self.is_reified(db) } - /// Returns true if this method is decorated with `@classmethod`, or if it is implicitly a - /// classmethod. + /// Returns true if every definition of this method uses `@classmethod`, or is implicitly a + /// classmethod. An inconsistently applied decorator does not affect method binding. pub(crate) fn is_classmethod(self, db: &'db dyn Db) -> bool { - self.iter_overloads_and_implementation(db) - .any(|overload| overload.is_classmethod(db)) + let mut overloads = self.iter_overloads_and_implementation(db); + // Overload discovery can return no definitions during cycle recovery. + overloads + .next() + .is_some_and(|overload| overload.is_classmethod(db)) + && overloads.all(|overload| overload.is_classmethod(db)) } - /// Returns true if this method is decorated with `@staticmethod`, or if it is implicitly a - /// static method. + /// Returns true if every definition of this method uses `@staticmethod`, or is implicitly a + /// static method. An inconsistently applied decorator does not affect method binding. pub(crate) fn is_staticmethod(self, db: &'db dyn Db) -> bool { - self.iter_overloads_and_implementation(db) - .any(|overload| overload.is_staticmethod(db)) + let mut overloads = self.iter_overloads_and_implementation(db); + // Overload discovery can return no definitions during cycle recovery. + overloads + .next() + .is_some_and(|overload| overload.is_staticmethod(db)) + && overloads.all(|overload| overload.is_staticmethod(db)) } /// Returns true if this function has an implicit `self` or `cls` receiver parameter. @@ -2476,20 +2487,27 @@ impl<'db> FunctionType<'db> { .unwrap_or_else(|| self.literal(db).signature(db)) } - /// Infer the variance of a type variable within this function's signature. - /// - /// This is tracked because signatures can contain recursive `TypeOf` references back to the - /// function itself. Class and generic-alias variance use the same `Bivariant` cycle fallback. + /// Refer to this signature's equation, including recursive `TypeOf` references to itself. + pub(crate) fn variance_of( + self, + db: &'db dyn Db, + typevar: BoundTypeVarIdentity<'db>, + ) -> VarianceTerm<'db> { + VarianceTerm::variable(db, VarianceOrigin::Function(self), typevar) + } + + /// Build the signature's equation in the function's defining environment, independent of + /// the caller's environment. Recursive `TypeOf` annotations remain named references. #[salsa::tracked( returns(copy), - cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, + cycle_initial=|_, _, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size, )] - pub(crate) fn variance_of( + pub(in crate::types) fn variance_equation( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { let env = ProgramEnvironment::from_scope(self.literal(db).last_definition.body_scope(db)); self.signature(db).variance_of(db, &env, typevar) } @@ -2551,14 +2569,7 @@ impl<'db> FunctionType<'db> { /// Convert the `FunctionType` into a [`CallableType`]. pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> CallableType<'db> { - CallableType::new( - db, - self.signature(db), - self.callable_type_kind(db), - CallableFunctionProvenance::from_function_return_annotation( - self.has_explicit_return_annotation(db), - ), - ) + CallableType::new(db, self.signature(db), self.callable_type_kind(db)) } /// Convert the `FunctionType` into a [`BoundMethodType`]. @@ -2567,7 +2578,7 @@ impl<'db> FunctionType<'db> { db: &'db dyn Db, self_instance: Type<'db>, ) -> BoundMethodType<'db> { - BoundMethodType::new(db, self, self_instance) + BoundMethodType::new(db, self, self_instance, self_instance) } pub(crate) fn find_legacy_typevars_impl( @@ -2984,6 +2995,7 @@ fn is_instance_truthiness<'db>( | Type::SpecialForm(..) | Type::KnownInstance(..) | Type::PropertyInstance(..) + | Type::SlotDescriptor(..) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::BoundSuper(..) @@ -3230,6 +3242,9 @@ pub enum KnownFunction { /// `_pytest.fixtures.fixture` — the `@pytest.fixture` decorator #[strum(serialize = "fixture")] PytestFixture, + /// `_pytest.fixtures.yield_fixture` + #[strum(serialize = "yield_fixture")] + PytestYieldFixture, /// `functools.total_ordering` TotalOrdering, @@ -3357,10 +3372,12 @@ impl KnownFunction { matches!(module, KnownModule::Dataclasses) } Self::PydanticField => matches!(module, KnownModule::PydanticFields), - Self::PytestFixture => matches!(module, KnownModule::PytestFixtures), Self::PydanticFieldValidator => { matches!(module, KnownModule::PydanticFunctionalValidators) } + Self::PytestFixture | Self::PytestYieldFixture => { + matches!(module, KnownModule::PytestFixtures) + } Self::TotalOrdering => module.is_functools(), Self::GetattrStatic => module.is_inspect(), Self::StaticAssert | Self::IgnorableReturnValue | Self::MustUseReturnValue => { @@ -3402,6 +3419,7 @@ impl KnownFunction { overload: &mut Binding<'db>, call_arguments: &CallArguments<'_, 'db>, call_expression: &ast::ExprCall, + caller_semantic_index: &SemanticIndex<'db>, ) { let db = context.db(); let parameter_types = overload.parameter_types(); @@ -3455,9 +3473,14 @@ impl KnownFunction { &ASSERT_TYPE_UNSPELLABLE_SUBTYPE }; if let Some(builder) = context.report_lint(diagnostic, call_expression) { + let settings = DisplaySettings::from_possibly_ambiguous_types( + db, + env, + [*actual_ty, asserted_ty], + ); let mut diagnostic = builder.into_diagnostic(format_args!( "Argument does not have asserted type `{}`", - asserted_ty.display(db, env), + asserted_ty.display_with(db, env, settings.clone()), )); diagnostic.annotate( @@ -3469,28 +3492,28 @@ impl KnownFunction { ) .message(format_args!( "Inferred type is `{}`", - actual_ty.display(db, env) + actual_ty.display_with(db, env, settings.clone()) )), ); if actual_ty.is_subtype_of(db, env, asserted_ty) { diagnostic.info(format_args!( "`{inferred_type}` is a subtype of `{asserted_type}`, but they are not equivalent", - asserted_type = asserted_ty.display(db, env), - inferred_type = actual_ty.display(db, env), + asserted_type = asserted_ty.display_with(db, env, settings.clone()), + inferred_type = actual_ty.display_with(db, env, settings.clone()), )); } else { diagnostic.info(format_args!( "`{asserted_type}` and `{inferred_type}` are not equivalent types", - asserted_type = asserted_ty.display(db, env), - inferred_type = actual_ty.display(db, env), + asserted_type = asserted_ty.display_with(db, env, settings.clone()), + inferred_type = actual_ty.display_with(db, env, settings.clone()), )); } diagnostic.set_concise_message(format_args!( "Type `{}` does not match asserted type `{}`", - actual_ty.display(db, env), - asserted_ty.display(db, env), + actual_ty.display_with(db, env, settings.clone()), + asserted_ty.display_with(db, env, settings), )); } } @@ -3609,23 +3632,18 @@ impl KnownFunction { } if let Some(value) = call_expression.arguments.find_argument_value("val", 1) { + let source = source_text(db, context.file()); let covering = covering_node( context.module().syntax().into(), call_expression.range(), ); - let needs_parens = covering - .parent() - .and_then(ast::AnyNodeRef::as_expr_ref) - .is_some_and(|parent| { - let value_precedence = OperatorPrecedence::from_expr(value); - OperatorPrecedence::from_expr_ref(parent) >= value_precedence - }); - let value_text = &source_text(db, context.file())[value.range()]; - let replacement = if needs_parens { - format!("({value_text})") - } else { - value_text.to_string() - }; + let replacement = unwrapped_call_argument( + call_expression, + value, + covering.parent(), + context.module().tokens(), + &source, + ); diagnostic.help("Remove the redundant `cast`"); diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( replacement, @@ -3633,6 +3651,99 @@ impl KnownFunction { ))); } } + } else if context.is_lint_enabled(&DISJOINT_CAST) + && !context.file().is_stub(db) + && !caller_semantic_index.is_in_type_checking_block( + context.scope().file_scope_id(db), + call_expression.range(), + ) + && source_type.is_disjoint_from(db, env, casted_type) + && !casted_type.is_equivalent_to(db, env, Type::Never) + && !source_type.is_equivalent_to(db, env, Type::Never) + && let Some(builder) = context.report_lint(&DISJOINT_CAST, call_expression) + { + let types = [*source_type, casted_type]; + let settings = DisplaySettings::from_possibly_ambiguous_types(db, env, types); + let source_display = source_type.display_with(db, env, settings.clone()); + let casted_display = casted_type.display_with(db, env, settings.clone()); + let mut diagnostic = builder.into_diagnostic("Cast to a disjoint type"); + diagnostic.set_concise_message(format_args!( + "Cast from `{source_display}` to disjoint type `{casted_display}`", + )); + if let Some(arg) = call_expression.arguments.find_argument_value("typ", 0) { + diagnostic.annotate( + context + .secondary(arg) + .message("Disjoint from the inferred type"), + ); + } + if let Some(arg) = call_expression.arguments.find_argument_value("val", 1) { + diagnostic.annotate( + context + .secondary(arg) + .message(format_args!("Inferred as `{source_display}`")), + ); + } + + // deduplicate definitions before attaching a subdiagnostic to each definition, + // or we'd have multiple subdiagnostics pointing to a single definition + // if the two types are specializations of the same generic class. + let definitions: FxIndexMap, String> = types + .into_iter() + .filter_map(|ty| ty.definition(db, env)) + .filter_map(|definition| definition.definition()) + .filter_map(|definition| Some((definition, definition.name(db)?))) + .collect(); + + for (definition, name) in definitions { + let file = definition.python_file(db); + let module = parsed_module(db, file).load(db); + let mut range = definition.focus_range(db, &module); + if let DefinitionKind::Class(class) = definition.kind(db) { + let definition_types = infer_definition_types(db, definition); + if let Some(decorator) = + class.node(&module).decorator_list.iter().find(|decorator| { + definition_types + .expression_type(&decorator.expression) + .as_function_literal() + .is_some_and(|func| func.is_known(db, KnownFunction::Final)) + }) + { + range = range.cover_range(decorator.range()); + } + } + diagnostic.annotate( + Annotation::secondary(Span::from(range)) + .message(format_args!("`{name}` defined here")), + ); + } + + if casted_type.is_protocol_instance() { + if source_type.is_protocol_instance() { + diagnostic.info(format_args!( + "protocol `{casted_display}` is disjoint \ + from protocol `{source_display}`" + )); + } else { + diagnostic.info(format_args!( + "protocol `{casted_display}` is disjoint \ + from `{source_display}`" + )); + } + } else if source_type.is_protocol_instance() { + diagnostic.info(format_args!( + "`{casted_display}` is disjoint \ + from protocol `{source_display}`" + )); + } else { + diagnostic.info(format_args!( + "`{casted_display}` is disjoint from `{source_display}`" + )); + } + + source_type + .disjointness_error_context(db, env, casted_type) + .attach_to(db, env, &mut diagnostic); } } @@ -3964,8 +4075,9 @@ pub(crate) mod tests { KnownFunction::PydanticField => KnownModule::PydanticFields, KnownFunction::PydanticFieldValidator => KnownModule::PydanticFunctionalValidators, - - KnownFunction::PytestFixture => KnownModule::PytestFixtures, + KnownFunction::PytestFixture | KnownFunction::PytestYieldFixture => { + KnownModule::PytestFixtures + } KnownFunction::GetattrStatic => KnownModule::Inspect, diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index a38a13a8b8..868f34f3cb 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -7,39 +7,41 @@ use itertools::Itertools; use ruff_db::parsed::parsed_module; use ruff_python_ast as ast; use rustc_hash::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; use crate::types::callable::walk_callable_type; use crate::types::class::ClassType; use crate::types::class_base::ClassBase; +use crate::types::constraints::projection::{ProjectionError, SolutionBudget, SolutionProjection}; use crate::types::constraints::{ - ConstraintBounds, ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, PathBound, - PathBounds, Solutions, + Constraint, ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, PathBound, + PathBoundSolution, PathBounds, SolutionPaths, Solutions, TypeVarSolution, }; use crate::types::infer::original_class_type; use crate::types::relation::{ DisjointnessChecker, HasRelationToVisitor, IsDisjointVisitor, TypeRelation, TypeRelationChecker, TypeVarEvaluation, }; -use crate::types::signatures::{ - CallableSignature, Parameters, ReturnCallableTypeVarScope, SignatureRelationVisitor, -}; +use crate::types::signatures::{Parameters, ReturnCallableTypeVarScope, SignatureRelationVisitor}; use crate::types::tuple::{ TupleSpec, TupleSpecBuilder, TupleType, VariableSegment, walk_tuple_type, }; use crate::types::type_alias::{walk_manual_pep_695_type_alias, walk_pep_695_type_alias}; use crate::types::typevar::{ BoundTypeVarIdentity, PackBoundViolation, TypeVarIdentity, TypeVarInstance, TypeVarSet, - pack_bound_violation, walk_type_var_bounds, + pack_bound_violation, }; use crate::types::visitor::{ - TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, + TypeCollector, TypeVisitor, any_over_type, any_over_type_expanding_aliases, + walk_type_with_recursion_guard, }; use crate::types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarInstance, CallableType, CallableTypes, - ClassLiteral, FindLegacyTypeVarsVisitor, IntersectionType, KnownClass, KnownInstanceType, - MaterializationKind, PromotionKind, PromotionMode, SubclassOfInner, Type, TypeAliasType, - TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, - UnionAccumulator, UnionType, binding_type, infer_definition_types, inferred_declaration, + ClassLiteral, ErrorContext, FindLegacyTypeVarsVisitor, IntersectionType, KnownClass, + KnownInstanceType, MaterializationKind, PromotionKind, PromotionMode, SubclassOfInner, Type, + TypeAliasType, TypeContext, TypeMapping, TypeRecursionContext, TypeVarBoundOrConstraints, + TypeVarKind, TypeVarVariance, UnionAccumulator, UnionType, binding_type, + infer_definition_types, inferred_declaration, }; use crate::{Db, FxIndexMap, FxOrderMap, FxOrderSet}; use ty_python_core::definition::{Definition, DefinitionKind}; @@ -441,6 +443,11 @@ pub(super) fn walk_generic_context<'db, V: TypeVisitor<'db> + ?Sized>( // The Salsa heap is tracked separately. impl get_size2::GetSize for GenericContext<'_> {} +/// basedpython: whether `ty` is, or contains, the synthetic `Self` type variable. +fn references_self<'db>(db: &'db dyn Db, ty: Type<'db>) -> bool { + matches!(ty, Type::TypeVar(typevar) if typevar.typevar(db).is_self(db)) +} + impl<'db> GenericContext<'db> { /// Creates a generic context from a list of PEP-695 type parameters. pub(crate) fn from_type_params( @@ -599,76 +606,31 @@ impl<'db> GenericContext<'db> { remove_self_inner(db, self, binding_context) } - /// Returns the typevars that are inferable in this generic context. This set might include - /// more typevars than the ones directly bound by the generic context. For instance, consider a - /// method of a generic class: - /// - /// ```py - /// class C[A]: - /// def method[T](self, t: T): - /// ``` + /// basedpython: whether any variable here is bounded by `Self`. /// - /// In this example, `method`'s generic context binds `Self` and `T`, but its inferable set - /// also includes `A@C`. This is needed because at each call site, we need to infer the - /// specialized class instance type whose method is being invoked. - pub(crate) fn inferable_typevars(self, db: &'db dyn Db) -> TypeVarSet<'db> { - struct CollectTypeVars<'a, 'db> { - env: &'a ProgramEnvironment<'db>, - typevars: RefCell, BoundTypeVarInstance<'db>>>, - recursion_guard: TypeCollector<'db>, - } - - impl<'db> TypeVisitor<'db> for CollectTypeVars<'_, 'db> { - fn program_environment(&self) -> &ProgramEnvironment<'db> { - self.env - } - - fn should_visit_lazy_type_attributes(&self) -> bool { - false - } - - fn visit_bound_type_var_type( - &self, - db: &'db dyn Db, - bound_typevar: BoundTypeVarInstance<'db>, - ) { - self.typevars - .borrow_mut() - .entry(bound_typevar.identity(db)) - .or_insert(bound_typevar); - let typevar = bound_typevar.typevar(db); - if let Some(bound_or_constraints) = typevar.bound_or_constraints(db, self.env) { - walk_type_var_bounds(db, bound_or_constraints, self); - } - } - - fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { - walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); - } - } - - #[salsa::tracked( - returns(copy), - cycle_initial=|_, _, _| TypeVarSet::None, - heap_size=ruff_memory_usage::heap_size, - )] - fn inferable_typevars_inner<'db>( - db: &'db dyn Db, - generic_context: GenericContext<'db>, - ) -> TypeVarSet<'db> { - let env = ProgramEnvironment::from_program(generic_context.program(db)); - let visitor = CollectTypeVars { - env: &env, - typevars: RefCell::default(), - recursion_guard: TypeCollector::default(), - }; - for bound_typevar in generic_context.variables(db) { - visitor.visit_bound_type_var_type(db, bound_typevar); - } - TypeVarSet::from_typevars(db, visitor.typevars.into_inner().into_values()) - } + pub(crate) fn has_self_bounded_variable( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + self.variables(db).any(|bound_typevar| { + bound_typevar + .typevar(db) + .bound_or_constraints(db, env) + .is_some_and(|bound| match bound { + TypeVarBoundOrConstraints::UpperBound(bound) => references_self(db, bound), + TypeVarBoundOrConstraints::Constraints(constraints) => constraints + .elements(db) + .iter() + .copied() + .any(|constraint| references_self(db, constraint)), + }) + }) + } - inferable_typevars_inner(db, self) + /// Returns the typevars directly bound by this generic context. + pub(crate) fn inferable_typevars(self, db: &'db dyn Db) -> TypeVarSet<'db> { + TypeVarSet::from_typevars(db, self.variables(db)) } pub fn variables( @@ -760,7 +722,7 @@ impl<'db> GenericContext<'db> { param .annotated_type() .find_legacy_typevars(db, &env, Some(definition), &mut variables); - if let Some(ty) = param.default_type() { + if let Some(ty) = param.eager_default_type() { ty.find_legacy_typevars(db, &env, Some(definition), &mut variables); } } @@ -893,12 +855,7 @@ impl<'db> GenericContext<'db> { ); let signatures = signatures.with_inherited_generic_context(db, generic_context); - let replacement = CallableType::new( - db, - signatures, - callable.kind(db), - callable.provenance(db), - ); + let replacement = callable.with_signatures(db, signatures); Some((callable, replacement)) }) @@ -1357,12 +1314,22 @@ pub struct Specialization<'db> { // The Salsa heap is tracked separately. impl get_size2::GetSize for Specialization<'_> {} +/// Visit specialization arguments and the generic declaration. pub(super) fn walk_specialization<'db, V: TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, specialization: Specialization<'db>, visitor: &V, ) { walk_generic_context(db, specialization.generic_context(db), visitor); + walk_specialization_types(db, specialization, visitor); +} + +/// Visit specialization arguments without walking the generic declaration. +pub(super) fn walk_specialization_types<'db, V: TypeVisitor<'db> + ?Sized>( + db: &'db dyn Db, + specialization: Specialization<'db>, + visitor: &V, +) { for ty in specialization.types(db) { visitor.visit_type(db, *ty); } @@ -1457,6 +1424,35 @@ impl<'db> Specialization<'db> { mapped_types.map_or(Cow::Borrowed(types), Cow::Owned) } + /// Intersects gradual type arguments with their type parameters' upper bounds. + /// + /// For example, in a protocol `P[T: str]`, a member typed as `T` remains bounded by + /// `str` when the argument is `Any`. Using `Any & str` lets structural comparisons + /// materialize the member in either direction without losing that bound. + pub(super) fn with_typevar_bounds(self, db: &'db dyn Db) -> Self { + let env = ProgramEnvironment::from_program(self.generic_context(db).program(db)); + let types = self.map_types(db, |_, typevar, ty| { + if !any_over_type_expanding_aliases(db, &env, ty, |ty| ty.is_dynamic()) { + return ty; + } + let Some(upper_bound) = typevar.top_materialized_upper_bound(db) else { + return ty; + }; + IntersectionType::from_two_elements(db, &env, ty, upper_bound) + }); + if matches!(types, Cow::Borrowed(_)) { + return self; + } + Self::new( + db, + self.generic_context(db), + types.into_owned().into_boxed_slice(), + self.materialization_kind(db), + self.tuple_inner(db), + self.projections(db), + ) + } + /// Restricts this specialization to only include the typevars in a generic context. If the /// specialization does not include all of those typevars, returns `None`. pub(crate) fn restrict( @@ -1563,12 +1559,23 @@ impl<'db> Specialization<'db> { /// `{U: int}`, we can apply the second specialization to the first, resulting in `T: int`. /// That lets us produce the generic alias `A[int]`, which is the corresponding entry in the /// MRO of `B[int]`. - pub(crate) fn apply_specialization(self, db: &'db dyn Db, other: Specialization<'db>) -> Self { + fn apply_specialization(self, db: &'db dyn Db, other: Specialization<'db>) -> Self { + self.apply_specialization_with_recursion(db, other, None) + } + + pub(super) fn apply_specialization_with_recursion( + self, + db: &'db dyn Db, + other: Specialization<'db>, + recursion_context: Option<&TypeRecursionContext<'db>>, + ) -> Self { let env = &ProgramEnvironment::from_program(other.generic_context(db).program(db)); - let new_specialization = self.apply_type_mapping( + let new_specialization = self.apply_type_mapping_impl( db, env, - &TypeMapping::ApplySpecialization(ApplySpecialization::Specialization(other)), + &TypeMapping::ApplySpecialization(ApplySpecialization::specialization(other)), + &[], + &ApplyTypeMappingVisitor::new(env).with_recursion_context(recursion_context), ); match other.materialization_kind(db) { None => new_specialization, @@ -1576,7 +1583,7 @@ impl<'db> Specialization<'db> { db, env, materialization_kind, - &ApplyTypeMappingVisitor::new(env), + &ApplyTypeMappingVisitor::new(env).with_recursion_context(recursion_context), ), } } @@ -1596,21 +1603,6 @@ impl<'db> Specialization<'db> { ) } - fn apply_type_mapping<'a>( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - type_mapping: &TypeMapping<'a, 'db>, - ) -> Self { - self.apply_type_mapping_impl( - db, - env, - type_mapping, - &[], - &ApplyTypeMappingVisitor::new(env), - ) - } - pub(crate) fn apply_type_mapping_impl<'a>( self, db: &'db dyn Db, @@ -1645,7 +1637,8 @@ impl<'db> Specialization<'db> { env, &TypeMapping::ApplySpecialization(*specialization), tcx, - &ApplyTypeMappingVisitor::new(env), + &ApplyTypeMappingVisitor::new(env) + .with_recursion_context(visitor.recursion_context), ); if new_materialization_kind.is_none() { @@ -1654,7 +1647,8 @@ impl<'db> Specialization<'db> { env, type_mapping, tcx, - &ApplyTypeMappingVisitor::new(env), + &ApplyTypeMappingVisitor::new(env) + .with_recursion_context(visitor.recursion_context), ); if specialized != materialized { new_materialization_kind = Some(*materialization_kind); @@ -1710,7 +1704,7 @@ impl<'db> Specialization<'db> { }); let original_tuple_inner = self.tuple_inner(db); - let tuple_inner = original_tuple_inner.and_then(|tuple| { + let tuple_inner = original_tuple_inner.map(|tuple| { tuple.apply_type_mapping_impl(db, env, type_mapping, TypeContext::default(), visitor) }); @@ -1890,7 +1884,7 @@ impl<'db> Specialization<'db> { } }); let original_tuple_inner = self.tuple_inner(db); - let tuple_inner = original_tuple_inner.and_then(|tuple| { + let tuple_inner = original_tuple_inner.map(|tuple| { // Tuples are immutable, so tuple element types are always in covariant position. tuple.apply_type_mapping_impl( db, @@ -2005,25 +1999,13 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ty.is_dynamic() || matches!(ty, Type::TypeAlias(_)) }) }) - ) && ( - // Avoid the `self.always()` type-variable shortcut in - // `check_subtyping_in_invariant_position`: it would incorrectly conclude - // that `Top[Inv[Any]] <: Inv[T]` for an unresolved `T`. - // TODO: remove this once that shortcut is removed. - target - .types(db) - .iter() - .all(|ty| !ty.has_typevar_or_typevar_instance(db, env)) ) && ( // Only non-pure redundancy needs a target already equal to its top. // Materializing the source otherwise loses the bottom needed to // simplify `Covariant[Any] | Covariant[Any | str]`. Comparing both // top and bottom is a possible alternative, but it gets more complex - // due to the need to preserve Divergent markers. Also the fact that we currently - // simplify tuples containing `Never` to `Never` means that for - // `class C[T: tuple[int, int]]`, `C[tuple[Any, int]]` and `C[tuple[int, Any]]` - // have the same top and bottom but expose `Any` in different tuple positions. - // TODO: Try resolving the above issues so we can compare top/bottom subtyping here. + // due to the need to preserve Divergent markers. + // TODO: Resolve that issue so we can compare top/bottom subtyping here. !matches!(self.relation, TypeRelation::Redundancy { pure: false }) || target == target.materialize_impl( @@ -2086,6 +2068,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // vs `Container[in int]`. No subtyping relation possible. return self.never(); }; + // basedpython: a parameter only a private member mentions is bivariant, so a + // comparison against it normally adds nothing. That is the right answer to the + // yes-or-no question an eager comparison asks. A lazy one is not asking: it is + // recording what the two sides say about the type variables in the target, and + // the class does carry the argument such a solve is after — skipping the position + // hands it back no bound at all. Reading the position covariantly recovers the + // argument without making the relation stricter, since the recorded constraint + // still leaves the source assignable for some solution. + let effective = if matches!(effective, TypeVarVariance::Bivariant) + && self.typevar_evaluation == TypeVarEvaluation::Lazy + && bound_typevar.is_bivariant_by_privacy(db) + && target_type.has_typevar(db, env) + { + TypeVarVariance::Covariant + } else { + effective + }; // Subtyping/assignability of each type in the specialization depends on the variance // of the corresponding typevar: // - covariant: verify that source_type <: target_type @@ -2384,6 +2383,46 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { target_type: Type<'db>, target_materialization: MaterializationKind, ) -> ConstraintSet<'db, 'c> { + // `tuple[Any, ...]` can materialize to a builtin tuple type of any length. Its top + // materialization, `tuple[object, ...]`, is correct. Our bottom materialization is + // incorrect: it becomes `tuple[()]`, which is not a subtype of every fixed-length + // tuple. The endpoint comparisons below therefore cannot establish + // `Box[tuple[int]] <: Top[Box[tuple[Any, ...]]]` for an invariant `Box`. + // Handle this unrestricted materialization family directly. Tuple subclasses do not + // qualify: `Box[MyTuple]` is not a materialization of `Box[tuple[Any, ...]]`. + // TODO: Correct bottom materialization for gradual tuple arity, including required prefixes + // and suffixes, and handle these materialization families in the general invariant comparison. + // Then remove this entire special-case block. + if let (Some(source_tuple), Some(target_tuple)) = ( + source_type.exact_tuple_instance_spec(db), + target_type.exact_tuple_instance_spec(db), + ) { + let is_unrestricted = |tuple: &TupleSpec<'db>| { + if let TupleSpec::Variable(tuple) = tuple + && tuple.prefix_elements().is_empty() + && tuple.suffix_elements().is_empty() + && let VariableSegment::Homogeneous(element) = tuple.variable() + { + // Follow element aliases, stopping at the first non-alias type. A non-dynamic + // type or an alias cycle rules out this unrestricted materialization family. + !any_over_type_expanding_aliases(db, self.env, element, |ty| { + !matches!(ty, Type::TypeAlias(_) | Type::Dynamic(_)) + }) + } else { + false + } + }; + // Top preserves family inclusion, Bottom reverses it, and Bottom-to-Top needs + // only an overlap, so either unrestricted family is enough in that case. + if (target_materialization == MaterializationKind::Top + && is_unrestricted(&target_tuple)) + || (source_materialization == MaterializationKind::Bottom + && is_unrestricted(&source_tuple)) + { + return self.always(); + } + } + let source_top = source_type.materialize( db, env, @@ -2409,20 +2448,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.materialization_visitor, ); - let is_subtype_of = |source: Type<'db>, target: Type<'db>| { - // TODO: - // This should be removed and properly handled in the respective - // `(Type::TypeVar(_), _) | (_, Type::TypeVar(_))` branch of - // `TypeRelationChecker::check_type_pair`. Right now, we cannot generally - // return `self.always()` from that branch, as that leads to union - // simplification, which means that we lose track of type variables - // without recording the constraints under which the relation holds. - if matches!(target, Type::TypeVar(_)) || matches!(source, Type::TypeVar(_)) { - return self.always(); - } - - self.check_type_pair(db, source, target) - }; + let is_subtype_of = |source, target| self.check_type_pair(db, source, target); match (source_materialization, target_materialization) { // `source` is a subtype of `target` if the range of materializations covered by `source` // is a subset of the range covered by `target`. @@ -2437,25 +2463,13 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { is_subtype_of(target_top, source_top) }) } - // The bottom materialization of `source` is a subtype of the top materialization - // of `target` if there is some type that is both within the - // range of types covered by derived and within the range covered by base, because if such a type - // exists, it's a subtype of `Top[target]` and a supertype of `Bottom[source]`. + // The ranges overlap when each lower bound is a subtype of the other upper bound. + // Their common materialization can lie strictly inside both ranges: neither the + // lower bounds nor the upper bounds need to be comparable to each other. (MaterializationKind::Bottom, MaterializationKind::Top) => { - is_subtype_of(target_bottom, source_bottom) - .and(db, self.constraints, || { - is_subtype_of(source_bottom, target_top) - }) - .or(db, self.constraints, || { - is_subtype_of(target_bottom, source_top).and(db, self.constraints, || { - is_subtype_of(source_top, target_top) - }) - }) - .or(db, self.constraints, || { - is_subtype_of(target_top, source_top).and(db, self.constraints, || { - is_subtype_of(source_bottom, target_top) - }) - }) + is_subtype_of(source_bottom, target_top).and(db, self.constraints, || { + is_subtype_of(target_bottom, source_top) + }) } // A top materialization is a subtype of a bottom materialization only if both original // un-materialized types are the same fully static type. @@ -2468,7 +2482,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } -fn specialization_variance<'db>( +pub(super) fn specialization_variance<'db>( db: &'db dyn Db, bound_typevar: BoundTypeVarInstance<'db>, ) -> TypeVarVariance { @@ -2604,16 +2618,45 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { // `Bottom[L] <: Top[R]` asks whether the materialization ranges for `L` // and `R` have any common materialization, so this is symmetric despite // using a directional subtyping checker. - self.as_relation_checker(TypeRelation::Subtyping) - .check_subtyping_in_invariant_position( - db, - env, - left_type, - MaterializationKind::Bottom, - right_type, - MaterializationKind::Top, - ) - .negate(db, self.constraints) + // Keep type-variable comparisons as constraints: `list[T]` can equal + // `list[int]` when `T = int`, but cannot equal `int` for any `T`. Disjointness + // requires that no valid specialization satisfies the overlap constraints, + // including the type variables' declared bounds and constraints. + // These variables stand for specializations we have yet to choose. Keep + // their declared domains intact: materializing `T: Any` to `T: Never` + // would incorrectly rule out the valid choice `T = str`. + let materialization_visitor = ApplyTypeMappingVisitor { + materialize_typevar_bounds_and_defaults: false, + ..ApplyTypeMappingVisitor::new(self.env) + }; + let mut checker = self.as_relation_checker(TypeRelation::Subtyping); + checker.typevar_evaluation = TypeVarEvaluation::Lazy; + checker.materialization_visitor = &materialization_visitor; + let result = self + .check_relation_with_context(db, checker, |checker| { + let overlap = checker.check_subtyping_in_invariant_position( + db, + env, + left_type, + MaterializationKind::Bottom, + right_type, + MaterializationKind::Top, + ); + ConstraintSet::from_bool( + self.constraints, + !overlap.has_no_valid_solutions(db, self.env), + ) + }) + .negate(db, self.constraints); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, self.env) + { + context.push(ErrorContext::InvariantTypeArgument { + left: left_type, + right: right_type, + }); + } + result } // If `Foo[T]` is covariant in `T`, `Foo[Never]` is a subtype of `Foo[A]` and `Foo[B]` @@ -2635,7 +2678,15 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// substitute types for type variables before we have fully constructed a [`Specialization`]. #[derive(Clone, Copy, Debug, Eq, PartialEq, get_size2::GetSize)] pub enum ApplySpecialization<'a, 'db> { - Specialization(Specialization<'db>), + Specialization { + specialization: Specialization<'db>, + /// Whether to substitute free owner variables in the declared domain of a retained + /// synthetic `Self`. + /// + /// This is only set when projecting a member from its enclosing generic owner. Ordinary + /// specialization preserves that domain as fixed evidence. + specialize_self_domain: bool, + }, TypeAlias(Specialization<'db>), Partial { generic_context: GenericContext<'db>, @@ -2648,9 +2699,53 @@ pub enum ApplySpecialization<'a, 'db> { /// Maps a single typevar to a concrete type. Used by the constraint set's sequent map to /// substitute a typevar nested inside another constraint's bound. Single(BoundTypeVarInstance<'db>, Type<'db>), + /// Overrides the given type variables in an existing specialization mapping. + WithBindings { + specialization: &'a ApplySpecialization<'a, 'db>, + bindings: &'a [(BoundTypeVarInstance<'db>, Type<'db>)], + }, } impl<'db> ApplySpecialization<'_, 'db> { + pub(crate) fn specialization(specialization: Specialization<'db>) -> Self { + Self::Specialization { + specialization, + specialize_self_domain: false, + } + } + + /// The same mapping, but also rewriting the declared domain of a retained synthetic `Self`. + /// + /// Only a member projected out of its enclosing generic owner wants this: `Self`'s domain + /// names the owner's own variables, so on `Box[int].add` it has to become `Box[int]` along + /// with everything else. Everywhere else that domain is fixed evidence and must not move. + pub(crate) fn specialization_for_member(specialization: Specialization<'db>) -> Self { + Self::Specialization { + specialization, + specialize_self_domain: true, + } + } + + pub(crate) fn specialize_self_domain(self) -> bool { + match self { + Self::Specialization { + specialize_self_domain, + .. + } => specialize_self_domain, + Self::WithBindings { specialization, .. } => specialization.specialize_self_domain(), + _ => false, + } + } + + /// Returns `true` if this mapping should leave unevaluated function signatures unchanged. + pub(super) fn preserves_lazy_signatures(self) -> bool { + match self { + Self::ReturnCallables(_) | Self::TypeAlias(_) => true, + Self::WithBindings { specialization, .. } => specialization.preserves_lazy_signatures(), + _ => false, + } + } + /// Returns the type that a typevar is mapped to, or None if the typevar isn't part of this /// mapping. pub(crate) fn get( @@ -2659,7 +2754,7 @@ impl<'db> ApplySpecialization<'_, 'db> { bound_typevar: BoundTypeVarInstance<'db>, ) -> Option> { match self { - ApplySpecialization::Specialization(specialization) + ApplySpecialization::Specialization { specialization, .. } | ApplySpecialization::TypeAlias(specialization) => { specialization.get(db, bound_typevar) } @@ -2686,6 +2781,14 @@ impl<'db> ApplySpecialization<'_, 'db> { None } } + ApplySpecialization::WithBindings { + specialization, + bindings, + } => bindings + .iter() + .find(|(typevar, _)| bound_typevar.is_same_typevar_as(db, *typevar)) + .map(|(_, ty)| *ty) + .or_else(|| specialization.get(db, bound_typevar)), } } @@ -2693,7 +2796,7 @@ impl<'db> ApplySpecialization<'_, 'db> { /// context, preserving skipped type variables in partial specializations as identity mappings. pub(crate) fn as_specialization(self, db: &'db dyn Db) -> Option> { match self { - ApplySpecialization::Specialization(specialization) + ApplySpecialization::Specialization { specialization, .. } | ApplySpecialization::TypeAlias(specialization) => Some(specialization), ApplySpecialization::Partial { generic_context, @@ -2719,6 +2822,26 @@ impl<'db> ApplySpecialization<'_, 'db> { ), ), ApplySpecialization::ReturnCallables(_) | ApplySpecialization::Single(_, _) => None, + ApplySpecialization::WithBindings { + specialization, + bindings, + } => { + let specialization = specialization.as_specialization(db)?; + let types = specialization.map_types(db, |_, variable, original| { + bindings + .iter() + .find(|(typevar, _)| variable.is_same_typevar_as(db, *typevar)) + .map_or(original, |(_, ty)| *ty) + }); + Some(Specialization::new( + db, + specialization.generic_context(db), + types, + specialization.materialization_kind(db), + specialization.tuple_inner(db), + specialization.projections(db), + )) + } } } } @@ -2749,9 +2872,10 @@ pub(crate) struct SpecializationBuilder<'db, 'c> { db: &'db dyn Db, env: &'c ProgramEnvironment<'db>, constraints: &'c ConstraintSetBuilder<'db>, + generic_context: GenericContext<'db>, inferable: TypeVarSet<'db>, pending: ConstraintSet<'db, 'c>, - types: FxHashMap, UnionAccumulator<'db>>, + types: LegacyTypeMappings<'db>, /// Typevars that were inferred only from bivariant positions, which contribute no bound to /// `pending`. The constraint solver therefore has nothing to solve them from, even though /// `types` holds the type we inferred for them. @@ -2759,24 +2883,39 @@ pub(crate) struct SpecializationBuilder<'db, 'c> { paramspec_seen: FxHashSet>, } -/// The result of type variable inference before choosing how to handle unsolved type variables. +/// The legacy mapping is usable only if no accepted relation was omitted in its entirety. +/// Missing evidence from one argument can make the other arguments' inferred types too narrow. +enum LegacyTypeMappings<'db> { + Available(FxHashMap, UnionAccumulator<'db>>), + BudgetExceeded, +} + +/// Correlated type-variable inference, together with the merged projection used by consumers +/// that still require a single specialization. /// -/// A `Some` entry means inference solved the corresponding type variable to that type. A `None` -/// entry means the type variable was not solved and should be projected according to the use site. +/// A `None` entry means no type was inferred for that variable. Defaults are applied only when a +/// consumer requests a specialization, after selecting its projection. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub(crate) struct TypeVarInference<'db> { #[returns(copy)] pub(crate) generic_context: GenericContext<'db>, + /// Inferred types in generic-context order. Multiple solutions are union-merged per variable, + /// including fallback types from incomplete solution families. This projection loses + /// correlations and completeness; `solutions` retains that information. When correlated + /// solutions are unavailable, this holds the compatibility or diagnostic recovery mapping. #[returns(deref)] - types: Box<[Option>]>, + merged_types: Box<[Option>]>, + #[returns(ref)] + pub(crate) solutions: TypeVarInferenceSolutions<'db>, } // The Salsa heap is tracked separately. impl get_size2::GetSize for TypeVarInference<'_> {} impl<'db> TypeVarInference<'db> { - /// Project this inference result into a closed specialization. - pub(crate) fn specialization(self, db: &'db dyn Db) -> Specialization<'db> { + /// Merge the alternatives into one closed specialization, discarding their correlations and + /// completeness. Compatibility and diagnostic results use their recovery mapping. + pub(crate) fn merged_specialization(self, db: &'db dyn Db) -> Specialization<'db> { #[salsa::tracked( returns(copy), cycle_initial=|db, _, inference: TypeVarInference<'db>| { @@ -2792,21 +2931,23 @@ impl<'db> TypeVarInference<'db> { } } )] - fn specialization_inner<'db>( + fn merged_specialization_inner<'db>( db: &'db dyn Db, inference: TypeVarInference<'db>, ) -> Specialization<'db> { - inference.specialization_with(db, |_, _| None) + inference.merged_specialization_with(db, |_, _| None) } - specialization_inner(db, self) + merged_specialization_inner(db, self) } + /// Project the merged inference result into a specialization with explicit handling for each + /// type variable. Alternatives are merged before applying defaults or the projection hook. /// Whether inference left any type variable unsolved that has no default to fall back on. pub(crate) fn has_unsolved(self, db: &'db dyn Db) -> bool { self.generic_context(db) .variables(db) - .zip(self.types(db).iter()) + .zip(self.merged_types(db).iter()) .any(|(typevar, inferred)| inferred.is_none() && typevar.default_type(db).is_none()) } @@ -2816,7 +2957,7 @@ impl<'db> TypeVarInference<'db> { /// The hook receives the type variable and its inferred type, if any. Returning `Some` overrides /// the projection for that variable. Returning `None` uses the inferred type if present, /// otherwise the type variable's default. - pub(crate) fn specialization_with( + pub(crate) fn merged_specialization_with( self, db: &'db dyn Db, mut choose: impl FnMut(BoundTypeVarInstance<'db>, Option>) -> Option>, @@ -2824,21 +2965,177 @@ impl<'db> TypeVarInference<'db> { let types = self .generic_context(db) .variables(db) - .zip(self.types(db).iter().copied()) + .zip(self.merged_types(db).iter().copied()) .map(|(typevar, inferred)| choose(typevar, inferred).or(inferred)); self.generic_context(db).specialize_recursive(db, types) } } -/// A failure to project a constraint set into the legacy type-mapping representation. +/// The alternatives retained when solving the pending constraints, before merging each +/// variable's inferred types. +/// +/// These describe the constraints recorded in `SpecializationBuilder::pending`. Argument inference +/// can simplify argument and parameter types first: for example, it drops `None` when comparing +/// `list[int] | None` with `list[T]`. A complete solve of the resulting constraints does not imply +/// that the original argument is assignable to the parameter. /// -/// A type-variable declaration failure can be reported immediately. Other unsatisfiable -/// relations must remain in the pending constraint set so that they invalidate the call-wide -/// solution without producing a misleading bound diagnostic. -enum ConstraintSetInferenceError<'db> { - InvalidTypeVar(SpecializationError<'db>), +/// Each alternative stores types in the generic context's variable order, like `merged_types`, +/// with `None` when no evidence selects a type. Completeness means all retained paths were solved +/// without budget-exhaustion fallback, not that every variable has an inferred type. Defaults for +/// variables without evidence are applied only when a consumer creates a specialization. +/// +/// If a retained path exhausts its budget, the family is incomplete even if its siblings were +/// solved without fallback. Such a family cannot be treated as an exhaustive account of the +/// constraint set's specializations. +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum TypeVarInferenceSolutions<'db> { + /// The sole solution is already stored in `merged_types`. + Single, + /// Two or more correlated alternatives, none relying on budget-exhaustion fallback. + /// A variable may still be `None` if the constraints provide no evidence for its type. + Alternatives(Box<[Box<[Option>]>]>), + /// Retained alternatives, including fallback bindings from budget-exhausted solutions. + /// Siblings solved without fallback are kept, but the family as a whole is incomplete. + Incomplete(Box<[Box<[Option>]>]>), + /// Only a compatibility or diagnostic mapping is available, not correlated solutions. + Unavailable(TypeVarInferenceFallback), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum TypeVarInferenceFallback { + Unconstrained, + Variadic, Unsatisfiable, + ExpandingCycle, + BudgetExceeded, +} + +/// An owned projection before allocating a cached inference result. Correlated callers retain +/// `SolutionPaths`; merged-only callers use `()` and never allocate an alternative family. +struct PendingInference<'db, T> { + merged_types: FxHashMap, Type<'db>>, + solutions: Result, +} + +/// The available solutions of a constraint set, or evidence for why it is unsatisfiable. +/// +/// Failed paths can occur alongside valid paths, but their declaration failures matter only when +/// every path is rejected. Preserve that evidence exclusively for unsatisfiable constraint sets. +/// Per-variable budget exhaustion retains fallback bindings and completeness information. If +/// collecting the whole relation exceeds a limit, no partial family is available for projection. +enum ConstraintSetAnalysis<'db> { + Unsatisfiable(SmallVec<[ConstraintFailure<'db>; 1]>), + Unconstrained, + Constrained(SolutionPaths<'db>), + /// A collection or result limit was exceeded. No partial family is safe to project. + BudgetExceeded, +} + +impl<'db> ConstraintSetAnalysis<'db> { + /// Reports why a type variable's declared bound or constraints cannot be satisfied. + /// + /// Multiple rejected paths describe one failure when their lower bounds violate the same type + /// variable's declaration in a contravariant position. Their argument types are combined into + /// an intersection. For example, paths rejecting `int` and `bool` for `T: bytes` report + /// `bool`, the intersection of `int` and `bool`. + /// + /// The inference API returns at most one declaration error per relation. Failures involving + /// different declarations, variances, or type variables cannot be combined meaningfully, so + /// only the first is reported. + fn specialization_error( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option> { + let Self::Unsatisfiable(failures) = self else { + return None; + }; + + let first = failures.first()?; + // A single failure needs no aggregation; failures for different declarations, type + // variables, or variances cannot be combined, so they also return the first failure. + // TODO: Rank incompatible failures by their diagnostic usefulness, or report them + // separately. + if failures.len() < 2 + || failures.iter().any(|failure| { + failure.error.bound_typevar() != first.error.bound_typevar() + || failure.variance != first.variance + || !matches!( + (&first.error, &failure.error), + ( + SpecializationError::MismatchedBound { .. }, + SpecializationError::MismatchedBound { .. } + ) | ( + SpecializationError::MismatchedConstraint { .. }, + SpecializationError::MismatchedConstraint { .. } + ) + ) + }) + { + return Some(first.error.clone()); + } + + let arguments = failures.iter().map(|failure| failure.error.argument_type()); + let argument = match first.variance { + ConstraintFailureVariance::Contravariant => { + IntersectionType::from_elements(db, env, arguments) + } + ConstraintFailureVariance::Invariant => { + // TODO: Combine invariant failures without losing their lower- or upper-bound + // evidence. + return Some(first.error.clone()); + } + }; + + let mut error = first.error.clone(); + if let SpecializationError::MismatchedBound { + argument: existing, .. + } + | SpecializationError::MismatchedConstraint { + argument: existing, .. + } = &mut error + { + *existing = argument; + } + Some(error) + } +} + +/// A declared type-variable bound or constraint rejected while solving one alternative. +/// +/// The variance identifies whether the rejected lower bound also has an upper bound, so multiple +/// failures from the same relation can be combined into one diagnostic. +struct ConstraintFailure<'db> { + error: SpecializationError<'db>, + variance: ConstraintFailureVariance, +} + +/// The possible variances for a path with a lower bound that violates a declaration. +/// +/// Covariant and bivariant paths have no lower bound, so they cannot produce declaration failures. +#[derive(Clone, Copy, Eq, PartialEq)] +enum ConstraintFailureVariance { + Contravariant, + Invariant, +} + +/// Returns the directional comparisons required by this comparison's polarity. +/// +/// A covariant comparison requires `actual <= formal`, a contravariant comparison requires +/// `formal <= actual`, and an invariant comparison requires both. Bivariant comparisons add +/// no constraints. +fn relation_directions( + formal: T, + actual: T, + polarity: TypeVarVariance, +) -> impl Iterator { + [ + (!polarity.is_contravariant()).then_some((actual, formal)), + (!polarity.is_covariant()).then_some((formal, actual)), + ] + .into_iter() + .flatten() } impl<'db, 'c> SpecializationBuilder<'db, 'c> { @@ -2846,15 +3143,16 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { db: &'db dyn Db, env: &'c ProgramEnvironment<'db>, constraints: &'c ConstraintSetBuilder<'db>, - inferable: TypeVarSet<'db>, + generic_context: GenericContext<'db>, ) -> Self { Self { db, env, constraints, - inferable, + generic_context, + inferable: generic_context.inferable_typevars(db), pending: ConstraintSet::from_bool(constraints, true), - types: FxHashMap::default(), + types: LegacyTypeMappings::Available(FxHashMap::default()), unconstrained: FxHashSet::default(), paramspec_seen: FxHashSet::default(), } @@ -2869,8 +3167,8 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { self.infer_from_constraint_set(set) } - /// Build a specialization, using a caller-provided hook to select the solution for each - /// typevar. + /// Build a merged specialization, using a caller-provided hook to select the solution for + /// each typevar. This compatibility API discards correlations and solving completeness. /// /// The `choose` hook is called for each typevar on the generic context with the typevar's /// explicit lower and upper bounds. @@ -2879,39 +3177,84 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { /// /// The hook should return `Some(ty)` to use `ty` as the specialization for this typevar, or /// `None` to use the inferred type unchanged. - pub(crate) fn build_with( + pub(crate) fn build_merged_with( &mut self, - generic_context: GenericContext<'db>, mut choose: impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, ) -> Specialization<'db> { let db = self.db; + let mut choose_solution = |typevar, bounds: Option<&PathBound<'db>>| { + choose(typevar, bounds).map(PathBoundSolution::Solved) + }; + let inference = self + .solve_pending_projection(&mut choose_solution, |builder, choose| { + builder.pending.try_fold_solutions( + db, + builder.env, + builder.inferable, + SolutionBudget::default(), + |_variance, path_bound| { + let outcome = choose(path_bound.bound_typevar, Some(path_bound)) + .unwrap_or_else(|| { + PathBounds::default_solve( + db, + builder.env, + builder.constraints, + path_bound, + ) + }); + // Only this explicitly merged projection accepts fallback bindings as + // ordinary types. Correlated inference retains their incomplete outcome. + match outcome { + PathBoundSolution::BudgetExceeded { fallback } => fallback + .map_or(PathBoundSolution::Unsolved, PathBoundSolution::Solved), + outcome => outcome, + } + }, + PendingInference { + merged_types: FxHashMap::default(), + solutions: Ok(()), + }, + |mut inference, solution, budget| { + for binding in solution { + budget.charge_type(db, binding.solution)?; + } + builder.merge_solution(&mut inference.merged_types, solution); + Ok(inference) + }, + ) + }) + .unwrap_or_else(|()| { + self.compatibility_inference_with( + TypeVarInferenceFallback::Unsatisfiable, + &mut choose_solution, + ) + }); let types = self - .solve_pending_with(generic_context, &mut choose) - .unwrap_or_else(|()| self.solve_hash_map_with(generic_context, &mut choose)); - let specialization = - generic_context - .variables_inner(db) - .iter() - .map(|(identity, variable)| { - types - .get(identity) - .copied() - .or_else(|| choose(*variable, None)) - }); - - generic_context.specialize_recursive(db, specialization) + .generic_context + .variables_inner(db) + .iter() + .map(|(identity, variable)| { + inference + .merged_types + .get(identity) + .copied() + .or_else(|| choose(*variable, None)) + }); + self.generic_context.specialize_recursive(db, types) } - /// Build raw type-variable inference, preserving which type variables were left unsolved. + /// Build correlated type-variable inference, preserving missing evidence and incomplete + /// solutions. The hook must retain whether an override is only a fallback. /// /// Returns an error if the call-wide pending constraints are unsatisfiable. pub(crate) fn build_inference_with( &mut self, - generic_context: GenericContext<'db>, - mut choose: impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, + mut choose: impl FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, ) -> Result, ()> { - let types = self.solve_pending_with(generic_context, &mut choose)?; - Ok(self.typevar_inference(generic_context, &types)) + self.solve_pending_with(SolutionBudget::default(), &mut choose) } /// Build a diagnostic specialization after the call-wide constraints were unsatisfiable. @@ -2921,50 +3264,173 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { /// even when a migrated inference path only populated `pending`. pub(crate) fn build_diagnostic_inference_with( &mut self, - generic_context: GenericContext<'db>, argument_relations: impl IntoIterator, Type<'db>)>, - mut choose: impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, + mut choose: impl FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, ) -> TypeVarInference<'db> { let db = self.db; for (formal, actual) in argument_relations { let when = actual.when_constraint_set_assignable_to(db, self.env, formal, self.constraints); - let _ = self.add_type_mappings_from_constraint_set(when); + let analysis = self.analyze_constraint_set(when); + self.project_for_legacy_fallback(&analysis); } - let types = self.solve_hash_map_with(generic_context, &mut choose); - self.typevar_inference(generic_context, &types) + let inference = + self.compatibility_inference_with(TypeVarInferenceFallback::Unsatisfiable, &mut choose); + self.finish_inference(inference) + } + + /// Builds a recovery mapping from the already accumulated per-relation solutions. This must + /// not rerun the call-wide solver or present a merged legacy mapping as a correlated solution. + fn compatibility_inference_with( + &mut self, + reason: TypeVarInferenceFallback, + choose: &mut impl FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, + ) -> PendingInference<'db, T> { + let merged_types = self + .solve_hash_map_with(self.generic_context, &mut |typevar, bounds| { + choose(typevar, bounds).and_then(PathBoundSolution::as_type) + }); + PendingInference { + merged_types, + solutions: Err(reason), + } } fn typevar_inference( &self, - generic_context: GenericContext<'db>, types: &FxHashMap, Type<'db>>, + solutions: TypeVarInferenceSolutions<'db>, ) -> TypeVarInference<'db> { + TypeVarInference::new( + self.db, + self.generic_context, + self.types_in_context_order(types), + solutions, + ) + } + + fn types_in_context_order( + &self, + types: &FxHashMap, Type<'db>>, + ) -> Box<[Option>]> { let db = self.db; - let inferred: Box<[_]> = generic_context + self.generic_context .variables_inner(db) .keys() .map(|identity| types.get(identity).copied()) - .collect(); - - TypeVarInference::new(db, generic_context, inferred) + .collect() } + /// Solves the call once, retaining correlated alternatives and deriving their compatibility + /// projection from the same bindings. fn solve_pending_with( &mut self, - generic_context: GenericContext<'db>, - choose: &mut impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, - ) -> Result, Type<'db>>, ()> { + budget: SolutionBudget, + choose: &mut impl FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, + ) -> Result, ()> { let db = self.db; - // TODO: Move `ParamSpec` and `TypeVarTuple` handling to the new constraint solver. - if generic_context - .variables_inner(db) - .values() - .any(|typevar| typevar.is_parameter_pack(self.db) || typevar.is_typevartuple(self.db)) - { - return Ok(self.solve_hash_map_with(generic_context, choose)); - } + let inference = self.solve_pending_projection(choose, |builder, choose| { + let solutions = builder.pending.solutions_with( + db, + builder.env, + builder.inferable, + budget, + |_variance, path_bound| { + choose(path_bound.bound_typevar, Some(path_bound)).unwrap_or_else(|| { + PathBounds::default_solve(db, builder.env, builder.constraints, path_bound) + }) + }, + )?; + Ok(match solutions { + Solutions::Unsatisfiable => SolutionProjection::Unsatisfiable, + Solutions::Unconstrained => SolutionProjection::Unconstrained, + Solutions::Constrained(solutions) => { + let mut merged_types = FxHashMap::default(); + for solution in solutions.as_slice() { + builder.merge_solution(&mut merged_types, solution); + } + + // Solving charges only present bindings, but context-aligned alternatives + // also allocate slots for unsolved variables. Bound those slots before + // allocating any arrays; a complete single solution reuses `merged_types`. + let solutions = if matches!(&solutions, SolutionPaths::Complete(paths) if paths.len() == 1) + || solutions + .as_slice() + .len() + .checked_mul(builder.generic_context.len(db)) + .is_some_and(|slots| slots <= budget.type_terms) + { + Ok(solutions) + } else { + // The merged projection is still valid when only alternative storage + // exceeds its limit. + Err(TypeVarInferenceFallback::BudgetExceeded) + }; + SolutionProjection::Constrained(PendingInference { + merged_types, + solutions, + }) + } + }) + })?; + Ok(self.finish_inference(inference)) + } + + fn merge_solution( + &self, + types: &mut FxHashMap, Type<'db>>, + solution: &[TypeVarSolution<'db>], + ) { + let db = self.db; + for binding in solution { + types + .entry(binding.bound_typevar.identity(db)) + .and_modify(|existing| { + *existing = + UnionType::from_two_elements(db, self.env, *existing, binding.solution); + }) + .or_insert(binding.solution); + } + } + + /// Shares recovery and normalization between streaming merged projection and correlated + /// solution collection. The projection decides which solved evidence it needs to retain. + fn solve_pending_projection( + &mut self, + choose: &mut Choose, + project: impl FnOnce( + &Self, + &mut Choose, + ) + -> Result>, ProjectionError>, + ) -> Result, ()> + where + Choose: FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, + { + let db = self.db; + let generic_context = self.generic_context; + // TODO: Move `ParamSpec` and `TypeVarTuple` handling to the new constraint solver. + if generic_context + .variables(db) + .any(|typevar| typevar.is_parameter_pack(db) || typevar.is_typevartuple(db)) + { + return Ok( + self.compatibility_inference_with(TypeVarInferenceFallback::Variadic, choose) + ); + } // TODO: This projection / solve can be expensive for large-union collection-literal type // contexts. During the pending-constraint-set migration, pydantic and hydra-zen regressed @@ -2978,40 +3444,25 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // was not enough: `solutions_with` still performed the expensive path traversal, and the // skipped projection changed precision in LiteralString tests. See the // `ty_micro[pydantic_core_schema_dict]` benchmark for a minimized reproducer. - let solutions = match self.pending.solutions_with( - db, - self.env, - self.constraints, - self.inferable, - |_variance, path_bound| { - let typevar = path_bound.bound_typevar; - if let Some(ty) = choose(typevar, Some(path_bound)) { - return Ok(Some(ty)); - } - - PathBounds::default_solve(db, self.env, self.constraints, path_bound) - }, - ) { - Solutions::Unsatisfiable => return Err(()), - Solutions::Unconstrained => { - return Ok(self.solve_hash_map_with(generic_context, choose)); + let mut inference = match project(self, choose) { + Ok(SolutionProjection::Unsatisfiable) => return Err(()), + Ok(SolutionProjection::Unconstrained) => { + return Ok(self.compatibility_inference_with( + TypeVarInferenceFallback::Unconstrained, + choose, + )); } - Solutions::Constrained(solutions) => solutions, - }; - - let mut types = FxHashMap::default(); - for solution in solutions { - for binding in solution { - let identity = binding.bound_typevar.identity(db); - types - .entry(identity) - .and_modify(|existing| { - *existing = - UnionType::from_two_elements(db, self.env, *existing, binding.solution); - }) - .or_insert(binding.solution); + Err(_) => { + // A partial mapping may be narrower than the unvisited alternatives. Recover + // without choosing a witness or repeating the exhausted traversal/construction. + return Ok(PendingInference { + merged_types: self.unknown_type_mappings(generic_context), + solutions: Err(TypeVarInferenceFallback::BudgetExceeded), + }); } - } + Ok(SolutionProjection::Constrained(inference)) => inference, + }; + let types = &mut inference.merged_types; // Sequent-map transitivity can add relationships between inferable typevars to path // bounds. Those relationships are important while solving, but should not become recursive @@ -3044,14 +3495,79 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // solution layer. if types .iter() - .any(|(identity, ty)| self.has_expanding_cycle(generic_context, &types, *identity, *ty)) + .any(|(identity, ty)| self.has_expanding_cycle(generic_context, types, *identity, *ty)) { // Recursive specialization cannot reach a fixed point when a cycle grows through an // embedded generic type, such as `SupportsAdd[T, S]`. - Ok(self.solve_hash_map_with(generic_context, choose)) - } else { - Ok(types) + return Ok( + self.compatibility_inference_with(TypeVarInferenceFallback::ExpandingCycle, choose) + ); } + + Ok(inference) + } + + fn finish_inference( + &self, + inference: PendingInference<'db, SolutionPaths<'db>>, + ) -> TypeVarInference<'db> { + let db = self.db; + let generic_context = self.generic_context; + let PendingInference { + merged_types: types, + solutions, + } = inference; + let solutions = match solutions { + Ok(solutions) => solutions, + Err(reason) => { + return self + .typevar_inference(&types, TypeVarInferenceSolutions::Unavailable(reason)); + } + }; + let complete = matches!(solutions, SolutionPaths::Complete(_)); + if complete && solutions.as_slice().len() == 1 { + return self.typevar_inference(&types, TypeVarInferenceSolutions::Single); + } + + // The compatibility projection must be cleaned after merging, independently of these + // alternatives: a bare `U` survives on one path, but is removed from a merged `U | int`. + let mut paths = Vec::with_capacity(solutions.as_slice().len()); + for path in solutions.into_vec() { + let path_types: FxHashMap<_, _> = path + .into_iter() + .filter_map(|binding| { + let identity = binding.bound_typevar.identity(db); + generic_context.contains(db, identity).then(|| { + ( + identity, + self.remove_inferable_typevar_artifacts_from_solution( + binding.bound_typevar, + binding.solution, + ), + ) + }) + }) + .collect(); + if path_types.iter().any(|(identity, ty)| { + self.has_expanding_cycle(generic_context, &path_types, *identity, *ty) + }) { + return self.typevar_inference( + &types, + TypeVarInferenceSolutions::Unavailable( + TypeVarInferenceFallback::ExpandingCycle, + ), + ); + } + paths.push(self.types_in_context_order(&path_types)); + } + + let paths = paths.into_boxed_slice(); + let solutions = if complete { + TypeVarInferenceSolutions::Alternatives(paths) + } else { + TypeVarInferenceSolutions::Incomplete(paths) + }; + self.typevar_inference(&types, solutions) } fn has_expanding_cycle( @@ -3197,37 +3713,77 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { choose: &mut impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, ) -> FxHashMap, Type<'db>> { let db = self.db; + let LegacyTypeMappings::Available(types) = &mut self.types else { + return self.unknown_type_mappings(generic_context); + }; generic_context .variables_inner(db) .iter() .filter_map(|(identity, variable)| { - Some((*identity, self.mapped_type(*variable, choose)?)) + let mapped_ty = types + .get_mut(identity) + .map(|accumulator| accumulator.get_or_build(db, self.env)); + let chosen = match mapped_ty { + Some(mapped_ty) => { + let path_bound = PathBound::exact(*variable, mapped_ty); + choose(*variable, Some(&path_bound)).unwrap_or(mapped_ty) + } + None => choose(*variable, None)?, + }; + Some((*identity, chosen)) }) .collect() } /// The type inferred for `variable` while walking the arguments, as the `choose` hook projects /// it. - fn mapped_type( + fn mapped_type( &mut self, variable: BoundTypeVarInstance<'db>, - choose: &mut impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, - ) -> Option> { + choose: &mut Choose, + ) -> Option> + where + Choose: FnMut( + BoundTypeVarInstance<'db>, + Option<&PathBound<'db>>, + ) -> Option>, + { let env = self.env; - let mapped_ty = self - .types - .get_mut(&variable.identity(self.db)) - .map(|accumulator| accumulator.get_or_build(self.db, env)); + let db = self.db; + let mapped_ty = match &mut self.types { + LegacyTypeMappings::Available(types) => types + .get_mut(&variable.identity(db)) + .map(|accumulator| accumulator.get_or_build(db, env)), + LegacyTypeMappings::BudgetExceeded => None, + }; match mapped_ty { Some(mapped_ty) => { let path_bound = PathBound::exact(variable, mapped_ty); - Some(choose(variable, Some(&path_bound)).unwrap_or(mapped_ty)) + Some( + choose(variable, Some(&path_bound)) + .and_then(PathBoundSolution::as_type) + .unwrap_or(mapped_ty), + ) } - None => choose(variable, None), + None => choose(variable, None).and_then(PathBoundSolution::as_type), } } + fn unknown_type_mappings( + &self, + generic_context: GenericContext<'db>, + ) -> FxHashMap, Type<'db>> { + let db = self.db; + let unknown = generic_context.unknown_specialization(db, None); + generic_context + .variables_inner(db) + .keys() + .copied() + .zip(unknown.types(db).iter().copied()) + .collect() + } + fn insert_hash_map_type_mapping( &mut self, bound_typevar: BoundTypeVarInstance<'db>, @@ -3235,7 +3791,10 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ) { let db = self.db; let identity = bound_typevar.identity(db); - match self.types.entry(identity) { + let LegacyTypeMappings::Available(types) = &mut self.types else { + return; + }; + match types.entry(identity) { Entry::Occupied(mut entry) => { match bound_typevar.kind(self.db) { TypeVarKind::LegacyParamSpec @@ -3285,25 +3844,15 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } } - fn intersect_pending_typevar_constraint( - &mut self, - bound_typevar: BoundTypeVarInstance<'db>, - bounds: ConstraintBounds<'db>, - ) { + fn intersect_pending_typevar_constraint(&mut self, constraint: Constraint<'db>) { let db = self.db; - let identity = bound_typevar.identity(self.db); - if bound_typevar.is_parameter_pack(self.db) && !self.paramspec_seen.insert(identity) { + let bound_typevar = constraint.typevar(); + let identity = bound_typevar.identity(db); + if bound_typevar.is_parameter_pack(db) && !self.paramspec_seen.insert(identity) { return; } - let constraint = ConstraintSet::constrain_typevar_with_bounds( - db, - self.env, - self.constraints, - bound_typevar, - bounds.lower, - bounds.upper, - ); + let constraint = ConstraintSet::from_constraint(db, self.env, self.constraints, constraint); self.pending.intersect(db, self.constraints, constraint); } @@ -3313,13 +3862,16 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ty: Type<'db>, ) -> bool { let db = self.db; - self.types - .get_mut(&bound_typevar) - .is_some_and(|inferred_ty| { - inferred_ty - .get_or_build(db, self.env) - .is_assignable_to(db, self.env, ty) - }) + let LegacyTypeMappings::Available(types) = &mut self.types else { + return false; + }; + + // An unsolved type variable is always compatible. + types.get_mut(&bound_typevar).is_none_or(|inferred_ty| { + inferred_ty + .get_or_build(db, self.env) + .is_assignable_to(db, self.env, ty) + }) } /// Add a type mapping for a bound typevar using the given variance to determine how the @@ -3340,58 +3892,73 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ty: Type<'db>, variance: TypeVarVariance, ) { - let bounds = match variance { - TypeVarVariance::Covariant => ConstraintBounds::new(Some(ty), None), - TypeVarVariance::Contravariant => ConstraintBounds::new(None, Some(ty)), - TypeVarVariance::Invariant => ConstraintBounds::exact(ty), + let db = self.db; + let constraint = match variance { + TypeVarVariance::Covariant => Constraint::from_evidence(bound_typevar, Some(ty), None), + TypeVarVariance::Contravariant => { + Constraint::from_evidence(bound_typevar, None, Some(ty)) + } + TypeVarVariance::Invariant => Constraint::exact(bound_typevar, ty), // a bivariant position accepts every type, so there is nothing to constrain — but the // mapping is still the type we inferred for this typevar, so remember that we have one TypeVarVariance::Bivariant => { - self.unconstrained.insert(bound_typevar.identity(self.db)); + self.unconstrained.insert(bound_typevar.identity(db)); return; } }; - self.intersect_pending_typevar_constraint(bound_typevar, bounds); + self.intersect_pending_typevar_constraint(constraint); } - /// Finds all of the valid specializations of a constraint set, and adds their type mappings to - /// the specialization that this builder is building up. - /// - /// TODO: This is a stopgap! Eventually, the builder will maintain a single constraint set for - /// the main specialization that we are building, and [`build_with`][Self::build_with] will - /// build the specialization directly from that constraint set. This method lets us migrate to - /// that brave new world incrementally, by using the new constraint set mechanism piecemeal for - /// certain type comparisons. - fn add_type_mappings_from_constraint_set( - &mut self, - set: ConstraintSet<'db, 'c>, - ) -> Result<(), ConstraintSetInferenceError<'db>> { + /// Solves one relation without recording it or changing the legacy type mappings. + fn analyze_constraint_set(&self, set: ConstraintSet<'db, 'c>) -> ConstraintSetAnalysis<'db> { let db = self.db; - let mut first_error = None; - let solutions = match set.solutions_with( + let mut failures = SmallVec::new(); + let solutions = set.solutions_with( db, self.env, - self.constraints, self.inferable, + SolutionBudget::default(), |_variance, path_bound| { let solution = - PathBounds::default_solve(db, self.env, self.constraints, path_bound); - if solution.is_err() && first_error.is_none() { - first_error = self.specialization_error_from_failed_bounds(path_bound); + PathBounds::preliminary_solve(db, self.env, self.constraints, path_bound); + if matches!(solution, PathBoundSolution::Unsatisfiable) + && let Some(failure) = self.constraint_failure_from_failed_bounds(path_bound) + { + failures.push(failure); } solution }, - ) { - Solutions::Unsatisfiable => { - return Err(first_error.map_or( - ConstraintSetInferenceError::Unsatisfiable, - ConstraintSetInferenceError::InvalidTypeVar, - )); - } - Solutions::Unconstrained => return Ok(()), - Solutions::Constrained(solutions) => solutions, + ); + + match solutions { + Ok(Solutions::Unsatisfiable) => ConstraintSetAnalysis::Unsatisfiable(failures), + Ok(Solutions::Unconstrained) => ConstraintSetAnalysis::Unconstrained, + Ok(Solutions::Constrained(solutions)) => ConstraintSetAnalysis::Constrained(solutions), + Err(_) => ConstraintSetAnalysis::BudgetExceeded, + } + } + + /// Adds available solutions, including fallback bindings, to the legacy inference mapping. + /// + /// This projection loses correlations between alternatives, so callers must only request it + /// after they have accepted the corresponding relation. + /// Omitting an accepted relation makes the legacy mapping unavailable for precise recovery. + /// + /// TODO: Remove this compatibility path once [`build_merged_with`][Self::build_merged_with] and all other + /// inference consumers can build specializations solely from the call-wide constraint set. + fn project_for_legacy_fallback(&mut self, analysis: &ConstraintSetAnalysis<'db>) { + if matches!(analysis, ConstraintSetAnalysis::BudgetExceeded) { + self.types = LegacyTypeMappings::BudgetExceeded; + return; + } + if matches!(self.types, LegacyTypeMappings::BudgetExceeded) { + return; + } + let ConstraintSetAnalysis::Constrained(solutions) = analysis else { + return; }; - for solution in solutions { + + for solution in solutions.as_slice() { for binding in solution { let solution = self.remove_inferable_typevar_artifacts_from_solution( binding.bound_typevar, @@ -3400,21 +3967,25 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { self.insert_hash_map_type_mapping(binding.bound_typevar, solution); } } - Ok(()) } - /// Returns an actionable type-variable error for a failed projected path. + /// Classifies a failed path when its lower bound violates a type-variable declaration. /// /// Conflicting inferred lower and upper bounds are not necessarily violations of the type /// variable's declaration, so they remain generic unsatisfiable constraints. - fn specialization_error_from_failed_bounds( + fn constraint_failure_from_failed_bounds( &self, path_bound: &PathBound<'db>, - ) -> Option> { + ) -> Option> { let db = self.db; let bound_typevar = path_bound.bound_typevar; - let argument = path_bound.lower?; - match bound_typevar + let argument = path_bound.evidence_lower()?; + let variance = if path_bound.has_upper_evidence() { + ConstraintFailureVariance::Invariant + } else { + ConstraintFailureVariance::Contravariant + }; + let error = match bound_typevar .typevar(db) .bound_or_constraints(db, self.env)? { @@ -3425,30 +3996,57 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { bound_typevar, argument, }), - TypeVarBoundOrConstraints::Constraints(_) => { - (!path_bound.has_upper()).then_some(SpecializationError::MismatchedConstraint { + TypeVarBoundOrConstraints::Constraints(_) => (!path_bound.has_upper_evidence()) + .then_some(SpecializationError::MismatchedConstraint { bound_typevar, argument, - }) - } - } + }), + }?; + Some(ConstraintFailure { error, variance }) + } + + /// Records one relation in the call-wide constraint set. + /// + /// Generic unsatisfiability is retained in `pending` rather than reported as a misleading + /// type-variable declaration error. + fn record_constraint_set(&mut self, when: ConstraintSet<'db, 'c>) { + self.pending.intersect(self.db, self.constraints, when); } - /// Adds legacy type mappings from `when` and records it in the call-wide constraint set. + /// Records a relation and projects its solutions into the legacy type mapping. /// - /// Generic unsatisfiability is retained in `pending`; only failures against a type variable's - /// declared bound or constraints are returned for immediate diagnosis. + /// Contextual preference checks, variadic inference, and recursive-specialization recovery + /// require the projected mapping while processing the call. fn infer_from_constraint_set( &mut self, when: ConstraintSet<'db, 'c>, ) -> Result<(), SpecializationError<'db>> { let db = self.db; - let result = self.add_type_mappings_from_constraint_set(when); - self.pending.intersect(db, self.constraints, when); - match result { - Ok(()) | Err(ConstraintSetInferenceError::Unsatisfiable) => Ok(()), - Err(ConstraintSetInferenceError::InvalidTypeVar(error)) => Err(error), + let analysis = self.analyze_constraint_set(when); + self.record_constraint_set(when); + if let Some(error) = analysis.specialization_error(db, self.env) { + return Err(error); } + self.project_for_legacy_fallback(&analysis); + Ok(()) + } + + /// Returns the assignability constraints required by this comparison's polarity. + fn constraint_for_relation( + &self, + formal: Type<'db>, + actual: Type<'db>, + polarity: TypeVarVariance, + ) -> ConstraintSet<'db, 'c> { + let db = self.db; + relation_directions(formal, actual, polarity).when_all( + db, + self.constraints, + |(source, target)| { + let when = source.when_constraint_set_assignable_to_owned(db, self.env, target); + self.constraints.load(db, self.env, &when) + }, + ) } /// Returns common protocol constraints for the `TypedDict` members of a union when every such @@ -3617,8 +4215,9 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { let mapping_when = mapping.when_constraint_set_assignable_to_owned(db, env, formal); let mapping_when = self.constraints.load(db, env, &mapping_when); // Logically equivalent constraints can still infer different solutions, such as `Any` - // instead of `object`; preserve the original constraints when gradual evidence differs. - let mapping_solutions = mapping_when.solutions(db, env, self.constraints, self.inferable); + // instead of `object`; preserve the original constraints when gradual evidence differs + // or either solution collection exceeds its budget. + let mapping_solutions = mapping_when.solutions(db, env, self.inferable).ok()?; if !typed_dicts.into_iter().all(|element| { let element_when = self.constraints.load( db, @@ -3628,8 +4227,9 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { element_when .iff(db, self.constraints, mapping_when) .is_always_satisfied(db, env) - && element_when.solutions(db, env, self.constraints, self.inferable) - == mapping_solutions + && element_when + .solutions(db, env, self.inferable) + .is_ok_and(|solutions| solutions == mapping_solutions) }) { return None; } @@ -3655,12 +4255,22 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { /// are returned for immediate diagnosis. fn infer_from_callable_signature( &mut self, - formal_signature: &CallableSignature<'db>, - actual_callables: &CallableTypes<'db>, + formal: CallableType<'db>, + actual_callables: CallableTypes<'db>, + polarity: TypeVarVariance, ) -> Result<(), SpecializationError<'db>> { let db = self.db; - let formal_is_single_paramspec = formal_signature.is_single_paramspec().is_some(); + if !matches!(polarity, TypeVarVariance::Covariant) { + let actual = actual_callables + .map(|callable| callable.into_regular(db)) + .into_type(db, self.env); + let formal = Type::Callable(formal.into_regular(db)); + let when = self.constraint_for_relation(formal, actual, polarity); + return self.infer_from_constraint_set(when); + } + let formal_signature = formal.signatures(db); + let formal_is_single_paramspec = formal_signature.is_single_paramspec().is_some(); for actual_callable in actual_callables.as_slice() { if formal_is_single_paramspec { let when = actual_callable @@ -3673,41 +4283,48 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ); self.infer_from_constraint_set(when)?; } else { - // An overloaded actual callable is compatible with the formal signature if at - // least one of its overloads is. We collect type mappings from all satisfiable - // overloads, and only report an error if none of them are satisfiable. + // An overloaded actual callable is compatible if at least one overload matches. + // Analyze every alternative without changing the builder; only accepted overloads + // contribute mappings after their combined relation has been committed. let env = self.env.clone(); let constraints = self.constraints; - let mut first_error = None; - let combined = actual_callable - .signatures(db) - .overloads - .iter() - .filter_map(|actual_signature| { - let when = actual_signature.when_constraint_set_assignable_to_signatures( - db, - &env, - formal_signature, - constraints, - ); - match self.add_type_mappings_from_constraint_set(when) { - Ok(()) => Some(when), - Err(error) => { - first_error.get_or_insert(error); - None - } + let mut first_rejection = None; + let mut accepted = + SmallVec::<[(ConstraintSet<'db, 'c>, ConstraintSetAnalysis<'db>); 1]>::new(); + for actual_signature in &actual_callable.signatures(db).overloads { + let when = actual_signature.when_constraint_set_assignable_to_signatures( + db, + &env, + formal_signature, + constraints, + ); + let analysis = self.analyze_constraint_set(when); + match analysis { + rejected @ ConstraintSetAnalysis::Unsatisfiable(_) => { + first_rejection.get_or_insert(rejected); } - }) - .reduce(|lhs, rhs| lhs.or(db, constraints, || rhs)); - let Some(combined) = combined else { - self.pending = ConstraintSet::from_bool(self.constraints, false); - if let Some(ConstraintSetInferenceError::InvalidTypeVar(error)) = first_error { - return Err(error); + analysis => accepted.push((when, analysis)), } - return Ok(()); + } + + let combined = accepted + .iter() + .map(|(when, _)| *when) + .reduce(|left, right| left.or(db, constraints, || right)); + let Some(combined) = combined else { + self.record_constraint_set(ConstraintSet::from_bool(self.constraints, false)); + return first_rejection + .and_then(|analysis| analysis.specialization_error(db, &env)) + .map_or(Ok(()), Err); }; - self.pending.intersect(db, self.constraints, combined); + + // Retain every alternative that was not proved unsatisfiable. Solving the + // combined TDD here would repeat their potentially expensive path traversals. + self.record_constraint_set(combined); + for (_, analysis) in accepted { + self.project_for_legacy_fallback(&analysis); + } } } Ok(()) @@ -3732,7 +4349,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { formal: Type<'db>, actual: Type<'db>, polarity: TypeVarVariance, - seen: &mut FxHashSet<(Type<'db>, Type<'db>)>, + seen: &mut FxHashSet<(Type<'db>, Type<'db>, TypeVarVariance)>, ) -> Result<(), SpecializationError<'db>> { let env = self.env; let db = self.db; @@ -3749,8 +4366,8 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { return Ok(()); } - // Avoid infinite recursion - if !seen.insert((formal, actual)) { + // Avoid infinite recursion while retaining comparisons under different polarities. + if !seen.insert((formal, actual, polarity)) { return Ok(()); } @@ -3759,8 +4376,49 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // // For example, if `formal` is `list[T]` and `actual` is `list[int] | None`, we want to // specialize `T` to `int`, and so ignore the `None`. - let actual = actual.filter_disjoint_elements(db, self.env, formal, self.inferable); - let formal = formal.filter_disjoint_elements(db, self.env, actual, self.inferable); + // + // Replace inferable variables with `Unknown` for this filter to avoid solving + // specialization constraints separately for each actual union member. Inference below + // uses the original formal type to determine the specialization and validate its bounds. + // + // If no elements survive, keep the original union: inferring from `Never` would discard + // its type variables and skip the bound checks that reject the argument. + let actual = if actual.resolve_type_alias(db).is_union() { + let formal = formal + .apply_specialization(db, self.generic_context.unknown_specialization(db, None)); + actual + .discard_disjoint_union_elements(db, self.env, formal, self.inferable) + .unless_all_disjoint(actual) + } else { + actual + }; + // Ignore inferable variables' bounds when deciding which formal members can match. + // `list[T]` must survive a comparison with `list[object]` even if `T: str`, so that + // inference can report the bound violation. It can still be discarded when the + // argument is `str | None`, since no specialization of `list[T]` can match it. + let disjoint_constraints = ConstraintSetBuilder::new(); + let formal = formal.filter_union(db, self.env, |element| { + !element + .apply_specialization(db, self.generic_context.unknown_specialization(db, None)) + .when_disjoint_from(db, self.env, actual, &disjoint_constraints, self.inferable) + .is_always_satisfied(db, self.env) + }); + + // ParamSpecs and TypeVarTuples still use the forward-only legacy mapping table. Keep + // their entire inference context on the existing signature path, and use forward + // structural relations so nested variadics and ordinary type variables retain their + // mappings. Preserve the original polarity for recursive and ordinary inference. + // TODO: Apply full polarity once variadics are supported by the new constraint solver. + let relation_polarity = if !polarity.is_covariant() + && self + .inferable + .iter(db) + .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)) + { + TypeVarVariance::Covariant + } else { + polarity + }; match (formal, actual) { // Expand PEP 695 type aliases in the formal type. @@ -4221,6 +4879,20 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ); } + ( + formal @ (Type::SubclassOf(_) | Type::GenericAlias(_)), + Type::ClassLiteral(_) + | Type::GenericAlias(_) + | Type::SubclassOf(_) + | Type::Union(_), + ) if formal.is_generic_alias() + || matches!(formal, Type::SubclassOf(subclass) + if matches!(subclass.subclass_of(), SubclassOfInner::Class(_))) => + { + let when = self.constraint_for_relation(formal, actual, relation_polarity); + return self.infer_from_constraint_set(when); + } + (Type::SubclassOf(subclass_of), ty) | (ty, Type::SubclassOf(subclass_of)) if let Some(type_var) = subclass_of.into_type_var() && let Some(actual_instance) = ty.to_instance_approximation(db, self.env) => @@ -4262,31 +4934,57 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { && let Some(actual_origin) = actual_protocol.materialized_origin(db) && let Some(formal_origin) = formal_protocol.class_origin(db) { - let nominally_inherited = actual_origin - .iter_mro(db) - .filter_map(ClassBase::into_class) - .any(|base| base.class_literal(db) == formal_origin.class_literal(db)); - let when = if nominally_inherited - || formal_protocol.interface(db).has_only_finite_members(db) - { - Some(actual.when_constraint_set_assignable_to_owned(db, self.env, formal)) - } else { - actual_protocol - .when_non_recursive_members_assignable_to_owned(db, formal_protocol) - .map(Cow::Borrowed) - }; - // Materialized protocols cannot be replaced by their nominal origin: doing // so would recover the original `Any` requirements. Infer from the complete // interface when doing so is cycle-safe; otherwise use its nonrecursive // requirements and leave full recursive compatibility to argument checking. + let when = relation_directions( + (formal_protocol, formal_origin), + (actual_protocol, actual_origin), + relation_polarity, + ) + .try_fold( + ConstraintSet::from_bool(self.constraints, true), + |mut combined, ((source, source_origin), (target, target_origin))| { + if combined.is_trivially_never_satisfied() { + return Some(combined); + } + + let when = if source_origin + .is_subtype_of_class_literal(db, target_origin.class_literal(db)) + || target.interface(db).has_only_finite_members(db) + { + Type::ProtocolInstance(source) + .when_constraint_set_assignable_to_owned( + db, + self.env, + Type::ProtocolInstance(target), + ) + } else { + Cow::Borrowed( + source.when_non_recursive_members_assignable_to_owned( + db, target, + )?, + ) + }; + let next = self.constraints.load(db, self.env, &when); + Some(combined.intersect(db, self.constraints, next)) + }, + ); + if let Some(when) = when { - let when = self.constraints.load(db, self.env, &when); - self.infer_from_constraint_set(when)?; - return Ok(()); + return self.infer_from_constraint_set(when); } } + // Converting the actual protocol to its nominal origin makes the reversed + // comparison impossible: a protocol cannot be assignable to a nominal class. + if matches!(formal, Type::ProtocolInstance(_)) && !relation_polarity.is_covariant() + { + let when = self.constraint_for_relation(formal, actual, relation_polarity); + return self.infer_from_constraint_set(when); + } + // TODO: This will only handle protocol classes that explicit inherit // from other generic protocol classes by listing it as a base class. // To handle classes that implicitly implement a generic protocol, we @@ -4302,6 +5000,20 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } } + // Each alternative in a tuple union contributes constraints. The element-wise + // tuple path below only accepts a single actual tuple, while the constraint solver + // handles the entire union. Variadic type parameters still require legacy inference. + (formal @ Type::NominalInstance(formal_instance), actual @ Type::Union(_)) + if formal_instance.tuple_spec(db, self.env).is_some() + && !self + .inferable + .iter(db) + .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)) => + { + let when = self.constraint_for_relation(formal, actual, relation_polarity); + return self.infer_from_constraint_set(when); + } + // Special case: `formal` and `actual` are both tuples. (Type::NominalInstance(formal), Type::NominalInstance(actual)) if let Some(formal_tuple) = formal.tuple_spec(db, self.env) @@ -4393,71 +5105,50 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { return Ok(()); } + // basedpython: an actual carrying a divergence marker reads a protocol formal + // structurally, off the actual's own MRO, instead of through the constraint set. + // + // The constraint set reasons about a gradual source through its materializations, and + // a marker's bottom materialization is `Never`. So `Iterable[T]` solved against + // `list[Divergent]` records `Never ≤ T` and the marker is gone, while the same + // parameter declared `list[T]` — read structurally below — keeps it. That is the + // difference between + // + // ```python + // def h(n: int): + // if n: + // return "a" + // t = set([h(n)]) + // return "b" + next(iter(t)) + // ``` + // + // settling on `str | Unknown` and settling on `str`: `set.__init__` takes an + // `Iterable`, so the round that still has only the marker to go on solves the element + // to `Never`, and the `Unknown` that follows from it is recorded as the element type + // of the `[h(n)]` literal — a query of its own, which the return type's early-round + // discard never revisits. + // + // Only a protocol the actual's MRO actually lists can be read this way; an implicitly + // implemented one still goes through the constraint set below. ( formal @ (Type::NominalInstance(_) | Type::ProtocolInstance(_)), Type::NominalInstance(actual_nominal), - ) => { - // basedpython: an actual carrying a divergence marker reads a protocol formal - // structurally, off the actual's own MRO, instead of through the constraint set. - // - // The constraint set reasons about a gradual source through its materializations, - // and a marker's bottom materialization is `Never`. So `Iterable[T]` solved - // against `list[Divergent]` records `Never ≤ T` and the marker is gone, while the - // same parameter declared `list[T]` — read structurally below — keeps it. That is - // the difference between - // - // ```python - // def h(n: int): - // if n: - // return "a" - // t = set([h(n)]) - // return "b" + next(iter(t)) - // ``` - // - // settling on `str | Unknown` and settling on `str`: `set.__init__` takes an - // `Iterable`, so the round that still has only the marker to go on solves the - // element to `Never`, and the `Unknown` that follows from it is recorded as the - // element type of the `[h(n)]` literal — a query of its own, which the return - // type's early-round discard never revisits. - // - // Only a protocol the actual's MRO actually lists can be read this way; an - // implicitly implemented one still goes through the constraint set below. + ) if matches!(formal, Type::NominalInstance(_)) + || (matches!(formal, Type::ProtocolInstance(_)) + && any_over_type(db, self.env, actual, false, |ty| ty.is_divergent())) => + { let structural_protocol = match formal { - Type::ProtocolInstance(formal_protocol) - if any_over_type(db, self.env, actual, false, |ty| ty.is_divergent()) => - { - formal_protocol - .nominal_origin_instance(db) - .and_then(|nominal| nominal.class(db, self.env).into_generic_alias()) - } + Type::ProtocolInstance(formal_protocol) => formal_protocol + .nominal_origin_instance(db) + .and_then(|nominal| nominal.class(db, self.env).into_generic_alias()), _ => None, }; - - // Extract formal_alias if this is a generic class let formal_alias = match formal { Type::NominalInstance(formal_nominal) => { formal_nominal.class(db, self.env).into_generic_alias() } - - Type::ProtocolInstance(_) if structural_protocol.is_some() => { - structural_protocol - } - - Type::ProtocolInstance(_) => { - // TODO: For protocols, we use the new constraint set implementation, which - // will handle implicitly implemented protocols and generic protocols. We - // eventually want this logic to be used for _all_ nominal instances - // (replacing the logic below). - let when = - actual.when_constraint_set_assignable_to_owned(db, self.env, formal); - let when = self.constraints.load(db, self.env, &when); - self.infer_from_constraint_set(when)?; - return Ok(()); - } - - _ => None, + _ => structural_protocol, }; - if let Some(formal_alias) = formal_alias { let formal_origin = formal_alias.origin(db); for base in actual_nominal.class(db, self.env).iter_mro(db) { @@ -4478,7 +5169,8 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { formal_specialization, base_specialization ) { - let variance = typevar.variance_with_polarity(db, polarity); + let variance = typevar + .solving_variance_with_polarity(db, self.env, polarity, *formal_ty); self.infer_map_impl(*formal_ty, *base_ty, variance, seen)?; } return Ok(()); @@ -4487,7 +5179,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // The actual does not list this protocol in its MRO, so the structural read had // nothing to descend into; the constraint set is still the only way to relate them - if structural_protocol.is_some() { + if matches!(formal, Type::ProtocolInstance(_)) { let when = actual.when_constraint_set_assignable_to_owned(db, self.env, formal); let when = self.constraints.load(db, self.env, &when); self.infer_from_constraint_set(when)?; @@ -4495,100 +5187,30 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } } - // TODO: in principle this could be a generalized Union-actual arm that maps over the - // union, but the old solver isn't well-equipped to handle that (due to side effects - // from even failed matches), so for now we handle this particular case. - (formal @ Type::ProtocolInstance(_), actual @ Type::Union(actual_union)) => { - let when = self - .common_typed_dict_protocol_constraints(formal, actual_union) - .unwrap_or_else(|| { - actual.when_constraint_set_assignable_to( - db, - self.env, - formal, - self.constraints, - ) - }); - self.infer_from_constraint_set(when)?; - return Ok(()); - } - - (formal @ Type::ProtocolInstance(_), actual @ Type::TypedDict(_)) => { - let when = actual.when_constraint_set_assignable_to_owned(db, self.env, formal); - let when = self.constraints.load(db, self.env, &when); - self.infer_from_constraint_set(when)?; - return Ok(()); - } - - // basedpython: an argument that is the cycle's own divergence marker solves the - // formal's typevars to the marker, rather than leaving them unsolved. - // - // A marker is not a value nothing is known about — it stands for a type the - // fixed-point iteration has not finished computing, and it is the one thing cycle - // recovery folds on. Left unsolved, the typevars default to `Unknown`, so the call - // hands back `Unknown` and the marker is gone: - // - // ```python - // def h(n: int): - // if n: - // return "a" - // t = list([h(n)]) - // return "b" + next(iter(t)) - // ``` - // - // In the round where `t`'s own definition is still being computed, `t` reads as the - // bare marker, so `iter(t)` and `next(...)` are calls on it. Answering `Unknown` for - // those records that `Unknown` as the element type of the `[h(n)]` literal — a query - // of its own, which the return type's early-round discard never revisits — and every - // later round reads it back, so the recursion settles on `str | Unknown` rather than - // `str`. - // - // The marker materializes to the same gradual type the unsolved default would have - // produced. What changes is that it survives the call. - (_, Type::Divergent(_)) if formal.has_typevar(db, env) => { - let formal_typevars = Cell::new(Vec::new()); - any_over_type(db, self.env, formal, false, |ty| { - if let Type::TypeVar(bound_typevar) = ty - && bound_typevar.is_inferable(db, self.inferable) - { - let mut collected = formal_typevars.take(); - collected.push(bound_typevar); - formal_typevars.set(collected); - } - false - }); - for bound_typevar in formal_typevars.into_inner() { - self.add_type_mapping(bound_typevar, actual, polarity); - } - return Ok(()); - } - - // When the formal type is a protocol with a `__call__` method, infer the specialization - // from matching the actual type's callable signature against the protocol's `__call__` - // method signature. - (Type::ProtocolInstance(formal_protocol), _) => { - let Some(call_method) = formal_protocol.interface(db).call_method(db, self.env) - else { - return Ok(()); - }; - let Some(actual_callables) = actual.try_upcast_to_callable(db, self.env) else { - return Ok(()); + (formal @ Type::ProtocolInstance(_), actual) => { + // Common TypedDict constraints prove only `actual <= formal`. Contravariance + // reverses that relation, while invariance additionally requires the reverse. + let when = if let Type::Union(actual_union) = actual + && matches!(relation_polarity, TypeVarVariance::Covariant) + && let Some(common) = + self.common_typed_dict_protocol_constraints(formal, actual_union) + { + common + } else { + self.constraint_for_relation(formal, actual, relation_polarity) }; - - // The protocol interface exposes the callable signature already bound for - // instance access. - let formal_signature = call_method.signatures(db); - - self.infer_from_callable_signature(formal_signature, &actual_callables)?; + return self.infer_from_constraint_set(when); } (Type::Callable(formal_callable), _) => { let Some(actual_callables) = actual.try_upcast_to_callable(db, self.env) else { return Ok(()); }; - let formal_signature = formal_callable.signatures(db); - - self.infer_from_callable_signature(formal_signature, &actual_callables)?; + self.infer_from_callable_signature( + formal_callable, + actual_callables, + relation_polarity, + )?; } // TODO: Add more forms that we can structurally induct into: type[C], callables @@ -4641,35 +5263,593 @@ impl<'db> SpecializationError<'db> { mod tests { use super::*; + use ruff_db::files::system_path_to_file; + use ruff_db::system::DbWithWritableSystem; use ruff_python_ast::name::Name; + use ty_python_core::ProgramFile; + + use crate::db::tests::{TestDb, setup_db}; + use crate::place::global_symbol; + + fn create_typevars<'db, const N: usize>( + db: &'db TestDb, + names: [&'static str; N], + ) -> [BoundTypeVarInstance<'db>; N] { + let env = db.program_environment(); + names.map(|name| { + BoundTypeVarInstance::synthetic( + db, + &env, + Name::new_static(name), + TypeVarVariance::Invariant, + ) + }) + } + + fn exact_alternatives<'db, 'c, const N: usize>( + db: &'db TestDb, + constraints: &'c ConstraintSetBuilder<'db>, + typevars: [BoundTypeVarInstance<'db>; N], + alternatives: impl IntoIterator; N]>, + ) -> ConstraintSet<'db, 'c> { + let env = db.program_environment(); + alternatives.into_iter().when_any(db, constraints, |types| { + typevars + .into_iter() + .zip(types) + .when_all(db, constraints, |(typevar, ty)| { + ConstraintSet::constrain_typevar(db, &env, constraints, typevar, ty, ty) + }) + }) + } + + #[test] + fn inference_preserves_correlated_alternatives() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevars = create_typevars(db, ["T", "U"]); + let context = GenericContext::from_typevar_instances(db, &env, typevars); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let union = UnionType::from_two_elements(db, &env, int, str); + + for alternatives in [[[int, str], [str, int]], [[str, int], [int, str]]] { + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + builder.record_constraint_set(exact_alternatives( + db, + &constraints, + typevars, + alternatives, + )); + + let inference = builder + .build_inference_with(|_, _| None) + .map_err(|()| anyhow::anyhow!("expected satisfiable alternatives"))?; + let TypeVarInferenceSolutions::Alternatives(paths) = inference.solutions(db) else { + anyhow::bail!( + "expected complete alternatives, got {:?}", + inference.solutions(db) + ); + }; + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + FxHashSet::from_iter([ + [Some(int), Some(str)].as_slice(), + [Some(str), Some(int)].as_slice(), + ]) + ); + + // Only the explicit merged projection admits the crossed pairings. + for ty in inference.merged_specialization(db).types(db) { + assert!(ty.is_equivalent_to(db, &env, union)); + } + } + Ok(()) + } + + #[test] + fn incomplete_inference_preserves_complete_siblings() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevars @ [t, _] = create_typevars(db, ["T", "U"]); + let context = GenericContext::from_typevar_instances(db, &env, typevars); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let union = UnionType::from_two_elements(db, &env, int, str); + + for alternatives in [[[int, str], [str, int]], [[str, int], [int, str]]] { + for fallback in [Some(str), None] { + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + builder.record_constraint_set(exact_alternatives( + db, + &constraints, + typevars, + alternatives, + )); + + let inference = builder + .build_inference_with(|typevar, bounds| { + (typevar == t + && bounds.is_some_and(|bound| bound.evidence_lower() == Some(str))) + .then_some(PathBoundSolution::BudgetExceeded { fallback }) + }) + .map_err(|()| anyhow::anyhow!("incomplete alternatives remain satisfiable"))?; + let TypeVarInferenceSolutions::Incomplete(paths) = inference.solutions(db) else { + anyhow::bail!( + "expected incomplete alternatives, got {:?}", + inference.solutions(db) + ); + }; + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + FxHashSet::from_iter([ + [Some(int), Some(str)].as_slice(), + [fallback, Some(int)].as_slice(), + ]) + ); + + let expected = [if fallback.is_some() { union } else { int }, union]; + for (ty, expected) in inference + .merged_specialization(db) + .types(db) + .iter() + .zip(expected) + { + assert!(ty.is_equivalent_to(db, &env, expected)); + } + } + } + Ok(()) + } + + #[test] + fn single_inference_defers_unsolved_defaults() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevars @ [t, u] = create_typevars(db, ["T", "U"]); + let context = GenericContext::from_typevar_instances(db, &env, typevars); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + builder.record_constraint_set(ConstraintSet::constrain_typevar( + db, + &env, + &constraints, + t, + int, + int, + )); + + // A complete single solution reuses the merged array, so its unsolved slots do not + // require any additional storage budget. + let inference = builder + .solve_pending_with( + SolutionBudget { + type_terms: 1, + ..SolutionBudget::default() + }, + &mut |_, _| None, + ) + .map_err(|()| anyhow::anyhow!("expected a single solution"))?; + assert_eq!(inference.solutions(db), &TypeVarInferenceSolutions::Single); + assert_eq!(inference.merged_types(db), [Some(int), None]); + assert_eq!( + inference.merged_specialization(db).types(db), + [int, Type::unknown()] + ); + + let specialization = inference.merged_specialization_with(db, |typevar, inferred| { + (typevar == u && inferred.is_none()).then_some(str) + }); + assert_eq!(specialization.types(db), [int, str]); + Ok(()) + } + + #[test] + fn recovery_inference_is_not_a_complete_solution() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let [t] = create_typevars(db, ["T"]); + let context = GenericContext::from_typevar_instances(db, &env, [t]); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + + let unconstrained = builder + .build_inference_with(|_, _| None) + .map_err(|()| anyhow::anyhow!("unconstrained inference should recover"))?; + assert_eq!( + unconstrained.solutions(db), + &TypeVarInferenceSolutions::Unavailable(TypeVarInferenceFallback::Unconstrained) + ); + assert_eq!(unconstrained.merged_types(db), [None]); - use crate::db::tests::setup_db; + for ty in [int, str] { + builder.record_constraint_set(ConstraintSet::constrain_typevar( + db, + &env, + &constraints, + t, + ty, + ty, + )); + } + assert!(builder.build_inference_with(|_, _| None).is_err()); + + let diagnostic = builder.build_diagnostic_inference_with( + [(Type::TypeVar(t), int), (Type::TypeVar(t), str)], + |_, _| None, + ); + assert_eq!( + diagnostic.solutions(db), + &TypeVarInferenceSolutions::Unavailable(TypeVarInferenceFallback::Unsatisfiable) + ); + assert_eq!( + diagnostic.merged_types(db), + [Some(UnionType::from_two_elements(db, &env, int, str))] + ); + Ok(()) + } + + #[test] + fn inference_budget_exhaustion_discards_partial_mappings() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let [t] = create_typevars(db, ["T"]); + let context = GenericContext::from_typevar_instances(db, &env, [t]); + let constraints = ConstraintSetBuilder::new(); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let relation = [int, str].into_iter().when_any(db, &constraints, |ty| { + ConstraintSet::constrain_typevar(db, &env, &constraints, t, ty, ty) + }); + + for (budget, expected_choices) in [ + ( + SolutionBudget { + paths: 1, + ..SolutionBudget::default() + }, + 0, + ), + ( + SolutionBudget { + visits: 0, + ..SolutionBudget::default() + }, + 0, + ), + ( + SolutionBudget { + type_terms: 1, + ..SolutionBudget::default() + }, + 2, + ), + ] { + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + builder.record_constraint_set(relation); + let mut choices = 0; + let inference = builder + .solve_pending_with(budget, &mut |_, _| { + choices += 1; + None + }) + .map_err(|()| anyhow::anyhow!("budget exhaustion should recover"))?; + + assert_eq!(choices, expected_choices); + assert_eq!( + inference.solutions(db), + &TypeVarInferenceSolutions::Unavailable(TypeVarInferenceFallback::BudgetExceeded) + ); + assert_eq!(inference.merged_types(db), [Some(Type::unknown())]); + } + Ok(()) + } #[test] - fn generic_context_inferable_typevars_retain_instances_from_bounds() { + fn alternative_storage_budget_preserves_merged_inference() -> anyhow::Result<()> { let db = setup_db(); let db = &db; let env = db.program_environment(); - let u = BoundTypeVarInstance::synthetic( + let [t, unused] = create_typevars(db, ["T", "Unused"]); + // The context order differs from the variable creation order, and the first slot + // remains unsolved on both alternatives. + let context = GenericContext::from_typevar_instances(db, &env, [unused, t]); + let constraints = ConstraintSetBuilder::new(); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let relation = [int, str].into_iter().when_any(db, &constraints, |ty| { + ConstraintSet::constrain_typevar(db, &env, &constraints, t, ty, ty) + }); + + // Both budgets allow solving the two present bindings, but storing the complete + // alternatives requires four slots, including the unsolved ones. + for type_terms in [2, 4] { + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + builder.record_constraint_set(relation); + let inference = builder + .solve_pending_with( + SolutionBudget { + type_terms, + ..SolutionBudget::default() + }, + &mut |_, _| None, + ) + .map_err(|()| anyhow::anyhow!("alternative storage exhaustion should recover"))?; + + assert_eq!( + inference.merged_types(db), + [None, Some(UnionType::from_two_elements(db, &env, int, str))] + ); + if type_terms == 2 { + assert_eq!( + inference.solutions(db), + &TypeVarInferenceSolutions::Unavailable( + TypeVarInferenceFallback::BudgetExceeded + ) + ); + } else { + let TypeVarInferenceSolutions::Alternatives(paths) = inference.solutions(db) else { + anyhow::bail!("expected alternatives within the storage budget"); + }; + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + FxHashSet::from_iter([ + [None, Some(int)].as_slice(), + [None, Some(str)].as_slice(), + ]) + ); + } + } + Ok(()) + } + + #[test] + fn inference_cleans_merged_types_independently_of_alternatives() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented("/src/a.py", "def f[T, U](): ...")?; + let db = &db; + let env = db.program_environment(); + let file = system_path_to_file(db, "/src/a.py")?; + let file = ProgramFile::new(db, file, env.program(db)); + let context = global_symbol(db, file, "f") + .place + .expect_type() + .as_function_literal() + .and_then(|function| function.signature(db).overloads.first()?.generic_context) + .ok_or_else(|| anyhow::anyhow!("expected a generic function"))?; + let (t, u) = context + .variables(db) + .collect_tuple() + .ok_or_else(|| anyhow::anyhow!("expected two type variables"))?; + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + builder.record_constraint_set([str, int].into_iter().when_any(db, &constraints, |ty| { + ConstraintSet::constrain_typevar(db, &env, &constraints, t, ty, ty) + })); + + let inference = builder + .build_inference_with(|typevar, bounds| { + (typevar == t && bounds.is_some_and(|bound| bound.evidence_lower() == Some(str))) + .then_some(PathBoundSolution::Solved(Type::TypeVar(u))) + }) + .map_err(|()| anyhow::anyhow!("expected satisfiable alternatives"))?; + let TypeVarInferenceSolutions::Alternatives(paths) = inference.solutions(db) else { + anyhow::bail!( + "expected complete alternatives, got {:?}", + inference.solutions(db) + ); + }; + + // A bare U is preserved in its own alternative, but removed from the merged U | int. + assert_eq!( + paths.iter().map(AsRef::as_ref).collect::>(), + FxHashSet::from_iter([ + [Some(Type::TypeVar(u)), None].as_slice(), + [Some(int), None].as_slice(), + ]) + ); + assert_eq!(inference.merged_types(db), [Some(int), None]); + Ok(()) + } + + #[test] + fn inference_detects_expanding_cycles_hidden_by_merging() -> anyhow::Result<()> { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevars @ [t, u] = create_typevars(db, ["T", "U"]); + let context = GenericContext::from_typevar_instances(db, &env, typevars); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let list_of_u = KnownClass::List.to_specialized_instance(db, &env, &[Type::TypeVar(u)]); + builder.record_constraint_set(exact_alternatives( + db, + &constraints, + typevars, + [[int, str], [str, int]], + )); + + // Select T = list[U], U = T on one path and T = U = object on the other. Only the + // individual path still contains the cycle after merging with object. + let inference = builder + .build_inference_with(|typevar, bounds| { + let ty = match (typevar, bounds?.evidence_lower()) { + (typevar, Some(lower)) if typevar == t && lower == int => list_of_u, + (typevar, Some(lower)) if typevar == u && lower == str => Type::TypeVar(t), + _ => Type::object(), + }; + Some(PathBoundSolution::Solved(ty)) + }) + .map_err(|()| anyhow::anyhow!("an expanding cycle should recover"))?; + assert_eq!( + inference.solutions(db), + &TypeVarInferenceSolutions::Unavailable(TypeVarInferenceFallback::ExpandingCycle) + ); + assert_eq!( + inference.merged_types(db), + [Some(Type::object()), Some(Type::object())] + ); + Ok(()) + } + + #[test] + fn recording_constraints_does_not_project_legacy_mappings() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevar = BoundTypeVarInstance::synthetic( db, &env, - Name::new_static("U"), + Name::new_static("T"), TypeVarVariance::Invariant, ); - let t = BoundTypeVarInstance::synthetic( + let context = GenericContext::from_typevar_instances(db, &env, [typevar]); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let int = KnownClass::Int.to_instance(db, &env); + let set = ConstraintSet::constrain_typevar(db, &env, &constraints, typevar, int, int); + + let analysis = builder.analyze_constraint_set(set); + assert!(matches!(&builder.types, LegacyTypeMappings::Available(types) if types.is_empty())); + assert!(builder.pending.is_always_satisfied(db, &env)); + + builder.record_constraint_set(set); + assert!(matches!(&builder.types, LegacyTypeMappings::Available(types) if types.is_empty())); + assert!(!builder.pending.is_always_satisfied(db, &env)); + + builder.project_for_legacy_fallback(&analysis); + assert!(builder.inferred_type_is_assignable_to(typevar.identity(db), int)); + } + + #[test] + fn exhausted_projection_keeps_legacy_mapping_unavailable() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevar = BoundTypeVarInstance::synthetic( db, &env, Name::new_static("T"), TypeVarVariance::Invariant, - ) - .map_bound_or_constraints(db, |_| { - Some(TypeVarBoundOrConstraints::UpperBound(Type::TypeVar(u))) + ); + let context = GenericContext::from_typevar_instances(db, &env, [typevar]); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let str = KnownClass::Str.to_instance(db, &env); + + builder.add_type_mapping(typevar, str, TypeVarVariance::Covariant); + let ty = UnionType::from_two_elements(db, &env, str, Type::int_literal(0)); + let relation = ConstraintSet::constrain_typevar(db, &env, &constraints, typevar, ty, ty); + builder.record_constraint_set(relation); + builder.project_for_legacy_fallback(&ConstraintSetAnalysis::BudgetExceeded); + builder.add_type_mapping(typevar, str, TypeVarVariance::Covariant); + assert!(matches!(builder.types, LegacyTypeMappings::BudgetExceeded)); + assert!(!builder.pending.is_never_satisfied(db, &env)); + + let mut choices = 0; + let types = builder.solve_hash_map_with(context, &mut |_, _| { + choices += 1; + Some(str) }); - let context = GenericContext::from_typevar_instances(db, &env, [t]); + assert_eq!(choices, 0); + assert_eq!( + types, + FxHashMap::from_iter([(typevar.identity(db), Type::unknown())]) + ); + } + + #[test] + fn satisfiable_constraint_analysis_discards_rejected_paths() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let int = KnownClass::Int.to_instance(db, &env); + let str = KnownClass::Str.to_instance(db, &env); + let typevar = BoundTypeVarInstance::synthetic( + db, + &env, + Name::new_static("T"), + TypeVarVariance::Invariant, + ) + .map_bound_or_constraints(db, |_| Some(TypeVarBoundOrConstraints::UpperBound(int))); + let context = GenericContext::from_typevar_instances(db, &env, [typevar]); + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, &env, &constraints, context); + let lower_only = + ConstraintSet::constrain_typevar_lower_bound(db, &env, &constraints, typevar, str); + let rejected = ConstraintSet::constrain_typevar(db, &env, &constraints, typevar, str, str); + let accepted = ConstraintSet::constrain_typevar(db, &env, &constraints, typevar, int, int); + + for (set, variance) in [ + (lower_only, ConstraintFailureVariance::Contravariant), + (rejected, ConstraintFailureVariance::Invariant), + ] { + assert!(matches!( + builder.analyze_constraint_set(set), + ConstraintSetAnalysis::Unsatisfiable(failures) + if matches!(failures.as_slice(), [failure] if failure.variance == variance) + )); + } + + let analysis = builder.analyze_constraint_set(rejected.or(db, &constraints, || accepted)); + assert!(analysis.specialization_error(db, &env).is_none()); + + builder.project_for_legacy_fallback(&analysis); + assert!(builder.inferred_type_is_assignable_to(typevar.identity(db), int)); + assert!(!builder.inferred_type_is_assignable_to(typevar.identity(db), str)); + } + + #[test] + fn constraint_failure_diagnostics_preserve_variance() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let typevar = BoundTypeVarInstance::synthetic( + db, + &env, + Name::new_static("T"), + TypeVarVariance::Invariant, + ); + let int = KnownClass::Int.to_instance(db, &env); + let bool = KnownClass::Bool.to_instance(db, &env); + let analysis = |variance, first, second| { + ConstraintSetAnalysis::Unsatisfiable( + [first, second] + .into_iter() + .map(|argument| ConstraintFailure { + error: SpecializationError::MismatchedBound { + bound_typevar: typevar, + argument, + }, + variance, + }) + .collect(), + ) + }; + + let contravariant = analysis(ConstraintFailureVariance::Contravariant, int, bool) + .specialization_error(db, &env) + .map(|error| error.argument_type()); + assert_eq!(contravariant, Some(bool)); - let inferable = context.inferable_typevars(db); - assert_eq!(inferable.iter(db).collect::>(), [t, u]); - assert!(t.is_inferable(db, inferable)); - assert!(u.is_inferable(db, inferable)); + let invariant = analysis(ConstraintFailureVariance::Invariant, int, bool) + .specialization_error(db, &env) + .map(|error| error.argument_type()); + assert_eq!(invariant, Some(int)); } } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 485523dc96..26dfe720ea 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use crate::FxIndexSet; use std::ops::ControlFlow; @@ -27,12 +27,11 @@ use crate::types::signatures::{ ParameterKind, ParametersKind, ReturnCallableTypeVarScope, Signature, }; use crate::types::{ - CallDunderError, CallableTypes, ClassBase, ClassLiteral, ClassType, KnownClass, KnownFunction, - KnownUnion, PropertyAccessorRole, SpecialFormType, SubclassOfInner, Type, TypeContext, - TypeVarBoundOrConstraints, TypeVarVariance, binding_type, + CallDunderError, CallableTypes, ClassBase, ClassLiteral, KnownClass, KnownFunction, KnownUnion, + PropertyAccessorRole, SpecialFormType, Type, TypeContext, TypeVarBoundOrConstraints, + TypeVarVariance, binding_type, }; use crate::{Db, HasDefinition, HasType, ProgramEnvironment, SemanticModel}; -use itertools::Either; use ruff_db::files::{File, FileRange}; use ruff_db::parsed::parsed_module; use ruff_db::source::source_text; @@ -43,18 +42,21 @@ use rustc_hash::FxHashSet; use ty_module_resolver::{ ImportingFile, Module, ModuleName, ResolverFile, resolve_module_confident, }; -use ty_python_core::definition::{Definition, DefinitionKind, NestedBindingExecution}; +use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::scope::FileScopeId; -use ty_python_core::{ProgramFile, attribute_scopes, global_scope, semantic_index, use_def_map}; +use ty_python_core::{ProgramFile, attribute_scopes, semantic_index, use_def_map}; mod data_flow; mod unreachable_code; #[path = "ide_support/unused_bindings.rs"] mod unused_binding_support; +use crate::types::definition_resolution::{ + self, find_symbol_in_scope, resolve_definition, user_visible_definitions, +}; +pub use crate::types::definition_resolution::{ImportAliasResolution, ResolvedDefinition}; pub use data_flow::{ConditionVerdict, DataFlow, ValueVerdict, data_flow}; -pub use resolve_definition::{ImportAliasResolution, ResolvedDefinition, map_stub_definition}; -use resolve_definition::{find_symbol_in_scope, resolve_definition}; +pub use stub_mapping::map_stub_definition; pub use unreachable_code::{UnreachableKind, UnreachableRange, unreachable_ranges}; pub use unused_binding_support::{UnusedBinding, unused_bindings}; @@ -78,8 +80,7 @@ pub fn definition_for_name<'db>( None } -/// Returns all definitions for a name. If any definitions are imports, they -/// are resolved (recursively) to the original definitions or module files. +/// Returns definitions for IDE navigation, expanding numeric annotations to their accepted classes. pub fn definitions_for_name<'db>( model: &SemanticModel<'db>, name_str: &str, @@ -88,11 +89,9 @@ pub fn definitions_for_name<'db>( ) -> Vec> { let db = model.db(); let env = model.program_environment(); - let file = model.program_file(); - let index = semantic_index(db, file); // Get the scope for this name expression - let Some(file_scope) = model.scope(node) else { + let Some(scope) = model.scope(node) else { return vec![]; }; @@ -100,160 +99,63 @@ pub fn definitions_for_name<'db>( // receiver, the receiver outranks every binding outside the block, so it is // asked first — walking the scopes would otherwise land on the binding the // checker did *not* resolve the name to - if let Some(receiver) = block_receiver_definitions(model, node, file_scope, name_str) { + if let Some(receiver) = block_receiver_definitions(model, node, scope, name_str) { return receiver; } - let mut all_definitions = FxIndexSet::default(); - - // Search through the scope hierarchy: start from the current scope and - // traverse up through parent scopes to find definitions - for (scope_id, _scope) in index.visible_ancestor_scopes(file_scope) { - let place_table = index.place_table(scope_id); - - let Some(symbol_id) = place_table.symbol_id(name_str) else { - continue; // Name not found in this scope, try parent scope - }; - - let use_def_map = index.use_def_map(scope_id); - - // Check if this place is marked as global or nonlocal - let place_expr = place_table.symbol(symbol_id); - let is_global = place_expr.is_global(); - let is_nonlocal = place_expr.is_nonlocal(); - - if is_global || is_nonlocal { - // Assignments in a forwarding scope remain valid navigation targets, including eager - // walrus bindings exported from comprehensions. - all_definitions.extend(user_visible_definitions( - db, - use_def_map - .reachable_symbol_bindings(symbol_id) - .filter_map(|binding| binding.binding.definition()) - .filter(|definition| match definition.kind(db) { - DefinitionKind::NamedExpression(_) => true, - DefinitionKind::NestedBindings(nested) => { - nested.execution == NestedBindingExecution::Eager - } - _ => false, - }), - )); - } - - // TODO: The current algorithm doesn't return definitions or bindings - // for other scopes that are outside of this scope hierarchy that target - // this name using a nonlocal or global binding. The semantic analyzer - // doesn't appear to track these in a way that we can easily access - // them from here without walking all scopes in the module. - - // If marked as global, skip to global scope - if is_global { - let global_scope_id = global_scope(db, file); - let global_place_table = ty_python_core::place_table(db, global_scope_id); - - if let Some(global_symbol_id) = global_place_table.symbol_id(name_str) { - let global_use_def_map = ty_python_core::use_def_map(db, global_scope_id); - all_definitions.extend(user_visible_definitions( - db, - global_use_def_map - .reachable_symbol_bindings(global_symbol_id) - .filter_map(|binding| binding.binding.definition()) - .chain( - global_use_def_map - .reachable_symbol_declarations(global_symbol_id) - .filter_map(|declaration| declaration.declaration.definition()), - ), - )); - } - break; - } - - // If marked as nonlocal, skip current scope and search in ancestor scopes - if is_nonlocal { - // Continue searching in parent scopes, but skip the current scope - continue; - } - - // Get all definitions (both bindings and declarations) for this place - all_definitions.extend(user_visible_definitions( - db, - use_def_map - .reachable_symbol_bindings(symbol_id) - .filter_map(|binding| binding.binding.definition()) - .chain( - use_def_map - .reachable_symbol_declarations(symbol_id) - .filter_map(|declaration| declaration.declaration.definition()), - ), - )); - - // If we found definitions in this scope, we can stop searching - if !all_definitions.is_empty() { - break; - } - } - - // Resolve import definitions to their targets - let mut resolved_definitions = Vec::new(); - - for definition in &all_definitions { - let resolved = resolve_definition(db, &env, *definition, Some(name_str), alias_resolution); - resolved_definitions.extend(resolved); + let scope = scope.to_scope_id(db, model.program_file()); + let definitions = + definition_resolution::scoped_definitions_for_name(db, scope, name_str, alias_resolution); + if !definitions.is_empty() { + return definitions; } // If we didn't find any definitions in scopes, fallback to builtins - if resolved_definitions.is_empty() - && let Some(builtins_scope) = implicit_builtins_symbol_scope(db, &env, name_str) + let Some(builtins_scope) = implicit_builtins_symbol_scope(db, &env, name_str) else { + // basedpython: a name with no import behind it is not always a builtin — + // `Character` is `ty_extensions.Character` — so the implicit-name table is + // asked before giving up + return implicit_name_definitions(db, &env, model, node, name_str); + }; + // Special cases for `float` and `complex` in type annotation positions. + // We don't know whether we're in a type annotation position, so we'll just ask `Name`'s type, + // which resolves to `int | float` or `int | float | complex` if `float` or `complex` is used in + // a type annotation position and `float` or `complex` otherwise. + // + // https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex + // + // Only numeric builtins need expression inference to distinguish annotations from runtime values. + if matches!(name_str, "float" | "complex") + && let Some(expr) = node.expr_name() + && let Some(ty) = expr.inferred_type(model) + && let Some(union) = ty.as_union() + && matches!( + (name_str, union.known(db)), + ("float", Some(KnownUnion::Float)) | ("complex", Some(KnownUnion::Complex)) + ) { - // Special cases for `float` and `complex` in type annotation positions. - // We don't know whether we're in a type annotation position, so we'll just ask `Name`'s type, - // which resolves to `int | float` or `int | float | complex` if `float` or `complex` is used in - // a type annotation position and `float` or `complex` otherwise. - // - // https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex - if let Some(expr) = node.expr_name() - && let Some(ty) = expr.inferred_type(model) - && let Some(union) = ty.as_union() - && matches!( - (name_str, union.known(db)), - ("float", Some(KnownUnion::Float)) | ("complex", Some(KnownUnion::Complex)) - ) - { - return union - .elements(db) - .iter() - // Use `rev` so that `complex` and `float` come first. - // This is required for hover to pick up the docstring of `complex` and `float` - // instead of `int` (hover only shows the docstring of the first definition). - .rev() - .filter_map(|ty| ty.as_nominal_instance()) - .filter_map(|instance| { - let definition = instance.class_literal(db, &env).definition(db)?; - Some(ResolvedDefinition::Definition(definition)) - }) - .collect(); - } - - resolved_definitions = find_symbol_in_scope(db, builtins_scope, name_str) - .into_iter() - .filter(|def| def.is_reexported(db)) - .flat_map(|def| { - resolve_definition( - db, - &env, - def, - Some(name_str), - ImportAliasResolution::ResolveAliases, - ) + return union + .elements(db) + .iter() + // Use `rev` so that `complex` and `float` come first. + // This is required for hover to pick up the docstring of `complex` and `float` + // instead of `int` (hover only shows the docstring of the first definition). + .rev() + .filter_map(|ty| ty.as_nominal_instance()) + .filter_map(|instance| { + let definition = instance.class_literal(db, &env).definition(db)?; + Some(ResolvedDefinition::Definition(definition)) }) .collect(); } - if resolved_definitions.is_empty() { - resolved_definitions = implicit_name_definitions(db, &env, model, node, name_str); + let builtin_definitions = + definition_resolution::definitions_for_builtin(db, builtins_scope, name_str); + if !builtin_definitions.is_empty() { + return builtin_definitions; } - resolved_definitions + implicit_name_definitions(db, &env, model, node, name_str) } /// basedpython: what a bare name in a trailing lambda block that resolves @@ -350,23 +252,14 @@ fn implicit_name_definitions<'db>( .collect() } -/// Returns all resolved definitions for an attribute expression `x.y`. -/// This function duplicates much of the functionality in the semantic -/// analyzer, but it has somewhat different behavior so we've decided -/// to keep it separate for now. One key difference is that this function -/// doesn't model the descriptor protocol when accessing attributes. -/// For "go to definition", we want to get the type of the descriptor object -/// rather than "invoking" its `__get__` or `__set__` method. -/// If this becomes a maintenance burden in the future, it may be worth -/// changing the corresponding logic in the semantic analyzer to conditionally -/// handle this case through the use of mode flags. +/// Returns definitions for an attribute expression, inferring its receiver through the IDE model. pub fn definitions_for_attribute<'db>( model: &SemanticModel<'db>, attribute: &ast::ExprAttribute, ) -> Vec> { // Determine the type of the LHS let Some(lhs_ty) = attribute.value.inferred_type(model) else { - return Vec::new(); + return vec![]; }; let resolved = definitions_for_member(model, lhs_ty, attribute.attr.as_str()); if !resolved.is_empty() { @@ -391,7 +284,6 @@ pub fn definitions_for_attribute<'db>( /// dispatch target and lands on the extension's function that way. A bare /// `xs.second` has no call to go through, and neither does a property — which /// can never be a callee — so those answered nothing at all -/// fn definitions_for_fallback_attribute<'db>( model: &SemanticModel<'db>, attribute: &ast::ExprAttribute, @@ -468,113 +360,12 @@ fn definitions_for_member<'db>( name_str: &str, ) -> Vec> { let db = model.db(); - - let mut resolved = Vec::new(); - - let env = model.program_environment(); - - // A structural protocol meta-type still uses its nominal protocol declaration as the source - // location for go-to-definition, even though the origin is not a nominal upper bound. - let subclass_origin = |subclass_of: SubclassOfInner<'db>| { - let class = match subclass_of { - SubclassOfInner::Protocol(protocol) => protocol.class_origin(db).map(|origin| *origin), - subclass_of => subclass_of.into_class(db, &env), - }?; - class - .static_class_literal(db) - .map(|(literal, _)| ClassLiteral::Static(literal)) - }; - - let tys = match lhs_ty { - Type::Union(union) => union.elements(model.db()), - _ => std::slice::from_ref(&lhs_ty), - }; - - // Expand intersections for each subtype into their components - let expanded_tys = tys - .iter() - .flat_map(|ty| match ty { - Type::Intersection(intersection) => Either::Left(intersection.positive(db).iter()), - _ => Either::Right(std::iter::once(ty)), - }) - .copied(); - - for ty in expanded_tys { - // Handle modules - if let Type::ModuleLiteral(module_literal) = ty { - if let Some(module_file) = module_literal - .module(db) - .file(db) - .map(|file| ProgramFile::new(db, file, model.program_environment().program(db))) - { - let module_scope = global_scope(db, module_file); - for def in find_symbol_in_scope(db, module_scope, name_str) { - resolved.extend(resolve_definition( - db, - &env, - def, - Some(name_str), - ImportAliasResolution::ResolveAliases, - )); - } - } - continue; - } - - // Prevent lookup on BoundSuper proxy object - if matches!(ty, Type::BoundSuper(_)) { - continue; - } - - let meta_type = ty.to_meta_type(db, &env); - - // Look up the attribute first on the meta-type, unless it's already a class-like type. - let lookup_type = match ty { - Type::ClassLiteral(_) | Type::SubclassOf(_) | Type::GenericAlias(_) => ty, - _ => meta_type, - }; - - let class_literal = match lookup_type { - Type::ClassLiteral(class_literal) => class_literal, - Type::SubclassOf(subclass) => { - let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { - continue; - }; - class_literal - } - _ => continue, - }; - - resolved.extend(definitions_for_attribute_in_class_hierarchy( - &class_literal, - model, - name_str, - )); - - // The metaclass of a derived class must be a subclass of the metaclasses of all of - // its base classes. This is why we only have to look at the metaclass of the - // class_literal. - // Only look up definitions on the metaclass if the type is a class object to begin with in - // order to prevent looking up instance members on the class metaclass - if resolved.is_empty() && meta_type != lookup_type { - let class_literal = match meta_type { - Type::ClassLiteral(class_literal) => class_literal, - Type::SubclassOf(subclass) => { - let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { - continue; - }; - class_literal - } - _ => continue, - }; - - resolved.extend(definitions_for_attribute_in_class_hierarchy( - &class_literal, - model, - name_str, - )); - } - } + let mut resolved = definition_resolution::definitions_for_attribute( + db, + &model.program_environment(), + lhs_ty, + name_str, + ); // basedpython: a property accessor block is one declaration in the source // and several `def`s in the tree, and each of them carries the *same* name @@ -587,6 +378,20 @@ fn definitions_for_member<'db>( resolved } +/// basedpython: the fork's callers pass a [`SemanticModel`] rather than a db and environment. +fn definitions_for_attribute_in_class_hierarchy<'db>( + class_literal: &ClassLiteral<'db>, + model: &SemanticModel<'db>, + attribute_name: &str, +) -> Vec> { + definition_resolution::definitions_for_attribute_in_class_hierarchy( + model.db(), + &model.program_environment(), + class_literal, + attribute_name, + ) +} + /// basedpython: the member declaration inside the [inline protocol] annotation /// the receiver was declared with — the `a: int` of /// `def f(x: protocol(a: int; def g(self) -> int))`, reached from `x.a`. @@ -1121,77 +926,6 @@ pub fn static_member_type_for_attribute<'db>( .ignore_possibly_undefined() } -fn definitions_for_attribute_in_class_hierarchy<'db>( - class_literal: &ClassLiteral<'db>, - model: &SemanticModel<'db>, - attribute_name: &str, -) -> Vec> { - let db = model.db(); - let env = model.program_environment(); - let mut resolved = Vec::new(); - 'scopes: for ancestor in class_literal - .iter_mro(db) - .filter_map(ClassBase::into_class) - .filter_map(|cls: ClassType<'db>| cls.static_class_literal(db).map(|(lit, _)| lit)) - { - let class_scope = ancestor.body_scope(db); - let class_place_table = ty_python_core::place_table(db, class_scope); - - // Look for class-level declarations and bindings - if let Some(place_id) = class_place_table.symbol_id(attribute_name) { - let use_def = use_def_map(db, class_scope); - let resolved_in_scope = resolve_reachable_definitions( - db, - &env, - attribute_name, - use_def - .reachable_symbol_declarations(place_id) - .filter_map(|declaration| declaration.declaration.definition()) - .chain( - use_def - .reachable_symbol_bindings(place_id) - .filter_map(|binding| binding.binding.definition()), - ), - ); - if !resolved_in_scope.is_empty() { - resolved.extend(resolved_in_scope); - break 'scopes; - } - } - - // Look for instance attributes in method scopes (e.g., self.x = 1) - let index = semantic_index(db, class_scope.program_file(db)); - - for function_scope_id in attribute_scopes(db, class_scope) { - if let Some(place_id) = index - .place_table(function_scope_id) - .member_id_by_instance_attribute_name(attribute_name) - { - let use_def = index.use_def_map(function_scope_id); - let resolved_in_scope = resolve_reachable_definitions( - db, - &env, - attribute_name, - use_def - .reachable_member_declarations(place_id) - .filter_map(|declaration| declaration.declaration.definition()) - .chain( - use_def - .reachable_member_bindings(place_id) - .filter_map(|binding| binding.binding.definition()), - ), - ); - if !resolved_in_scope.is_empty() { - resolved.extend(resolved_in_scope); - break 'scopes; - } - } - } - } - - resolved -} - /// Finds member implementations contributed by subclasses of `roots` defined in `file`. fn member_implementations_for_file<'db>( db: &'db dyn Db, @@ -1281,7 +1015,7 @@ fn mro_member_definitions<'db>( /// /// A class-body definition (method or attribute) takes priority and determines this class's /// contribution when present, mirroring the goto-definition lookup in -/// [`definitions_for_attribute_in_class_hierarchy`]. Otherwise, instance attributes assigned in the +/// [`definition_resolution::definitions_for_attribute`]. Otherwise, instance attributes assigned in the /// class's own method bodies (`self.member = ...`) are used. /// /// Subclasses that only inherit the member do not add a new implementation target. The inherited @@ -1495,56 +1229,6 @@ fn collect_implementation_root_classes<'db>( } } -/// Returns the user-visible definitions represented by a use-def binding. -/// -/// Comprehension walruses are represented in the containing scope by synthetic eager bindings: -/// -/// ```python -/// [(last := item) for item in items] -/// print(last) # Go to definition should select `last := item` above. -/// ``` -/// -/// The binding for the use in `print` is synthetic, so follow it into the comprehension's -/// end-of-scope bindings. Nested comprehensions can produce a chain of these proxies. Only -/// follow sources that resolve to the same variable, so `global` and `nonlocal` writes do not -/// become definitions of each other. -fn user_visible_definitions<'db>( - db: &'db dyn Db, - definitions: impl IntoIterator>, -) -> FxIndexSet> { - let mut pending = definitions.into_iter().collect::>(); - let mut seen = FxHashSet::default(); - let mut result = FxIndexSet::default(); - - while let Some(definition) = pending.pop_front() { - if !seen.insert(definition) { - continue; - } - - match definition.kind(db) { - DefinitionKind::NestedBindings(nested) => { - let index = semantic_index(db, definition.program_file(db)); - let sources = nested - .visible_binding_sources(index, definition.file_scope(db)) - .flatten() - .filter_map(|binding| binding.binding.definition()); - // A lazy function proxy can lead to an eager comprehension proxy. Follow that - // proxy-only chain without exposing ordinary lazy nested assignments. - pending.extend(sources.filter(|source| { - nested.execution == NestedBindingExecution::Eager - || matches!(source.kind(db), DefinitionKind::NestedBindings(_)) - })); - } - kind if kind.is_user_visible() => { - result.insert(definition); - } - _ => {} - } - } - - result -} - fn reachable_implementation_definitions<'db>( db: &'db dyn Db, definitions: impl IntoIterator>, @@ -1600,26 +1284,6 @@ fn is_ascii_identifier_continue(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' } -fn resolve_reachable_definitions<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - symbol_name: &str, - definitions: impl IntoIterator>, -) -> Vec> { - user_visible_definitions(db, definitions) - .into_iter() - .flat_map(|definition| { - resolve_definition( - db, - env, - definition, - Some(symbol_name), - ImportAliasResolution::ResolveAliases, - ) - }) - .collect() -} - pub struct TypedDictKeyHover<'db> { pub owner: String, pub key: String, @@ -1720,7 +1384,7 @@ pub fn definitions_for_imported_symbol<'db>( ) -> Vec> { let mut visited = FxHashSet::default(); let env = model.program_environment(); - resolve_definition::resolve_from_import_definitions( + definition_resolution::resolve_from_import_definitions( model.db(), &env, ImportingFile::File(model.file(), env.resolver_environment(model.db())), @@ -1813,7 +1477,7 @@ impl<'db> CallSignatureDetails<'db> { binding: &crate::types::call::Binding<'db>, ) -> Self { let argument_to_parameter_mapping = binding.argument_matches().to_vec(); - let specialization = binding.specialization(db, env); + let specialization = binding.merged_specialization(db, env); let signature = binding.signature.clone(); let display_details = signature.display(db, env).to_string_parts(); let (parameters, parameter_to_displayed_parameter_mapping) = @@ -2108,7 +1772,14 @@ fn known_type_form_parameter_index(db: &dyn Db, callable_type: Type<'_>) -> Opti Some(KnownFunction::AssertType) => Some(1), _ => None, }, - Type::ClassLiteral(class) if class.is_known(db, KnownClass::TypeAliasType) => Some(1), + Type::ClassLiteral(class) + if matches!( + class.known(db), + Some(KnownClass::TypeAliasType | KnownClass::ExtensionsTypeAliasType) + ) => + { + Some(1) + } _ => None, } } @@ -2586,429 +2257,21 @@ pub fn inlay_hint_call_argument_details<'db>( }) } -mod resolve_definition { - //! Resolves an Import, `ImportFrom` or `StarImport` definition to one or more - //! "resolved definitions". This is done recursively to find the original - //! definition targeted by the import. - - /// Controls whether local import aliases should be resolved to their targets or returned as-is. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum ImportAliasResolution { - /// Resolve import aliases to their original definitions - ResolveAliases, - /// Keep import aliases as-is, don't resolve to original definitions - PreserveAliases, - } - - use indexmap::IndexSet; - use ruff_db::files::{FileRange, vendored_path_to_file}; - use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +mod stub_mapping { + use crate::lexical_name_path::{ + lexical_name_path_component_for_node, lexical_name_path_for_definition, + }; + use crate::types::definition_resolution::{ + ImportAliasResolution, ResolvedDefinition, find_symbol_in_scope, resolve_definition, + }; + use crate::{Db, ProgramEnvironment}; + use ruff_db::files::vendored_path_to_file; + use ruff_db::parsed::parsed_module; use ruff_db::system::SystemPath; use ruff_db::vendored::VendoredPathBuf; - use ruff_python_ast as ast; - use ruff_python_stdlib::sys::is_builtin_module; - use ruff_text_size::TextRange; - use rustc_hash::FxHashSet; use tracing::trace; - use ty_module_resolver::{ - ImportingFile, ModuleName, file_to_module, resolve_module, resolve_real_module, - }; - - use crate::Db; - use crate::ProgramEnvironment; - use crate::module_docstring; - use crate::types::binding_type; - use ty_python_core::definition::{Definition, DefinitionCategory, DefinitionKind}; - use ty_python_core::scope::{NodeWithScopeKind, ScopeId}; - use ty_python_core::{ProgramFile, global_scope, place_table, semantic_index, use_def_map}; - - /// Represents the result of resolving an import to either a specific definition or - /// a specific range within a file. - /// This enum helps distinguish between cases where an import resolves to: - /// - A specific definition within a module (e.g., `from os import path` -> definition of `path`) - /// - A specific range within a file, sometimes an empty range at the top of the file - #[derive(Debug, Clone, PartialEq, Eq)] - pub enum ResolvedDefinition<'db> { - /// The import resolved to a specific definition within a module - Definition(Definition<'db>), - /// The import resolved to an entire module - Module(ProgramFile<'db>), - /// The import resolved to a file with a specific range - FileWithRange(FileRange), - } - - impl<'db> ResolvedDefinition<'db> { - pub fn focus_range(&self, db: &dyn Db) -> FileRange { - match self { - ResolvedDefinition::Definition(definition) => { - let parsed = parsed_module(db, definition.python_file(db)).load(db); - definition.focus_range(db, &parsed) - } - // For modules, navigate to the start of the file - ResolvedDefinition::Module(module) => { - FileRange::new(module.file(db), TextRange::default()) - } - ResolvedDefinition::FileWithRange(file_range) => *file_range, - } - } - - pub(crate) fn category(&self, db: &dyn Db) -> DefinitionCategory { - match self { - ResolvedDefinition::Definition(definition) => { - let file = definition.file(db); - let parsed = parsed_module(db, definition.python_file(db)).load(db); - definition.kind(db).category(file.is_stub(db), &parsed) - } - ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => { - DefinitionCategory::DeclarationAndBinding - } - } - } - - pub fn definition(&self) -> Option> { - match self { - ResolvedDefinition::Definition(definition) => Some(*definition), - ResolvedDefinition::Module(_) => None, - ResolvedDefinition::FileWithRange(_) => None, - } - } - - fn program_file(&self, db: &'db dyn Db) -> Option> { - match *self { - ResolvedDefinition::Definition(definition) => Some(definition.program_file(db)), - ResolvedDefinition::Module(file) => Some(file), - ResolvedDefinition::FileWithRange(_) => None, - } - } - - pub fn docstring(&self, db: &'db dyn Db) -> Option { - match self { - ResolvedDefinition::Definition(definition) => definition.docstring(db), - ResolvedDefinition::Module(file) => module_docstring(db, file.python_file(db)), - ResolvedDefinition::FileWithRange(_) => None, - } - } - - pub fn implementation_docstring(&self, db: &'db dyn Db) -> Option { - match self { - ResolvedDefinition::Definition(definition) => { - implementation_docstring(db, *definition) - } - ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => None, - } - } - } - - // Overload declarations often omit docstrings, while the runtime - // implementation appears as the last sibling binding for the same symbol. - // Fall back to that binding's docstring when the resolved overload has none. - // - // Uses type-aware matching: resolves each end-of-scope binding's type to a - // function literal, then checks whether that function's overloads contain the - // current definition. This correctly handles version-conditional branches and - // avoids picking up unrelated reassignments of the same name. - fn implementation_docstring<'db>( - db: &'db dyn Db, - definition: Definition<'db>, - ) -> Option { - let DefinitionKind::Function(_) = definition.kind(db) else { - return None; - }; - - let name = definition.name(db)?; - let scope = definition.scope(db); - let symbol_id = place_table(db, scope).symbol_id(&name)?; - let use_def = use_def_map(db, scope); - - let current_overload = binding_type(db, definition) - .as_function_literal()? - .literal(db) - .last_definition; - - // Find the last end-of-scope binding whose function type contains this overload. - let implementation = use_def - .end_of_scope_symbol_bindings(symbol_id) - .filter_map(|binding| { - let ty = binding_type(db, binding.binding.definition()?).as_function_literal()?; - ty.iter_overloads_and_implementation(db) - .any(|overload| overload == current_overload) - .then_some(ty) - }) - .last()?; - - implementation.definition(db).docstring(db) - } - - /// Resolve import definitions to their targets. - /// Returns resolved definitions which can be either specific definitions or module files. - /// For non-import definitions, returns the definition wrapped in `ResolvedDefinition::Definition`. - /// Always returns at least the original definition as a fallback if resolution fails. - pub(crate) fn resolve_definition<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - definition: Definition<'db>, - symbol_name: Option<&str>, - alias_resolution: ImportAliasResolution, - ) -> Vec> { - let mut visited = FxHashSet::default(); - let resolved = resolve_definition_recursive( - db, - env, - definition, - &mut visited, - symbol_name, - alias_resolution, - ); - - // If resolution failed, return the original definition as fallback - if resolved.is_empty() { - vec![ResolvedDefinition::Definition(definition)] - } else { - resolved - } - } - - /// Helper function to resolve import definitions recursively. - fn resolve_definition_recursive<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - definition: Definition<'db>, - visited: &mut FxHashSet>, - symbol_name: Option<&str>, - alias_resolution: ImportAliasResolution, - ) -> Vec> { - // Prevent infinite recursion if there are circular imports - if visited.contains(&definition) { - return Vec::new(); // Return empty list for circular imports - } - visited.insert(definition); - - let kind = definition.kind(db); - - match kind { - DefinitionKind::Import(import_def) => { - let file = definition.program_file(db); - let module = parsed_module(db, file.python_file(db)).load(db); - let alias = import_def.alias(&module); - - if alias.asname.is_some() - && alias_resolution == ImportAliasResolution::PreserveAliases - { - return vec![ResolvedDefinition::Definition(definition)]; - } - - // Get the full module name being imported - let Some(module_name) = ModuleName::new(&alias.name) else { - return Vec::new(); // Invalid module name, return empty list - }; - - // Resolve the module to its file - let importing_file = - ImportingFile::File(file.file(db), env.resolver_environment(db)); - let Some(resolved_module) = resolve_module(db, importing_file, &module_name) else { - return Vec::new(); // Module not found, return empty list - }; - - let Some(module_file) = resolved_module.file(db) else { - return Vec::new(); // No file for module, return empty list - }; - let module_file = ProgramFile::new(db, module_file, env.program(db)); - - // For simple imports like "import os", we want to navigate to the module itself. - // Return the module file directly instead of trying to find definitions within it. - vec![ResolvedDefinition::Module(module_file)] - } - - DefinitionKind::ImportFrom(import_from_def) => { - let file = definition.program_file(db); - let module = parsed_module(db, file.python_file(db)).load(db); - let import_node = import_from_def.import(&module); - let alias = import_from_def.alias(&module); - - if alias.asname.is_some() - && alias_resolution == ImportAliasResolution::PreserveAliases - { - return vec![ResolvedDefinition::Definition(definition)]; - } - - // For `ImportFrom`, we need to resolve the original imported symbol name - // (alias.name), not the local alias (symbol_name) - resolve_from_import_definitions( - db, - env, - ImportingFile::File(file.file(db), env.resolver_environment(db)), - import_node, - &alias.name, - visited, - alias_resolution, - ) - } - - // For star imports, try to resolve to the specific symbol being accessed - DefinitionKind::StarImport(star_import_def) => { - let file = definition.program_file(db); - let module = parsed_module(db, file.python_file(db)).load(db); - let import_node = star_import_def.import(&module); - - // If we have a symbol name, use the helper to resolve it in the target module - if let Some(symbol_name) = symbol_name { - resolve_from_import_definitions( - db, - env, - ImportingFile::File(file.file(db), env.resolver_environment(db)), - import_node, - symbol_name, - visited, - alias_resolution, - ) - } else { - // No symbol context provided, can't resolve star import - Vec::new() - } - } - - // For non-import definitions, return the definition as is - _ => vec![ResolvedDefinition::Definition(definition)], - } - } - - /// Helper function to resolve import definitions for `ImportFrom` and `StarImport` cases. - pub(crate) fn resolve_from_import_definitions<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - importing_file: ImportingFile<'db>, - import_node: &ast::StmtImportFrom, - symbol_name: &str, - visited: &mut FxHashSet>, - alias_resolution: ImportAliasResolution, - ) -> Vec> { - if alias_resolution == ImportAliasResolution::PreserveAliases { - for alias in &import_node.names { - if let Some(asname) = &alias.asname { - if asname.as_str() == symbol_name { - return vec![ResolvedDefinition::FileWithRange(FileRange::new( - importing_file.file(db), - asname.range, - ))]; - } - } - } - } - - // Resolve the module being imported from (handles both relative and absolute imports) - let Some(module_name) = - ModuleName::from_import_statement(db, importing_file, import_node).ok() - else { - return Vec::new(); - }; - let Some(resolved_module) = resolve_module(db, importing_file, &module_name) else { - return Vec::new(); - }; - - // Resolve the target module file - let module_file = resolved_module - .file(db) - .map(|file| ProgramFile::new(db, file, env.program(db))); - - let Some(module_file) = module_file else { - // No file means this is a namespace package, try to import the submodule - return Vec::from_iter(resolve_from_import_submodule_definitions( - db, - env, - importing_file, - symbol_name, - module_name, - )); - }; - - // Find the definition of this symbol in the imported module's global scope - let global_scope = global_scope(db, module_file); - let definitions_in_module = find_symbol_in_scope(db, global_scope, symbol_name); - - // Recursively resolve any import definitions found in the target module - let mut resolved_definitions = Vec::new(); - for def in definitions_in_module { - let resolved = resolve_definition_recursive( - db, - env, - def, - visited, - Some(symbol_name), - alias_resolution, - ); - resolved_definitions.extend(resolved); - } - - if resolved_definitions.is_empty() { - // In `pkg/__init__.py`, `from . import child` resolves `.` to - // `pkg/__init__.py`. Looking up `child` there can find an import definition - // that recursively resolves back here (possibly through `from . import *`), - // so recursive resolution bottoms out before reaching the `pkg.child` - // submodule target. Fall back to the same submodule candidate we use when - // `child` has no binding in `pkg/__init__.py`. - Vec::from_iter(resolve_from_import_submodule_definitions( - db, - env, - importing_file, - symbol_name, - module_name, - )) - } else { - resolved_definitions - } - } - - // Helper to resolve `from x.y import z` assuming `x.y.z` is a module. - fn resolve_from_import_submodule_definitions<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - importing_file: ImportingFile<'db>, - symbol_name: &str, - module_name: ModuleName, - ) -> Option> { - let submodule_name = ModuleName::new(symbol_name)?; - let mut full_submodule_name = module_name; - full_submodule_name.extend(&submodule_name); - let module = resolve_module(db, importing_file, &full_submodule_name)?; - let file = ProgramFile::new(db, module.file(db)?, env.program(db)); - - Some(ResolvedDefinition::Module(file)) - } - - /// Find definitions for a symbol name in a specific scope. - pub(crate) fn find_symbol_in_scope<'db>( - db: &'db dyn Db, - scope: ScopeId<'db>, - symbol_name: &str, - ) -> IndexSet> { - let place_table = place_table(db, scope); - let Some(symbol_id) = place_table.symbol_id(symbol_name) else { - return IndexSet::new(); - }; - - let use_def_map = use_def_map(db, scope); - let mut definitions = IndexSet::new(); - - // Get all definitions (both bindings and declarations) for this place - let bindings = use_def_map.reachable_symbol_bindings(symbol_id); - let declarations = use_def_map.reachable_symbol_declarations(symbol_id); - - for binding in bindings { - if let Some(def) = binding.binding.definition() { - definitions.insert(def); - } - } - - for declaration in declarations { - if let Some(def) = declaration.declaration.definition() { - definitions.insert(def); - } - } - - super::user_visible_definitions(db, definitions) - .into_iter() - .collect() - } + use ty_module_resolver::stub_file_to_real_module; + use ty_python_core::{ProgramFile, global_scope, semantic_index}; /// Given a definition that may be in a stub file, find the "real" definition in a non-stub. #[tracing::instrument(skip_all)] @@ -3057,73 +2320,28 @@ mod resolve_definition { // It's definitely a stub, so now rerun module resolution but with stubs disabled. let resolver_file = stub_file_for_module_lookup.resolver_file(db); - let stub_module = file_to_module(db, resolver_file)?; - trace!("Found stub module: {}", stub_module.name(db)); - // We need to pass an importing file to `resolve_real_module` which is a bit odd - // here because there isn't really an importing file. However this `resolve_real_module` - // can be understood as essentially `import .`, which is also what `file_to_module` is, - // so this is in fact exactly the file we want to consider the importer. - // - // ... unless we have a builtin module. i.e., A module embedded - // into the interpreter. In which case, all we have are stubs. - // `resolve_real_module` will always return `None` for this case, but - // it will emit false positive logs. And this saves us some work. - if is_builtin_module(stub_module.python_version(db).minor, stub_module.name(db)) { - return None; - } - let real_module = resolve_real_module( - db, - ImportingFile::ResolverFile(resolver_file), - stub_module.name(db), - )?; + let real_module = stub_file_to_real_module(db, resolver_file)?; trace!("Found real module: {}", real_module.name(db)); let real_parse_file = ProgramFile::new(db, real_module.file(db)?, env.program(db)); let real_file = real_parse_file.file(db); trace!("Found real file: {}", real_file.path(db)); - // A definition has a "Definition Path" in a file made of nested definitions (~scopes): + // A definition's lexical name path describes its nesting within a module: // // ``` - // class myclass: # ./myclass - // def some_func(args: bool): # ./myclass/some_func - // # ^~~~ ./myclass/other_func/args/ + // class Outer: # [Outer] + // def method(): ... # [Outer, method] // ``` // - // So our heuristic goal here is to compute a Definition Path in the stub file - // and then resolve the same Definition Path in the real file. + // Compute the path in the stub file, then resolve the same path in the real file. // - // NOTE: currently a path component is just a str, but in the future additional + // NOTE: currently a path component is just a name, but in the future additional // disambiguators (like "is a class def") could be added if needed. - let mut path = Vec::new(); - let stub_parsed; - let stub_ref; - match *def { + let path = match *def { ResolvedDefinition::Definition(definition) => { - stub_parsed = parsed_module(db, definition.python_file(db)); - stub_ref = stub_parsed.load(db); - - // Get the leaf of the path (the definition itself) - let leaf = definition_path_component_for_leaf(db, &stub_ref, definition) - .map_err(|()| { - trace!("Found unsupported DefinitionKind while stub mapping, giving up"); - }) - .ok()?; - path.push(leaf); - - // Get the ancestors of the path (all the definitions we're nested under) - let index = semantic_index(db, definition.program_file(db)); - for (_scope_id, scope) in index.ancestor_scopes(definition.file_scope(db)) { - let node = scope.node(); - let component = definition_path_component_for_node(&stub_ref, node) - .map_err(|()| { - trace!("Found unsupported NodeScopeKind while stub mapping, giving up"); - }) - .ok()?; - if let Some(component) = component { - path.push(component); - } - } - trace!("Built Definition Path: {path:?}"); + let path = lexical_name_path_for_definition(db, definition)?; + trace!("Built lexical name path: {path:?}"); + path } ResolvedDefinition::Module(_) => { trace!( @@ -3135,13 +2353,13 @@ mod resolve_definition { } ResolvedDefinition::FileWithRange(_) => { // Not yet implemented -- in this case we want to recover something like a Definition - // and build a Definition Path, but this input is a bit too abstract for now. + // and build a lexical name path, but this input is a bit too abstract for now. trace!("Found arbitrary FileWithRange while stub mapping, giving up"); return None; } - } + }; - // Walk down the Definition Path in the real file + // Walk down the lexical name path in the real file. let mut definitions = Vec::new(); let index = semantic_index(db, real_parse_file); let global_scope = global_scope(db, real_parse_file); @@ -3149,24 +2367,25 @@ mod resolve_definition { let real_ref = real_parsed.load(db); // Start our search in the module (global) scope let mut scopes = vec![global_scope]; - while let Some(component) = path.pop() { - trace!("Traversing definition path component: {}", component); + let mut path = path.iter().peekable(); + while let Some(component) = path.next() { + trace!("Traversing lexical name path component: {}", component); // We're doing essentially a breadth-first traversal of the definitions. // If ever we find multiple matching scopes for a component, we need to continue // walking down each of them to try to resolve the path. Here we loop over // all the scopes at the current level of search. for scope in std::mem::take(&mut scopes) { - if path.is_empty() { + if path.peek().is_none() { // We're at the end of the path, everything we find here is the final result definitions.extend( - find_symbol_in_scope(db, scope, component) + find_symbol_in_scope(db, scope, component.as_str()) .into_iter() .flat_map(|definition| { resolve_definition( db, &env, definition, - Some(component), + Some(component.as_str()), ImportAliasResolution::ResolveAliases, ) }), @@ -3177,11 +2396,10 @@ mod resolve_definition { { let scope_node = child_scope.node(); if let Ok(Some(real_component)) = - definition_path_component_for_node(&real_ref, scope_node) + lexical_name_path_component_for_node(&real_ref, scope_node) + && real_component == *component { - if real_component == component { - scopes.push(child_scope_id.to_scope_id(db, real_parse_file)); - } + scopes.push(child_scope_id.to_scope_id(db, real_parse_file)); } scope.node(db); } @@ -3201,89 +2419,6 @@ mod resolve_definition { Some(definitions) } } - - /// Computes a "Definition Path" component for an internal node of the definition path. - /// - /// See [`map_stub_definition`][] for details. - fn definition_path_component_for_node<'parse>( - parsed: &'parse ParsedModuleRef, - node: &NodeWithScopeKind, - ) -> Result, ()> { - let component = match node { - NodeWithScopeKind::Module => { - // This is just implicit, so has no component - return Ok(None); - } - NodeWithScopeKind::Class(class) => class.node(parsed).name.as_str(), - NodeWithScopeKind::Function(func) => func.node(parsed).name.as_str(), - NodeWithScopeKind::TypeAlias(_) - | NodeWithScopeKind::ClassTypeParameters(_) - | NodeWithScopeKind::FunctionTypeParameters(_) - | NodeWithScopeKind::TypeAliasTypeParameters(_) - | NodeWithScopeKind::Lambda(_) - | NodeWithScopeKind::ListComprehension(_) - | NodeWithScopeKind::SetComprehension(_) - | NodeWithScopeKind::DictComprehension(_) - | NodeWithScopeKind::GeneratorExpression(_) => { - // Not yet implemented - return Err(()); - } - }; - Ok(Some(component)) - } - - /// Computes a "Definition Path" component for a leaf node of the definition path. - /// - /// See [`map_stub_definition`][] for details. - fn definition_path_component_for_leaf<'parse>( - db: &dyn Db, - parsed: &'parse ParsedModuleRef, - definition: Definition, - ) -> Result<&'parse str, ()> { - let component = match definition.kind(db) { - DefinitionKind::Function(func) => func.node(parsed).name.as_str(), - DefinitionKind::Class(class) => class.node(parsed).name.as_str(), - DefinitionKind::Assignment(assignment) => { - let ast::Expr::Name(name) = assignment.target(parsed) else { - return Err(()); - }; - name.id.as_str() - } - DefinitionKind::AnnotatedAssignment(assignment) => { - let ast::Expr::Name(name) = assignment.target(parsed) else { - return Err(()); - }; - name.id.as_str() - } - DefinitionKind::TypeAlias(_) - | DefinitionKind::Import(_) - | DefinitionKind::ImportFrom(_) - | DefinitionKind::ImportFromSubmodule(_) - | DefinitionKind::StarImport(_) - | DefinitionKind::NamedExpression(_) - | DefinitionKind::StatementExpressionValue(_) - | DefinitionKind::AugmentedAssignment(_) - | DefinitionKind::DictKeyAssignment(_) - | DefinitionKind::For(_) - | DefinitionKind::Comprehension(_) - | DefinitionKind::Parameter(_) - | DefinitionKind::LambdaParameter { .. } - | DefinitionKind::WithItem(_) - | DefinitionKind::MatchPattern(_) - | DefinitionKind::ExceptHandler(_) - | DefinitionKind::TypeVar(_) - | DefinitionKind::ParamSpec(_) - | DefinitionKind::TypeVarTuple(_) - | DefinitionKind::TypeMatchCapture(_) - | DefinitionKind::LoopHeader(_) - | DefinitionKind::NestedBindings(_) => { - // Not yet implemented - return Err(()); - } - }; - - Ok(component) - } } /// Information about a class in the type hierarchy. @@ -4025,7 +3160,7 @@ pub fn inherited_parameter_annotation<'db>( /// A method's defaults are part of what it declares, so a parameter an override re-declares /// without one keeps the base's. `None` when the parameter writes a default of its own, when /// nothing it overrides declares one, or when what the base declares is an expression rather than -/// a value — see [`Type::display_default_value`]. +/// a value — see `Type::display_default_value`. pub fn inherited_parameter_default( model: &SemanticModel<'_>, parameter: &ast::ParameterWithDefault, @@ -4043,7 +3178,7 @@ pub fn inherited_parameter_default( .parameters() .iter() .find(|candidate| candidate.name() == Some(¶meter.parameter.name.id))? - .default_type()? + .default_type(db)? .display_default_value(db, &model.program_environment()) } @@ -4242,7 +3377,7 @@ pub fn callable_parameters<'db>( declared_type: (parameter.should_annotation_be_displayed() && !parameter.annotated_type().is_unknown()) .then(|| parameter.annotated_type()), - has_default: parameter.default_type().is_some(), + has_default: parameter.has_default(), }) .collect(), ) @@ -4433,13 +3568,48 @@ pub fn is_union_special_form(ty: Type) -> bool { #[cfg(test)] mod tests { - use super::{CallArgumentForm, call_argument_forms, contains_identifier}; + use super::{ + CallArgumentForm, ImportAliasResolution, call_argument_forms, contains_identifier, + definitions_for_name, + }; use crate::SemanticModel; use crate::db::tests::TestDbBuilder; + use anyhow::Context; use ruff_db::files::system_path_to_file; use ruff_db::parsed::parsed_module; + use ruff_db::testing::assert_function_query_was_not_run_by_name; + use ruff_python_ast as ast; use ty_python_core::ProgramFile; + #[test] + fn builtin_definition_lookup_does_not_infer_scope() -> anyhow::Result<()> { + let mut db = TestDbBuilder::new() + .with_file("/src/foo.py", "isinstance") + .build()?; + let file = system_path_to_file(&db, "/src/foo.py")?; + let file = ProgramFile::new(&db, file, db.program_environment().program(&db)); + let parsed = parsed_module(&db, file.python_file(&db)).load(&db); + let expression = &parsed + .suite() + .first() + .and_then(ast::Stmt::as_expr_stmt) + .context("expected an expression statement")? + .value; + let model = SemanticModel::new(&db, file); + + let definitions = definitions_for_name( + &model, + "isinstance", + expression.as_ref().into(), + ImportAliasResolution::ResolveAliases, + ); + assert_eq!(definitions.len(), 1); + + let events = db.take_salsa_events(); + assert_function_query_was_not_run_by_name(&db, "infer_scope_types_impl", None, &events); + Ok(()) + } + #[test] fn source_candidate_prefilters_use_identifier_boundaries() { for (source, name) in [("x = 1", "x"), ("obj.x", "x"), ("x()", "x")] { diff --git a/crates/ty_python_semantic/src/types/ide_support/unused_bindings.rs b/crates/ty_python_semantic/src/types/ide_support/unused_bindings.rs index d84cdcd6b3..dab47fcbbd 100644 --- a/crates/ty_python_semantic/src/types/ide_support/unused_bindings.rs +++ b/crates/ty_python_semantic/src/types/ide_support/unused_bindings.rs @@ -7,9 +7,7 @@ use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_python_ast::name::Name; use ruff_text_size::TextRange; use rustc_hash::FxHashSet; -use ty_python_core::definition::{ - DefinitionCategory, DefinitionKind, DefinitionState, ParameterDefinitionNodeKind, -}; +use ty_python_core::definition::{DefinitionCategory, DefinitionKind, ParameterDefinitionNodeKind}; use ty_python_core::place::ScopedPlaceId; use ty_python_core::scope::{FileScopeId, ScopeKind}; use ty_python_core::{ProgramFile, SemanticIndex, semantic_index}; @@ -135,8 +133,8 @@ pub fn unused_bindings(db: &dyn Db, file: ProgramFile<'_>) -> Box<[UnusedBinding let used_definitions = index.scope_ids().flat_map(|scope_id| { index .use_def_map(scope_id.file_scope_id(db)) - .all_definitions_with_usage() - .filter_map(|(_, state, is_used)| is_used.then_some(state.definition()).flatten()) + .definitions_with_usage() + .filter_map(|(_, definition, is_used)| is_used.then_some(definition)) }); let used_user_visible_definitions = super::user_visible_definitions(db, used_definitions); @@ -165,10 +163,7 @@ pub fn unused_bindings(db: &dyn Db, file: ProgramFile<'_>) -> Box<[UnusedBinding // track used IDs as we go. let mut loop_header_used_definition_ids = FxHashSet::default(); - for (definition_id, state, is_used) in use_def_map.all_definitions_with_usage() { - let DefinitionState::Defined(definition) = state else { - continue; - }; + for (definition_id, definition, is_used) in use_def_map.definitions_with_usage() { let is_used = is_used || used_user_visible_definitions.contains(&definition); if is_used { @@ -866,6 +861,23 @@ mod tests { Ok(()) } + #[test] + fn closure_uses_later_annotated_binding() -> anyhow::Result<()> { + let source = dedent( + " + def outer(): + def inner(): + return value + + value: int = 1 + return inner + ", + ); + + assert!(collect_unused_names(&source)?.is_empty()); + Ok(()) + } + #[test] fn nested_comprehension_capture_uses_intermediate_rebindings() -> anyhow::Result<()> { let source = dedent( @@ -995,6 +1007,45 @@ mod tests { Ok(()) } + #[test] + fn skips_annotated_loop_carried_rebinding() -> anyhow::Result<()> { + let source = dedent( + " + def f(items: list[int]) -> None: + value = 0 + for item in items: + print(value) + value: int = item + ", + ); + + assert!(collect_unused_names(&source)?.is_empty()); + Ok(()) + } + + #[test] + fn reports_shadowed_annotated_binding() -> anyhow::Result<()> { + let source = dedent( + " + def f() -> int: + value: int = 1 + value: int = 2 + return value + ", + ); + + let bindings = collect_unused_bindings(&source)?; + let start = TextSize::try_from(source.find("value: int = 1").unwrap()).unwrap(); + assert_eq!( + bindings, + vec![UnusedBinding { + range: TextRange::new(start, start + TextSize::new(5)), + name: Name::new("value"), + }] + ); + Ok(()) + } + #[test] fn skips_annotation_only_declaration_before_reassignment() -> anyhow::Result<()> { let source = dedent( diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index fc36d99a65..a6787ab202 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -71,9 +71,9 @@ use ty_python_core::expression::Expression; use ty_python_core::scope::{NodeWithScopeKind, ScopeId}; use ty_python_core::statement::StatementInner; use ty_python_core::unpack::Unpack; -use ty_python_core::{ExpressionNodeKey, SemanticIndex, Statement, semantic_index}; +use ty_python_core::{ExpressionNodeKey, SemanticIndex, Statement, Truthiness, semantic_index}; -mod builder; +pub(crate) mod builder; pub(crate) use builder::{ fold_tuple_concat, fold_tuple_repeat, literal_binary_op, literal_unary_op, }; @@ -93,6 +93,9 @@ bitflags::bitflags! { /// The operand of an `Unpack[...]` expression is neither a tuple nor a `TypeVarTuple`. const INVALID_UNPACK = 1 << 1; + + /// The expression refers to a `TypeVarTuple` without unpacking it. + const INVALID_BARE_TYPE_VAR_TUPLE = 1 << 2; } } @@ -274,7 +277,7 @@ impl<'db> FunctionDecoratorInference<'db> { } /// basedpython: the type the call a trailing lambda block stands for produces. - pub(crate) fn trailing_lambda_return(&self) -> Option> { + fn trailing_lambda_return(&self) -> Option> { self.trailing_lambda_return } @@ -287,6 +290,7 @@ impl<'db> FunctionDecoratorInference<'db> { /// /// Deferred expressions are type expressions (annotations, base classes, aliases...) in a stub /// file, or in a file with `from __future__ import annotations`, or stringified annotations. +/// Function parameter defaults are inferred separately by [`infer_function_default_types`]. #[salsa::tracked( returns(ref), cycle_initial=|db, id, definition: Definition<'db>| { @@ -328,6 +332,43 @@ pub(crate) fn infer_deferred_types<'db>( .finish_definition(definition) } +/// Infer a function's parameter defaults without retaining its annotation types. +/// +/// Callable signature checking only needs to know which parameters are optional. Inferring their +/// default values while inferring annotations can re-enter the decorated function's own signature. +/// Keeping the results separate also avoids caching annotation expressions twice. +#[salsa::tracked( + returns(ref), + cycle_initial=|db, id, definition: Definition<'db>| { + DefinitionInference::cycle_initial(db, definition, Type::divergent(id)) + }, + cycle_fn=|db: &'db dyn Db, cycle, previous: &DefinitionInference<'db>, inference: DefinitionInference<'db>, definition: Definition<'db>| { + inference.cycle_normalized(db, previous, cycle, definition) + }, + heap_size=ruff_memory_usage::heap_size +)] +pub(crate) fn infer_function_default_types<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> DefinitionInference<'db> { + let program_file = definition.program_file(db); + let python_file = program_file.python_file(db); + let module = parsed_module(db, python_file).load(db); + let index = semantic_index(db, program_file); + let env = ProgramEnvironment::from_file(program_file); + + TypeInferenceBuilder::new( + db, + &env, + InferenceRegion::FunctionDefaults(definition), + python_file.file(db), + program_file, + index, + &module, + ) + .finish_definition(definition) +} + /// Infer all types for a [`ScopeId`], including all definitions and expressions in that scope. /// Use when checking a scope, or needing to provide a type for an arbitrary expression in the /// scope. @@ -662,6 +703,37 @@ impl<'db> InferScope<'db> { } } +/// Where a [`TypeContext`] came from, when it is a call argument's parameter type. +#[derive( + Default, Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue, +)] +pub(crate) enum ArgumentContextOrigin { + /// Not a call argument's parameter type, or one the call left unspecialized. + #[default] + External, + /// A generic call's parameter type specialized by this very argument. Such a context is not + /// an external observer of the argument's type, so it must not adopt and lock a fluid + /// specialization. + Solved, + /// basedpython: as [`Self::Solved`], and the parameter spells the class's type argument out + /// rather than naming it with a type variable (see [`Type::prescribes_type_arguments`]). The + /// context then states what the parameter demands rather than what the argument holds, so a + /// fluid specialization must not read a widening out of it either. + Prescribed, +} + +impl ArgumentContextOrigin { + /// whether the context was solved from the argument it types + const fn is_solved_from_argument(self) -> bool { + !matches!(self, Self::External) + } + + /// whether the parameter spells the class's type argument out + const fn prescribes_type_arguments(self) -> bool { + matches!(self, Self::Prescribed) + } +} + /// The type context for a given expression, namely the type annotation /// in an annotated assignment. /// @@ -678,11 +750,8 @@ pub(crate) struct TypeContext<'db> { /// specialization candidates, whose literal types are promoted lazily on the first /// widening event rather than at creation time. pub(crate) preserve_literals: bool, - /// This context was solved from the very expression it is used to infer — a - /// generic call's parameter type specialized by this argument. Such a context - /// is not an external observer of the argument's type, so it must not adopt - /// and lock a fluid specialization. - pub(crate) inferred_from_argument: bool, + /// How this context relates to the argument expression it is used to infer. + pub(crate) argument_origin: ArgumentContextOrigin, /// basedpython: [`Self::target`] is then the expected type of the value a *call* /// of this expression produces, carried into the callee so that /// [context-sensitive resolution] reaches a constructor @@ -704,7 +773,7 @@ impl<'db> TypeContext<'db> { Self { target: annotation, preserve_literals: false, - inferred_from_argument: false, + argument_origin: ArgumentContextOrigin::External, describes_call_result: false, } } @@ -713,7 +782,7 @@ impl<'db> TypeContext<'db> { /// annotation describes the call's result, not the callee, so it is marked /// [`Self::describes_call_result`], which hides it from [`Self::annotation`] /// and leaves only context-sensitive resolution reading it - pub(crate) fn for_callee(self) -> Self { + fn for_callee(self) -> Self { Self { target: self.annotation(), describes_call_result: true, @@ -721,6 +790,16 @@ impl<'db> TypeContext<'db> { } } + /// Whether this context was solved from the argument expression it types. + const fn inferred_from_argument(self) -> bool { + self.argument_origin.is_solved_from_argument() + } + + /// Whether the parameter this context came from spells the class's type argument out. + const fn prescribes_type_arguments(self) -> bool { + self.argument_origin.prescribes_type_arguments() + } + /// The type annotation this expression is checked against, if any. pub(crate) fn annotation(self) -> Option> { if self.describes_call_result { @@ -769,7 +848,7 @@ impl<'db> TypeContext<'db> { Self { target: self.target.map(f), preserve_literals: self.preserve_literals, - inferred_from_argument: self.inferred_from_argument, + argument_origin: self.argument_origin, describes_call_result: self.describes_call_result, } } @@ -954,6 +1033,8 @@ pub(crate) enum InferenceRegion<'db> { Definition(Definition<'db>), /// infer types for the decorators on a function [`Definition`] FunctionDecorators(Definition<'db>), + /// Infer a function's parameter default values, but not its annotations. + FunctionDefaults(Definition<'db>), /// infer deferred types for a [`Definition`] Deferred(Definition<'db>), /// infer types for an entire [`ScopeId`] @@ -967,6 +1048,7 @@ impl<'db> InferenceRegion<'db> { InferenceRegion::Expression(expression, _) => expression.scope(db), InferenceRegion::Definition(definition) | InferenceRegion::FunctionDecorators(definition) + | InferenceRegion::FunctionDefaults(definition) | InferenceRegion::Deferred(definition) => definition.scope(db), InferenceRegion::Scope(scope, _) => scope, } @@ -1437,6 +1519,9 @@ struct OtherDefinitionInferenceExtra<'db> { /// For decorated function or class definitions, the type before applying decorators. undecorated_type: Option>, + /// Input types for failed decorator applications that are checked after inference. + deferred_decorator_calls: FrozenMap>, + /// Whether synthesized dictionary-key assignments derived from the right-hand side should be /// discarded. discards_dict_key_assignments: bool, @@ -1627,6 +1712,18 @@ impl<'db> DefinitionInference<'db> { definition, ); + if let Some(DefinitionInferenceExtra::Other(extra)) = self.extra.as_deref_mut() { + for (expression, ty) in &mut extra.deferred_decorator_calls { + *ty = if let Some(previous_ty) = + previous_inference.deferred_decorator_input_type(*expression) + { + ty.cycle_normalized(db, &env, previous_ty, cycle) + } else { + ty.recursive_type_normalized(db, &env, cycle) + }; + } + } + if cycle.iteration() > crate::TAINTED_CYCLES && let Some(previous_constraints) = previous_inference .extra @@ -1700,7 +1797,7 @@ impl<'db> DefinitionInference<'db> { .get(&collection_def) } - pub(crate) fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { + fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { self.extra .as_deref()? .fluid_adoptions()? @@ -1710,7 +1807,7 @@ impl<'db> DefinitionInference<'db> { /// The creation-time type of the fluid specialization candidate defined by this /// region, with literal types retained. - pub(crate) fn fluid_creation(&self) -> Option> { + fn fluid_creation(&self) -> Option> { self.extra .as_deref() .and_then(DefinitionInferenceExtra::fluid_creation) @@ -1718,7 +1815,7 @@ impl<'db> DefinitionInference<'db> { /// The resolved event timeline of the fluid specialization candidate defined by /// this region, with cumulative solutions. - pub(crate) fn fluid_timeline(&self) -> Option<&FluidTimeline<'db>> { + fn fluid_timeline(&self) -> Option<&FluidTimeline<'db>> { self.extra .as_deref() .and_then(DefinitionInferenceExtra::fluid_timeline) @@ -1834,6 +1931,19 @@ impl<'db> DefinitionInference<'db> { } } + fn deferred_decorator_input_type( + &self, + expression: impl Into, + ) -> Option> { + match self.extra.as_deref() { + Some(DefinitionInferenceExtra::Other(extra)) => extra + .deferred_decorator_calls + .get(&expression.into()) + .copied(), + Some(_) | None => None, + } + } + pub(crate) fn function_type(&self, definition: Definition<'db>) -> Option> { let ty = if let Some(undecorated) = self.undecorated_type() { undecorated @@ -1876,6 +1986,33 @@ struct ExpressionInferenceExtra<'db> { /// Metadata for type expressions in this region. type_expression_flags: FrozenMap, + /// A comparison chain's truthiness when evaluated directly as a condition. + /// + /// Expression types describe the objects produced by evaluation, which is not always enough + /// to determine a condition's outcome. If `x < 1` returns an object with mutable truthiness, + /// `saved = x < 1 < 0` can store that object after it tests falsy; `if saved:` can then test it + /// again and get `True`. In contrast, `if x < 1 < 0:` cannot enter its body: either the first + /// comparison tests falsy or the final comparison `1 < 0` does. Its condition truthiness is + /// `AlwaysFalse`, but its value type must still include objects returned by the first comparison. + /// + /// The same distinction matters for `and`/`or`, but their operands have separate expression + /// nodes with inferred types. [`crate::reachability::analyze_condition_expression`] can + /// reconstruct their condition truthiness by recursively visiting those operands, without + /// relying on the compound expression's value type. + /// + /// A comparison chain instead has one `ExprCompare` node with the operands and operators. + /// In `x < 1 < 0`, neither `x < 1` nor `1 < 0` has its own expression node, so their result + /// types are not recorded in [`ExpressionInference::expressions`]. We retain their combined + /// condition truthiness here while those types are available during comparison inference. + /// A single comparison needs no override: there is no intermediate truthiness check. + /// + /// When an `and`/`or` condition has a comparison chain as an operand, the recursive condition + /// analysis uses this map for that operand. + /// + /// Inference normally stores only differences from the truthiness of the chain's value type. + /// Cycle recovery also retains earlier overrides to keep widening monotonic. + comparison_truthiness: FrozenMap, + /// The constraints on any collection initializers that are accessed in this region. collection_use_constraints: CollectionUseConstraints<'db>, @@ -1962,6 +2099,10 @@ impl<'db> ExpressionInference<'db> { } } + if cycle.iteration() > crate::TAINTED_CYCLES { + self.widen_comparison_truthiness(db, env, previous); + } + for (expr, ty) in &mut self.expressions { let previous_ty = previous.expression_type(*expr); *ty = ty.cycle_normalized(db, env, previous_ty, cycle); @@ -1981,6 +2122,42 @@ impl<'db> ExpressionInference<'db> { self } + /// Sparse overrides can appear or disappear as operand types change. Compare the effective + /// truthiness in both iterations, including previous-only overrides, so widening cannot make + /// a condition alternate between definite outcomes. + fn widen_comparison_truthiness( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + previous: &Self, + ) { + let comparison_truthiness: FrozenMap<_, _> = self + .extra + .iter() + .chain(previous.extra.iter()) + .flat_map(|extra| &extra.comparison_truthiness) + .map(|(expression, _)| { + let truthiness = self + .comparison_truthiness(*expression) + .unwrap_or_else(|| self.expression_type(*expression).bool(db, env)); + let previous_truthiness = previous + .comparison_truthiness(*expression) + .unwrap_or_else(|| previous.expression_type(*expression).bool(db, env)); + ( + *expression, + if truthiness == previous_truthiness { + truthiness + } else { + Truthiness::Ambiguous + }, + ) + }) + .collect(); + if comparison_truthiness.iter().next().is_some() { + self.extra.get_or_insert_default().comparison_truthiness = comparison_truthiness; + } + } + pub(crate) fn try_expression_type( &self, expression: impl Into, @@ -2007,6 +2184,17 @@ impl<'db> ExpressionInference<'db> { .is_some_and(|extra| extra.unsolved_typevar_calls.contains(&expression.into())) } + pub(crate) fn comparison_truthiness( + &self, + expression: impl Into, + ) -> Option { + self.extra + .as_deref()? + .comparison_truthiness + .get(&expression.into()) + .copied() + } + fn collection_use_constraints( &self, collection_def: Definition<'db>, @@ -2017,7 +2205,7 @@ impl<'db> ExpressionInference<'db> { .get(&collection_def) } - pub(crate) fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { + fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { self.extra .as_ref()? .fluid_adoptions @@ -2027,13 +2215,13 @@ impl<'db> ExpressionInference<'db> { /// The creation-time type of the fluid specialization candidate whose assigned /// value is this region, with literal types retained. - pub(crate) fn fluid_creation(&self) -> Option> { + fn fluid_creation(&self) -> Option> { self.extra.as_ref().and_then(|extra| extra.fluid_creation) } /// The resolved event timeline of the fluid specialization candidate whose /// assigned value is this region, with cumulative solutions. - pub(crate) fn fluid_timeline(&self) -> Option<&FluidTimeline<'db>> { + fn fluid_timeline(&self) -> Option<&FluidTimeline<'db>> { self.extra .as_ref() .and_then(|extra| extra.fluid_timeline.as_ref()) @@ -2081,7 +2269,7 @@ impl<'db> StatementInference<'db> { } } - pub(crate) fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { + fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { match self { StatementInference::Expression(inference) => inference.fluid_adoption(use_expression), StatementInference::Definition(_, inference) => { @@ -2241,7 +2429,7 @@ impl<'db> StatementInferenceInner<'db> { .get(&collection_def) } - pub(crate) fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { + fn fluid_adoption(&self, use_expression: ExpressionNodeKey) -> Option> { self.extra .as_ref()? .fluid_adoptions diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 74c5d7020d..c0449927ab 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use compact_str::CompactString; use itertools::Itertools; -use ruff_db::diagnostic::{Annotation, Diagnostic, Span}; +use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; use ruff_db::files::File; use ruff_db::parsed::ParsedModuleRef; use ruff_db::source::source_text; @@ -50,21 +50,22 @@ use crate::place_load::{ PlaceLoadResolutionStep, PlaceLoadSource, PlaceLoadSourceKind, resolve_place_load, }; use crate::reachability::{ - ReachabilityEvaluationCache, analyze_pattern_predicate, evaluate_reachability, - evaluate_reachability_with_cache, is_reachable, + ReachabilityEvaluationCache, analyze_condition_expression, analyze_pattern_predicate, + evaluate_reachability, evaluate_reachability_with_cache, is_reachable, }; use crate::subscript::PyIndex; use crate::types::add_inferred_python_version_hint_to_diagnostic; use crate::types::attribute_write::{AssignmentAttributeMembers, assignment_attribute_members}; use crate::types::call::bind::{ - ArgumentTypeContext, CheckTypesMode, OverloadSet, requires_overload_evaluation, + ArgumentTypeContext, CallableDescription, CheckTypesMode, OverloadSet, + requires_overload_evaluation, }; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; +use crate::types::callable::CallableTypeKind; use crate::types::class::{ - ClassLiteral, CodeGeneratorKind, DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, - DynamicTypedDictAnchor, DynamicTypedDictLiteral, FrozenDataclassDispatch, MethodDecorator, - NamedTupleField, NamedTupleSpec, StaticClassLiteral, + ClassLiteral, CodeGeneratorKind, DynamicClassScopeOffset, DynamicNamedTupleAnchor, + DynamicNamedTupleLiteral, DynamicTypedDictAnchor, DynamicTypedDictLiteral, + FrozenDataclassDispatch, MethodDecorator, NamedTupleField, NamedTupleSpec, StaticClassLiteral, }; use crate::types::constraints::{ConstraintSetBuilder, PathBounds, Solutions}; use crate::types::context::InferContext; @@ -73,9 +74,10 @@ use crate::types::dedicated::{django, pydantic}; use crate::types::deferred::{is_integer_operand, is_symbolic_operand}; use crate::types::diagnostic::{ self, AMBIGUOUS_EXTENSION_MEMBER, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, - CYCLIC_TYPE_ALIAS_DEFINITION, ERASED_CAST_ARGUMENT, ERASED_TYPE_CHECK, FINAL_ON_VARIABLE, - GeneratorMismatchKind, IMPLICIT_DECLARATION, INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, - INVALID_ASSIGNMENT, INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, INVALID_FIELD_LOOKUP, + CYCLIC_TYPE_ALIAS_DEFINITION, DYNAMIC_FUNCTION_DECORATOR_RETURN, ERASED_CAST_ARGUMENT, + ERASED_TYPE_CHECK, FINAL_ON_VARIABLE, GeneratorMismatchKind, IMPLICIT_DECLARATION, + INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, + INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, INVALID_FIELD_LOOKUP, INVALID_LEGACY_TYPE_VARIABLE, INVALID_NEWTYPE, INVALID_PARAMSPEC, INVALID_REGEX, INVALID_REIFIED_TYPE_PARAM, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPE_VARIABLE_DEFAULT, INVALID_VARIANCE_DECLARATION, @@ -84,14 +86,15 @@ use crate::types::diagnostic::{ POSSIBLY_MISSING_SUBMODULE, REFUTABLE_DESTRUCTURING, REFUTABLE_UNPACKING, TRAILING_LAMBDA_PARAMETERS, TypeCheckDiagnostics, UNANNOTATED_MODEL_FIELD, UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, - UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSOUND_CAST, UNSOUND_YIELD, + UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSOUND_ASSIGNMENT, UNSOUND_CAST, UNSOUND_YIELD, UNSPECIALIZED_REIFIED_GENERIC, UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, YieldKind, - display_required_elements, hint_if_stdlib_attribute_exists_on_other_versions, - refutable_unpacking_applies, report_attempted_protocol_instantiation, - report_bad_dunder_delattr_call, report_bad_dunder_delete_call, report_bool_as_int, - report_bool_as_int_assignment, report_call_to_abstract_method, - report_cannot_pop_required_field_on_typed_dict, report_capturing_case_name, - report_capturing_case_name_alternative, report_invalid_assignment, + autofix_with_notimplementederror, display_required_elements, + hint_if_stdlib_attribute_exists_on_other_versions, refutable_unpacking_applies, + report_attempted_protocol_instantiation, report_bad_dunder_delattr_call, + report_bad_dunder_delete_call, report_bool_as_int, report_bool_as_int_assignment, + report_call_to_abstract_method, report_cannot_pop_required_field_on_typed_dict, + report_capturing_case_name, report_capturing_case_name_alternative, + report_dynamic_function_decorator_return, report_invalid_assignment, report_invalid_class_match_pattern, report_invalid_exception_caught, report_invalid_exception_cause, report_invalid_exception_raised, report_invalid_exception_tuple_caught, report_invalid_generator_yield_type, @@ -101,20 +104,21 @@ use crate::types::diagnostic::{ report_match_pattern_against_typed_dict, report_mismatched_type_name, report_possibly_missing_attribute, report_possibly_unresolved_reference, report_too_many_positional_patterns_for_class_pattern, - report_unplaceable_starred_class_pattern, report_unsound_yield, + report_unplaceable_starred_class_pattern, report_unsound_assignment, report_unsound_yield, report_unsupported_augmented_assignment, report_unsupported_comparison, }; use crate::types::enums::{enum_ignored_names, is_enum_class_by_inheritance}; use crate::types::extensions; use crate::types::format; use crate::types::function::{ - FunctionDecorators, FunctionType, KnownFunction, report_revealed_type, + FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral, report_revealed_type, same_module_uncached_raw_signature, }; use crate::types::generics::{ GenericContext, Specialization, SpecializationBuilder, bind_typevar, enclosing_binding_contexts, }; use crate::types::implicit_names::implicit_name; +use crate::types::infer::builder::binary_expressions::BinaryInferenceState; use crate::types::infer::builder::named_tuple::NamedTupleKind; use crate::types::infer::builder::paramspec_validation::validate_paramspec_components; use crate::types::infer::{ @@ -149,29 +153,33 @@ use crate::types::typed_dict::{TypedDictAssignmentKind, TypedDictKeyAssignment}; use crate::types::typevar::{ BoundTypeVarIdentity, TypeVarConstraints, TypeVarIdentity, TypeVarInstance, TypeVarSet, }; -use crate::types::unpacker::UnpackResult; +use crate::types::unpacker::{ + UnpackResult, fixed_sequence_elements, sequence_from_literal_elements, + tuple_literal_needs_promotion, +}; use crate::types::{ BindingContext, BoundTypeVarInstance, CallDunderError, CallableBinding, CallableType, - CallableTypes, ClassType, DeferredOperation, DeferredType, DynamicType, InferenceFlags, - InstanceProjection, InternedConstraintSet, InternedType, IntersectionBuilder, IntersectionType, - KnownClass, KnownInstanceType, KnownUnion, LiteralValueType, LiteralValueTypeKind, - MemberLookupPolicy, ParamSpecAttrKind, Parameter, Parameters, ProgramEnvironment, - RestrictedType, SentinelInstance, Signature, SpecialFormType, SubclassOfType, Type, - TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, TypeVarBoundOrConstraints, - TypeVarKind, TypeVarVariance, TypedDictModule, TypedDictType, UnionAccumulator, UnionBuilder, - UnionType, any_over_type, binding_type, extract_fixed_length_iterable_element_types, - infer_complete_scope_types, infer_scope_types, is_discarded_dict_key_assignment, - report_iteration_over_character, todo_type, + CallableTypes, ClassType, DeferredOperation, DeferredType, DynamicType, GeneratorTypeMode, + InferenceFlags, InstanceProjection, InternedConstraintSet, InternedType, IntersectionBuilder, + IntersectionType, KnownBoundMethodType, KnownClass, KnownInstanceType, KnownUnion, + LiteralValueType, LiteralValueTypeKind, MemberLookupPolicy, ParamSpecAttrKind, Parameter, + Parameters, ProgramEnvironment, PropertyDeprecations, RestrictedType, SentinelInstance, + Signature, SpecialFormType, SubclassOfType, Type, TypeAliasType, TypeAndQualifiers, + TypeContext, TypeQualifiers, TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, + TypedDictType, TypingModule, UnionAccumulator, UnionBuilder, UnionType, any_over_type, + binding_type, extract_fixed_length_iterable_element_types, infer_complete_scope_types, + infer_scope_types, is_discarded_dict_key_assignment, report_iteration_over_character, + todo_type, }; -use crate::{AnalysisSettings, Db, FxIndexSet, FxOrderSet}; +use crate::{AnalysisSettings, Db, DisplaySettings, FxIndexSet, FxOrderSet, SemanticModel}; use fluid::FluidTimeline; use ty_python_core::BlockScopedDeclaration; use ty_python_core::definition::{ - AnnotatedAssignmentDefinitionKind, AssignmentDefinitionKind, ComprehensionDefinitionKind, - Definition, DefinitionKind, DefinitionNodeKey, DefinitionState, ExceptHandlerDefinitionKind, - ForStmtDefinitionKind, LambdaParameterDefinitionNodeKind, LoopHeaderDefinitionKind, - NestedBindingExecution, NestedBindingsDefinitionKind, ParameterDefinitionNodeKind, TargetKind, - WithItemDefinitionKind, + AnnotatedAssignmentDefinitionKind, AssignmentDefinitionKind, BindingsOwner, + ComprehensionDefinitionKind, Definition, DefinitionKind, DefinitionNodeKey, DefinitionState, + ExceptHandlerDefinitionKind, ForStmtDefinitionKind, LambdaParameterDefinitionNodeKind, + LoopHeaderDefinitionKind, NestedBindingExecution, NestedBindingsDefinitionKind, + ParameterDefinitionNodeKind, TargetKind, WithItemDefinitionKind, }; use ty_python_core::expression::{Expression, ExpressionKind}; use ty_python_core::narrowing_constraints::ConstraintKey; @@ -188,7 +196,7 @@ use ty_python_core::{ExpressionNodeKey, Statement}; mod annotation_expression; mod attribute_assignment; -mod binary_expressions; +pub(crate) mod binary_expressions; pub(crate) use binary_expressions::{ fold_tuple_concat, fold_tuple_repeat, literal_binary_op, literal_unary_op, }; @@ -352,6 +360,11 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// The types of every expression in this region. expressions: FxHashMap>, + /// Truthiness overrides for evaluating comparison chains directly as conditions. + /// See [`ExpressionInferenceExtra::comparison_truthiness`] for why these are stored + /// separately from expression types. + comparison_truthiness: FxHashMap, + /// An expression cache shared across builders during multi-inference. expression_cache: Option>>>, @@ -497,6 +510,12 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// For decorated function or class definitions, the type before applying decorators. undecorated_type: Option>, + /// Input types for failed decorator applications, keyed by decorator expression. + /// + /// Recheck these calls after inference so formatting diagnostics cannot pull deferred + /// function defaults into definition inference cycles. + deferred_decorator_calls: Vec<(ExpressionNodeKey, Type<'db>)>, + /// The fallback type for missing expressions/bindings/declarations or recursive type inference. cycle_recovery: Option>, @@ -624,6 +643,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { called_functions: FxIndexSet::default(), deferred_state: DeferredExpressionState::None, expressions: FxHashMap::default(), + comparison_truthiness: FxHashMap::default(), expression_cache: None, reachability_cache: OnceCell::new(), qualifiers: FxHashMap::default(), @@ -643,6 +663,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { typevar_binding_context: None, deferred: VecSet::default(), undecorated_type: None, + deferred_decorator_calls: Vec::new(), cycle_recovery: None, discards_dict_key_assignments: false, dataclass_field_specifiers: SmallVec::new(), @@ -678,9 +699,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { fn recursive_type_expression_definition(&self) -> Option> { self.typevar_binding_context.or(match self.region { - InferenceRegion::Definition(definition) | InferenceRegion::Deferred(definition) => { - Some(definition) - } + InferenceRegion::Definition(definition) + | InferenceRegion::FunctionDefaults(definition) + | InferenceRegion::Deferred(definition) => Some(definition), InferenceRegion::Statement(_) | InferenceRegion::Expression(_, _) | InferenceRegion::FunctionDecorators(_) @@ -715,8 +736,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { #[cfg(debug_assertions)] assert_eq!(self.scope, inference.scope); - self.expressions - .extend(inference.expressions.iter().copied()); + self.extend_expression_types(inference.expressions.iter().copied()); self.declarations.extend(inference.declarations(definition)); if !matches!(self.region, InferenceRegion::Scope(..)) { @@ -794,8 +814,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { #[cfg(debug_assertions)] assert_eq!(self.scope, inference.scope); - self.expressions - .extend(inference.expressions.iter().copied()); + self.extend_expression_types(inference.expressions.iter().copied()); self.declarations.extend(inference.declarations()); if !matches!(self.region, InferenceRegion::Scope(..)) { @@ -828,10 +847,38 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn extend_expression_unchecked(&mut self, inference: &ExpressionInference<'db>) { - self.expressions - .extend(inference.expressions.iter().copied()); + self.extend_expression_without_bindings(inference); + + if let Some(extra) = &inference.extra + && !matches!(self.region, InferenceRegion::Scope(..)) + { + self.bindings.extend(extra.bindings.iter().copied()); + } + } + + /// Replacing an expression's type also replaces any truthiness override. A newly inferred + /// comparison may no longer need an override, so extending the sparse map alone is not enough. + fn extend_expression_types( + &mut self, + expressions: impl IntoIterator)>, + ) { + if self.comparison_truthiness.is_empty() { + self.expressions.extend(expressions); + } else { + for (expression, ty) in expressions { + self.expressions.insert(expression, ty); + self.comparison_truthiness.remove(&expression); + } + } + } + + /// Merges expression results without claiming bindings owned by their enclosing statement. + fn extend_expression_without_bindings(&mut self, inference: &ExpressionInference<'db>) { + self.extend_expression_types(inference.expressions.iter().copied()); if let Some(extra) = &inference.extra { + self.comparison_truthiness + .extend(extra.comparison_truthiness.iter().copied()); self.context.extend(&extra.diagnostics); self.extend_cycle_recovery(extra.cycle_recovery); self.called_functions @@ -855,10 +902,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } self.fluid_adoptions.extend(extra.fluid_adoptions.iter()); - - if !matches!(self.region, InferenceRegion::Scope(..)) { - self.bindings.extend(extra.bindings.iter().copied()); - } } } @@ -866,8 +909,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { #[cfg(debug_assertions)] assert_eq!(self.scope, inference.scope); - self.expressions - .extend(inference.expressions.iter().map(|(key, ty)| (*key, *ty))); + self.extend_expression_types(inference.expressions.iter().map(|(key, ty)| (*key, *ty))); + self.comparison_truthiness.extend( + inference + .comparison_truthiness + .iter() + .map(|(key, truthiness)| (*key, *truthiness)), + ); self.context.extend(&inference.diagnostics); self.extend_cycle_recovery(inference.cycle_recovery); self.called_functions @@ -910,7 +958,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn extend_scope(&mut self, inference: &ScopeInference<'db>) { - self.expressions.extend(inference.expressions.iter()); + self.extend_expression_types(inference.expressions.iter()); if let Some(extra) = &inference.extra { self.context.extend(&extra.diagnostics); @@ -1006,6 +1054,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .is_in_type_checking_block(scope.file_scope_id(self.db()), node.range()) } + /// Returns whether the current scope is the body of a dataclass or dataclass-transform class. + /// + /// Methods and nested functions have separate scopes and are not considered class bodies. + fn is_in_dataclass_like_class_body(&self) -> bool { + let db = self.db(); + let scope = self.scope(); + + self.index.scope(scope.file_scope_id(db)).kind() == ScopeKind::Class + && nearest_enclosing_class(db, self.index, scope) + .and_then(|class| CodeGeneratorKind::from_class(db, class.into())) + .is_some_and(|kind| { + matches!( + kind, + CodeGeneratorKind::DataclassLike(_) | CodeGeneratorKind::Pydantic(_) + ) + }) + } + /// If the current scope is a class body scope of a dataclass-like class, populate /// `self.dataclass_field_specifiers` with the field specifiers from the class's /// `dataclass_params` or `dataclass_transform` parameters. This is needed so that @@ -1087,6 +1153,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred_state.in_string_annotation() } + /// Temporarily changes lookup behavior without discarding the current string annotation. + /// + /// Parsed string nodes do not belong to the module's semantic index, so their enclosing + /// annotation must remain available even when nested expressions request another lookup mode. + fn replace_deferred_state( + &mut self, + state: DeferredExpressionState, + ) -> DeferredExpressionState { + let previous = self.deferred_state; + if !previous.in_string_annotation() { + self.deferred_state = state; + } + previous + } + /// Returns `true` if `expr` is a call to a known diagnostic function /// (e.g., `reveal_type` or `assert_type`) whose return value should not /// trigger the `unused-awaitable` lint. @@ -1203,6 +1284,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { InferenceRegion::FunctionDecorators(definition) => { self.infer_region_function_decorators(definition); } + InferenceRegion::FunctionDefaults(definition) => { + if let DefinitionKind::Function(function) = definition.kind(self.db()) { + self.infer_function_defaults(definition, function.node(self.module())); + } + } InferenceRegion::Deferred(definition) => self.infer_region_deferred(definition), InferenceRegion::Expression(expression, tcx) => { self.infer_region_expression(expression, tcx); @@ -1261,7 +1347,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Infer deferred types for all definitions. let deferred_definitions: Vec<_> = std::mem::take(&mut self.deferred).into_iter().collect(); for definition in &deferred_definitions { - self.extend_definition(*definition, infer_deferred_types(self.db(), *definition)); + if let DefinitionKind::Function(function) = definition.kind(self.db()) { + self.extend_function_deferred(*definition, function.node(self.module())); + } else { + self.extend_definition(*definition, infer_deferred_types(self.db(), *definition)); + } } assert!( @@ -1277,6 +1367,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let ty = ty_and_quals.inner_type(); match definition.kind(self.db()) { DefinitionKind::Function(function) => { + post_inference::decorator::check_decorator_calls( + &self.context, + definition, + &function.node(self.module()).decorator_list, + ); post_inference::function::check_function_definition( &self.context, definition, @@ -1298,6 +1393,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } DefinitionKind::Class(class_node) => { + post_inference::decorator::check_decorator_calls( + &self.context, + definition, + &class_node.node(self.module()).decorator_list, + ); let original_ty = match self.region { InferenceRegion::Definition(current) if current == definition => { self.undecorated_type @@ -1690,7 +1790,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { match definition.kind(self.db()) { DefinitionKind::Function(function) => { - self.infer_function_deferred(definition, function.node(self.module())); + self.infer_function_annotations(definition, function.node(self.module())); } DefinitionKind::Class(class) => { self.infer_class_deferred(definition, class.node(self.module())); @@ -1718,6 +1818,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.setup_dataclass_field_specifiers(); match expression.kind(self.db()) { + ExpressionKind::Callee => { + self.context.inference_flags |= InferenceFlags::CHECK_UNBOUND_TYPEVARS; + self.infer_expression_impl(expression.node_ref(self.db()).node(self.module()), tcx); + } ExpressionKind::Normal => { self.infer_expression_impl(expression.node_ref(self.db()).node(self.module()), tcx); } @@ -1773,7 +1877,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { declarations, self.reachability_cache(), ); - let declaration = result.first_declaration; let (mut place_and_quals, conflicting) = result.into_place_and_conflicting_declarations(); if let Some(conflicting) = conflicting { @@ -1807,6 +1910,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { qualifiers, } = place_and_quals; + let declaration = match resolved_place { + Place::Defined(DefinedPlace { provenance, .. }) => provenance + .definition() + .filter(|declaration| declaration.file(db) == self.context.file()), + Place::Undefined => None, + }; + let declared_ty = if resolved_place.is_undefined() && !place.is_symbol() { self.fallback_member_declared_type(node) } else { @@ -1816,11 +1926,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { AddBinding { declared_ty, + declaration, binding, node, qualifiers, is_local, - declaration, } } @@ -2086,7 +2196,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } let declared_type = declared_ty.inner_type(); - if inferred_ty.is_assignable_to(db, env, declared_type) { + if self.validate_assignment_type(node, definition, None, declared_type, inferred_ty) + { report_bool_as_int_assignment( &self.context, node, @@ -2108,13 +2219,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } else { self.discard_dict_key_assignments_for(definition); - report_invalid_assignment( - &self.context, - node, - definition, - declared_type, - inferred_ty, - ); // if the assignment is invalid, fall back to assuming the annotation is correct (declared_ty, declared_type) @@ -2126,6 +2230,59 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.bindings.insert(definition, inferred_ty); } + /// Checks an assigned value against its target's declared type and reports any mismatch. + /// + /// Returns `true` when the value is assignable, even if the stricter `unsound-assignment` + /// rule reports that it is not a subtype. Returns `false` when the value is not assignable, + /// in which case `invalid-assignment` is reported instead. + /// + /// The `unsound-assignment` rule is deliberately limited to name bindings; assignments to + /// attributes and subscripts are outside its scope. + fn validate_assignment_type( + &self, + target_node: AnyNodeRef, + definition: Definition<'db>, + declaration: Option>, + target_ty: Type<'db>, + value_ty: Type<'db>, + ) -> bool { + let db = self.db(); + let env = self.program_environment(); + + if !value_ty.is_assignable_to(db, env, target_ty) { + report_invalid_assignment( + &self.context, + target_node, + definition, + declaration, + target_ty, + value_ty, + ); + return false; + } + + // N.B. the implementation here is the ~same as for `UNSOUND_YIELD` and `UNSOUND_RETURN_STATEMENT`; + // update those too if updating this! + if self.context.is_lint_enabled(&UNSOUND_ASSIGNMENT) + && !self.file().is_stub(db) + && target_ty.is_fully_static(db, env) + && !self.is_in_dataclass_like_class_body() + && !value_ty.is_pure_redundant_with(db, env, target_ty) + { + report_unsound_assignment( + &self.context, + target_node, + definition, + declaration, + target_ty, + value_ty, + |expression| self.expression_type(expression), + ); + } + + true + } + fn add_unknown_declaration_with_binding( &mut self, node: AnyNodeRef, @@ -2176,7 +2333,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn infer_type_alias(&mut self, type_alias: &ast::StmtTypeAlias) { - let db = self.db(); let previous_check_unbound_typevars = self .context .inference_flags @@ -2186,7 +2342,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // against, and an unpacked pack (`match *Shape:`) is the ordinary way to write one. // an ordinary alias's value is left alone — its flags are whatever the surrounding // inference set, and forcing one either way here would change unrelated behaviour - let value_ty = if type_alias.cases.is_empty() { + let _value_ty = if type_alias.cases.is_empty() { self.infer_type_expression(&type_alias.value) } else { let previously_in_valid_unpack_context = self @@ -2209,34 +2365,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { previous_check_unbound_typevars, ); - // A type alias where a value type points to itself, i.e. the expanded type is `Divergent` is meaningless - // (but a type alias that expands to something like `list[Divergent]` may be a valid recursive type alias) - // and would lead to infinite recursion. Therefore, such type aliases should not be exposed. - // ```python - // type Itself = Itself # error: "Cyclic definition of `Itself`" - // type A = B # error: "Cyclic definition of `A`" - // type B = A # error: "Cyclic definition of `B`" - // type G[T] = G[T] # error: "Cyclic definition of `G`" - // type RecursiveList[T] = list[T | RecursiveList[T]] # OK - // type RecursiveList2[T] = list[RecursiveList2[T]] # It's not possible to create an element of this, but it's not an error for now - // type IntOr = int | IntOr # It's redundant, but OK for now - // type IntOrStr = int | StrOrInt # It's redundant, but OK - // type StrOrInt = str | IntOrStr # It's redundant, but OK - // ``` + if let Some(name) = type_alias.name.as_name_expr() { + self.check_type_alias_cycle(&name.id, &type_alias.value, type_alias); + } + } + + /// Check both alias syntaxes, including union members that disappear during expansion. + fn check_type_alias_cycle(&mut self, name: &str, value: &ast::Expr, node: impl Ranged) { + let db = self.db(); + let value_ty = self.expression_type(value); let expanded = value_ty.expand_eagerly(db, self.program_environment()); - if expanded.is_divergent() { - if let Some(builder) = self + if (expanded.is_divergent() || value_ty.has_unguarded_alias_cycle(db)) + && let Some(builder) = self .context - .report_lint(&CYCLIC_TYPE_ALIAS_DEFINITION, type_alias) - { - builder.into_diagnostic(format_args!( - "Cyclic definition of `{}`", - type_alias.name.as_name_expr().unwrap().id, - )); - } - // Replace with `Divergent`. - self.expressions - .insert(type_alias.value.as_ref().into(), expanded); + .report_lint(&CYCLIC_TYPE_ALIAS_DEFINITION, node) + { + builder.into_diagnostic(format_args!("Cyclic definition of `{name}`")); + } + if expanded.is_divergent() { + // Preserve the dynamic recovery type for aliases that cannot be expanded at all. + // Union cycles retain their non-recursive members for recovery. + self.expressions.insert(value.into(), expanded); } } @@ -2817,8 +2966,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { // Call into the context expression inference to validate that it evaluates // to a valid context manager. - let context_expression_ty = - self.infer_expression(&item.context_expr, TypeContext::default()); + let context_expression_ty = self + .infer_maybe_standalone_expression(&item.context_expr, TypeContext::default()); self.infer_context_expression(&item.context_expr, context_expression_ty, *is_async); self.infer_optional_expression(target, TypeContext::default()); } @@ -2925,12 +3074,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && let Some(node) = node { if let ast::Expr::Tuple(tuple) = node - && !tuple.iter().any(ast::Expr::is_starred_expr) - && Some(tuple.len()) == tuple_spec.len().into_fixed_length() + && let Some(tuple_length) = tuple_spec.len().into_fixed_length() + && let Some(elements) = fixed_sequence_elements(node, tuple_length) { let invalid_elements = invalid_elements .iter() - .map(|(index, ty)| (&tuple.elts[*index], *ty)); + .map(|(index, ty)| (&elements[*index], *ty)); report_invalid_exception_tuple_caught( &self.context, @@ -3116,7 +3265,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { nested_bindings_kind: &NestedBindingsDefinitionKind, definition: Definition<'db>, ) { - const MAX_EXACT_NESTED_BINDING_REACHABILITY_NODES: usize = 2048; + const MAX_EXACT_NESTED_BINDING_REACHABILITY_NODES: usize = 4096; let db = self.db(); let scope_id = definition.file_scope(db); @@ -3546,19 +3695,44 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { decorator_list: _, } = assignment; + if let [ast::Expr::Name(name)] = targets.as_slice() { + // the single-name fast path skips the target walk, so the two things that walk does + // for a bare name happen here: `x = 1` is the shape `implicit-declaration` is about, + // and inside a trailing lambda block it writes the receiver's member rather than + // binding anything + self.report_implicit_declaration(&targets[0]); + self.infer_definition(name); + self.validate_receiver_member_write(name, value); + return; + } + + let shared_value = self.index.expression(value.as_ref()); + + if !matches!(self.region, InferenceRegion::Scope(..)) { + // The statement owns every binding created while evaluating its shared value, + // including assignment expressions in lambda defaults. + let inference = infer_expression_types(self.db(), shared_value, TypeContext::default()); + if let Some(extra) = &inference.extra { + self.bindings.extend(extra.bindings.iter().copied()); + } + } + for target in targets { self.report_implicit_declaration(target); if let Some(unpack) = self.index.try_unpack(target) { - // Infer the standalone expression here to include its diagnostics in this region. - self.infer_standalone_expression(value, TypeContext::default()); + let inference = + infer_expression_types(self.db(), shared_value, TypeContext::default()); + self.extend_expression_without_bindings(inference); let unpacked = infer_unpack_types(self.db(), unpack); self.context.extend(unpacked.diagnostics()); self.infer_unpacked_assignment_target(target, value, unpacked); } else { self.infer_target(target, value, &|builder, tcx| { - builder.infer_standalone_expression(value, tcx) + let inference = infer_expression_types(builder.db(), shared_value, tcx); + builder.extend_expression_without_bindings(inference); + inference.expression_type(value.as_ref()) }); } } @@ -3726,6 +3900,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::SubclassOf(..) | Type::KnownInstance(..) | Type::PropertyInstance(..) + | Type::SlotDescriptor(..) | Type::FunctionLiteral(..) | Type::Callable(..) | Type::BoundMethod(_) @@ -4220,17 +4395,23 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let value_ty = if let Some(standalone_expression) = self.index.try_expression(value) { - self.infer_standalone_expression_impl(value, standalone_expression, tcx) + let inference = infer_expression_types(self.db(), standalone_expression, tcx); + match assignment.owner() { + BindingsOwner::Definition => { + self.extend_expression(inference); + } + BindingsOwner::Statement => { + self.extend_expression_without_bindings(inference); + } + } + inference.expression_type(value) } else if let ast::Expr::Call(call_expr) = value && call_expr.cast_kind.is_none() { // If the RHS is not a standalone expression, this is a simple assignment // (single target, no unpackings). That means it's a valid syntactic form // for a legacy TypeVar creation; check for that. - let callable_type = self.infer_maybe_standalone_expression( - call_expr.func.as_ref(), - TypeContext::default(), - ); + let callable_type = self.infer_callee(&call_expr.func, TypeContext::default()); let ty = if let Some(namedtuple_kind) = NamedTupleKind::from_type(self.db(), callable_type) @@ -4241,7 +4422,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { namedtuple_kind, ) } else if let Some(typed_dict_module) = - TypedDictModule::from_type(self.db(), callable_type) + TypingModule::from_typed_dict_type(self.db(), callable_type) { self.infer_typeddict_call_expression( call_expr, @@ -4299,8 +4480,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // signalling that we must fall back to normal call inference. self.infer_builtins_type_call(call_expr, Some(definition)) } - Some(KnownClass::TypeAliasType) => { - self.infer_typealiastype_call(target, call_expr, definition) + Some(known_class) + if let Some(typing_module) = + TypingModule::from_type_alias_class(known_class) => + { + self.infer_typealiastype_call( + target, + call_expr, + definition, + typing_module, + ) } Some(KnownClass::Sentinel) => self .infer_sentinel_expression(target, call_expr, definition) @@ -4578,8 +4767,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_newtype_assignment_deferred(arguments); return; } - (Some(KnownClass::TypeAliasType), InferenceRegion::Deferred(definition)) => { - self.infer_typealiastype_assignment_deferred(definition, arguments); + ( + Some(KnownClass::TypeAliasType | KnownClass::ExtensionsTypeAliasType), + InferenceRegion::Deferred(definition), + ) => { + self.infer_typealiastype_assignment_deferred(definition, target, arguments); return; } (Some(KnownClass::Type), InferenceRegion::Deferred(definition)) => { @@ -4588,7 +4780,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } _ => {} } - if TypedDictModule::from_type(self.db(), func_ty).is_some() { + if TypingModule::from_typed_dict_type(self.db(), func_ty).is_some() { self.infer_functional_typeddict_deferred(arguments); return; } @@ -4722,6 +4914,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target: &ast::Expr, call_expr: &ast::ExprCall, definition: Definition<'db>, + typing_module: TypingModule, ) -> Type<'db> { fn error<'db>( context: &InferContext<'db, '_>, @@ -4793,7 +4986,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::KnownInstance(KnownInstanceType::TypeAliasType( TypeAliasType::ManualPEP695(ManualPEP695TypeAliasType::new( - db, name, definition, None, None, + db, + name, + definition, + typing_module, + None, + None, )), )) } @@ -4802,6 +5000,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { fn infer_typealiastype_assignment_deferred( &mut self, definition: Definition<'db>, + target: &ast::Expr, arguments: &ast::Arguments, ) { let db = self.db(); @@ -4965,6 +5164,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } self.typevar_binding_context = previous_context; + if let Some(name) = target.as_name_expr() { + self.check_type_alias_cycle(&name.id, &arguments.args[1], &arguments.args[1]); + } } fn is_valid_receiver_annotation_target(&self, target: &ast::Expr) -> bool { @@ -5242,11 +5444,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let node = target.into(); let add = AddBinding { declared_ty: self.fallback_member_declared_type(node), + declaration: None, binding: definition, node, qualifiers: TypeQualifiers::empty(), is_local: true, - declaration: None, }; let target_ty = if let Some(value) = value { // Infer the value as an ordinary assignment without using the rejected annotation @@ -5630,7 +5832,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if is_pep_613_type_alias { self.context.inference_flags |= InferenceFlags::IN_PEP_613_ALIAS_FIRST_PASS; if self.in_stub() { - self.deferred_state = DeferredExpressionState::Deferred; + self.replace_deferred_state(DeferredExpressionState::Deferred); } } @@ -5831,6 +6033,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target_type: Type<'db>, value_expr: &ast::Expr, infer_value_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, + state: &mut BinaryInferenceState<'db>, ) -> Result, Type<'db>> { let db = self.db(); let env = self.program_environment(); @@ -5839,30 +6042,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let op = assignment.op; // Fall back to non-augmented binary operator inference. - let binary_return_ty = |builder: &mut Self, value_ty| { - builder - .infer_binary_expression_type( - assignment.into(), - false, - target_type, - value_ty, - op, - TypeContext::default(), - ) - // an extension's operator dunder is deliberately *not* consulted - // here: an augmented assignment has no lowering to the backing - // function (rewriting `a += b` re-evaluates the target), so - // accepting it would put the checker and the runtime at odds - .ok_or_else(|| { - report_unsupported_augmented_assignment( - &builder.context, - assignment, + let binary_return_ty = + |builder: &mut Self, value_ty, state: &mut BinaryInferenceState<'db>| { + builder + .infer_binary_expression_type( + assignment.into(), target_type, value_ty, - ); - Type::unknown() - }) - }; + op, + TypeContext::default(), + state, + ) + // an extension's operator dunder is deliberately *not* consulted + // here: an augmented assignment has no lowering to the backing + // function (rewriting `a += b` re-evaluates the target), so + // accepting it would put the checker and the runtime at odds + .ok_or_else(|| { + report_unsupported_augmented_assignment( + &builder.context, + assignment, + target_type, + value_ty, + ); + Type::unknown() + }) + }; match target_type { Type::Union(union) => { @@ -5879,6 +6083,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { elem_type, value_expr, &mut |builder, tcx| infer_value_ty.infer_silent(builder, tcx), + state, ) { Ok(ty) => ty, Err(recovery_ty) => { @@ -5920,16 +6125,28 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeContext::default(), ); match call { - Ok(outcome) => Ok(outcome.return_type(db, env)), + Ok(outcome) => { + state.deprecated_functions.extend( + outcome + .deprecated_functions(db) + .map(|(_, function)| function), + ); + Ok(outcome.return_type(db, env)) + } Err(CallDunderError::MethodNotAvailable) => { let value_ty = infer_value_ty(self, TypeContext::default()); - binary_return_ty(self, value_ty) + binary_return_ty(self, value_ty, state) } Err(CallDunderError::PossiblyUnbound { bindings: outcome, .. }) => { + state.deprecated_functions.extend( + outcome + .deprecated_functions(db) + .map(|(_, function)| function), + ); let value_ty = outcome.type_for_argument(&call_arguments, 0); - match binary_return_ty(self, value_ty) { + match binary_return_ty(self, value_ty, state) { Ok(binary_ty) => Ok(UnionType::from_two_elements( db, env, @@ -6006,10 +6223,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let target_type = target_result.unwrap_or_else(|recovery_ty| recovery_ty); - let operation_result = - self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { - builder.infer_expression(value, tcx) - }); + let mut state = BinaryInferenceState::default(); + let operation_result = self.infer_augmented_op( + assignment, + target_type, + value, + &mut |builder, tcx| builder.infer_expression(value, tcx), + &mut state, + ); + self.report_deprecated_functions(assignment, state.deprecated_functions); match (target_result, operation_result) { (Ok(_), Ok(result_ty)) => Ok(result_ty), @@ -6484,40 +6706,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { decorator_ty: Type<'db>, decorated_ty: Type<'db>, decorator_node: &ast::Decorator, + decorated_function: Option<&ast::StmtFunctionDef>, ) -> Type<'db> { fn propagate_callable_kind<'d>( db: &'d dyn Db, env: &ProgramEnvironment<'d>, ty: Type<'d>, kind: CallableTypeKind, - provenance: CallableFunctionProvenance, ) -> Option> { match ty { // parameter-only marker; behaves as the type a body sees (bound of `Key`) - Type::Overlapping(overlapping) => propagate_callable_kind( - db, - env, - overlapping.value_type(db, env), - kind, - provenance, - ), + Type::Overlapping(overlapping) => { + propagate_callable_kind(db, env, overlapping.value_type(db, env), kind) + } Type::Restricted(restricted) => { - propagate_callable_kind(db, env, restricted.value_type(db), kind, provenance) + propagate_callable_kind(db, env, restricted.value_type(db), kind) } Type::Deferred(deferred) => { - propagate_callable_kind(db, env, deferred.reduced(db, env), kind, provenance) + propagate_callable_kind(db, env, deferred.reduced(db, env), kind) } - Type::Callable(callable) => Some(Type::Callable(CallableType::new( - db, - callable.signatures(db), - kind, - provenance, - ))), + Type::Callable(callable) => Some(Type::Callable(callable.with_kind(db, kind))), Type::Union(union) => union.try_map(db, env, |element| { - propagate_callable_kind(db, env, *element, kind, provenance) + propagate_callable_kind(db, env, *element, kind) }), Type::TypeAlias(alias) => { - propagate_callable_kind(db, env, alias.value_type(db), kind, provenance) + propagate_callable_kind(db, env, alias.value_type(db), kind) } // Intersections are currently not handled here because that would require // the decorator to be explicitly annotated as returning an intersection. @@ -6541,6 +6754,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::LiteralValue(_) @@ -6561,21 +6775,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // computing the signature requires evaluating those defaults which may trigger // deferred inference. let propagatable_kind = match decorated_ty { - Type::FunctionLiteral(func) => Some(( - func.callable_type_kind(self.db()), - CallableFunctionProvenance::from_function_return_annotation( - func.has_explicit_return_annotation(self.db()), - ), - )), + Type::FunctionLiteral(func) => Some(func.callable_type_kind(db)), _ => decorated_ty .try_upcast_to_callable(db, env) .and_then(CallableTypes::exactly_one) .and_then(|callable| match callable.kind(self.db()) { kind @ (CallableTypeKind::FunctionLike | CallableTypeKind::StaticMethodLike - | CallableTypeKind::ClassMethodLike) => { - Some((kind, callable.provenance(self.db()))) - } + | CallableTypeKind::ClassMethodLike) => Some(kind), _ => None, }), }; @@ -6585,7 +6792,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { Ok(bindings) => (bindings.return_type(db, env), Some(bindings)), Err(CallError(_, bindings)) => { - bindings.report_diagnostics(&self.context, decorator_node.into()); + self.defer_decorator_call(decorator_node, decorated_ty); (bindings.return_type(db, env), None) } }; @@ -6604,11 +6811,43 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // classmethod-like or staticmethod-like). See "Decorating a method with // a `Callable`-typed decorator" in `callables_as_descriptors.md` for the // extended explanation. - propagatable_kind - .and_then(|(kind, provenance)| { - propagate_callable_kind(db, env, return_ty, kind, provenance) - }) - .unwrap_or(return_ty) + let inferred_ty = propagatable_kind + .and_then(|kind| propagate_callable_kind(db, env, return_ty, kind)) + .unwrap_or(return_ty); + + if let Some(decorated_function) = decorated_function + && let Some(decorator_bindings) = decorator_bindings.as_ref() + && self + .context + .is_lint_enabled(&DYNAMIC_FUNCTION_DECORATOR_RETURN) + && inferred_ty.is_equivalent_to(db, env, Type::any()) + && !decorated_ty.is_equivalent_to(db, env, Type::any()) + { + report_dynamic_function_decorator_return( + &self.context, + decorator_node, + decorated_ty, + decorator_bindings, + decorated_function, + inferred_ty, + ); + } + + inferred_ty + } + + fn defer_decorator_call(&mut self, decorator: &ast::Decorator, input_ty: Type<'db>) { + // We replay failed decorator applications after inference only to report call errors, + // such as incompatible argument types or missing arguments. `@no_type_check` suppresses + // these errors. Skip recording the calls here because the enclosing scope's post-inference + // diagnostic context does not inherit this definition-local flag. + if !self + .inference_flags() + .contains(InferenceFlags::IN_NO_TYPE_CHECK) + { + self.deferred_decorator_calls + .push(((&decorator.expression).into(), input_ty)); + } } #[expect(clippy::too_many_arguments)] @@ -6809,19 +7048,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; } - // Ensure the inferred return type is assignable to the narrowed declared type. + // A literal-valued result can span multiple members of the declared union, as with + // `TypedDict.get` on a field whose type is a literal union. If it is already a subtype + // of the complete union, there is no need to try the remaining members. Use subtyping + // for this additional check so a gradual alternative cannot bypass useful type context. // - // TODO: Checking assignability against the full declared type could help avoid - // cases where the constraint solver is not smart enough to solve complex unions. - // We should see revisit this after the new constraint solver is implemented. - if !speculative_bindings - .return_type(db, env) - .is_assignable_to(db, env, narrowed_ty) + // TODO: Revisit narrowing to individual union members. Comparing the inferred return + // type with the full declared union could avoid more redundant inference attempts, + // but must preserve the precision provided by type context. + let return_ty = speculative_bindings.return_type(db, env); + if !(return_ty.is_assignable_to(db, env, narrowed_ty) + || (return_ty + .resolve_type_alias(db) + .is_literal_or_union_of_literals(db, env) + && call_expression_tcx + .annotation() + .is_some_and(|declared_ty| return_ty.is_subtype_of(db, env, declared_ty)))) { return None; } - // Successfully narrowed to an element of the union. + // Successfully inferred a result compatible with the declared union. *bindings = speculative_bindings; *argument_types = speculative_argument_types; self.extend(speculative_builder); @@ -7411,7 +7658,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { tcx: TypeContext<'db>, state: DeferredExpressionState, ) -> Type<'db> { - let previous_deferred_state = std::mem::replace(&mut self.deferred_state, state); + let previous_deferred_state = self.replace_deferred_state(state); let ty = self.infer_expression(expression, tcx); self.deferred_state = previous_deferred_state; ty @@ -7674,7 +7921,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if self.fluid_specializations_enabled() && let Some(candidate_def) = self.index.fluid_candidate_binding(expression) { - if !tcx.inferred_from_argument + if !tcx.inferred_from_argument() && let Some(annotation) = tcx.annotation() { self.fluid_adoptions.insert(expression.into(), annotation); @@ -7702,7 +7949,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && let Some(tcx) = tcx.annotation() && let literal_tcx @ (Type::Union(_) | Type::LiteralValue(_)) = tcx .resolve_type_alias(db) - .filter_union(db, |ty| ty.as_literal_value().is_some()) + .filter_union(db, env, |ty| ty.as_literal_value().is_some()) && ty.is_assignable_to(db, env, literal_tcx) { ty = Type::LiteralValue(literal.to_unpromotable()); @@ -7800,7 +8047,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .with_definition(signature.definition()) }), ); - CallableType::new(db, signatures, callable.kind(db), callable.provenance(db)) + callable.with_signatures(db, signatures) }); let inferable = class_generic_context.inferable_typevars(db); let constraints = ConstraintSetBuilder::new(); @@ -7818,12 +8065,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut type_context_mappings: FxHashMap, UnionAccumulator<'db>> = FxHashMap::default(); - for solution in solutions { + for solution in solutions.into_vec() { for binding in solution { let inferred_ty = binding .solution - .filter_union(db, |ty| !ty.has_unspecialized_type_var(db, env)); - if inferred_ty.has_unspecialized_type_var(db, env) { + .filter_union(db, env, |ty| !ty.has_provisional_marker(db, env)); + if inferred_ty.has_provisional_marker(db, env) { continue; } @@ -8144,10 +8391,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// real `tuple[...]` type when it contains variadic markers. Returns /// `None` if the tuple has no variadic — caller falls back to the /// named-tuple synthesis path so the surface syntax round-trips - pub(super) fn lower_parameter_shape_to_tuple_type( - &mut self, - tuple: &ast::ExprTuple, - ) -> Option> { + fn lower_parameter_shape_to_tuple_type(&mut self, tuple: &ast::ExprTuple) -> Option> { use crate::types::tuple::{Tuple, TupleType}; // detect variadic up-front without inferring — type inference must @@ -8337,7 +8581,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { schema, packs: packs.into_boxed_slice(), }; - let td = DynamicTypedDictLiteral::new(db, class_name, anchor, TypedDictModule::Typing); + let td = DynamicTypedDictLiteral::new(db, class_name, anchor, TypingModule::Typing); Type::ClassLiteral(ClassLiteral::DynamicTypedDict(td)).to_instance_approximation(db, env) } @@ -8490,7 +8734,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let module_scope = global_scope(db, db.program_file(self.file())); let anchor = DynamicNamedTupleAnchor::ScopeOffset { scope: module_scope, - offset: 0, + offset: DynamicClassScopeOffset::Node(0), spec, }; @@ -8503,10 +8747,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { tuple: &ast::ExprTuple, tcx: TypeContext<'db>, ) -> Type<'db> { - /// If a tuple literal has more elements than this constant, - /// we promote `Literal` types when inferring the elements of the tuple. - /// This provides a huge speedup on files that have very large unannotated tuple literals. - const MAX_TUPLE_LENGTH_FOR_UNANNOTATED_LITERAL_INFERENCE: usize = 64; let env = self.program_environment(); let db = self.db(); @@ -8657,12 +8897,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .and_then(|class| class.generic_context(db)) .map(|generic_context| generic_context.inferable_typevars(db)) .unwrap_or(TypeVarSet::None); - annotation.filter_disjoint_elements( - db, - env, - Type::homogeneous_tuple(db, env, Type::unknown()), - inferable, - ) + annotation + .discard_disjoint_union_elements( + db, + env, + Type::homogeneous_tuple(db, env, Type::unknown()), + inferable, + ) + .or_never() }); let mut is_homogeneous_tuple_annotation = false; @@ -8699,7 +8941,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .unwrap_or_default(); let mut annotated_elt_tys = annotated_elt_tys.into_iter(); - let mut infer_element = |elt: &ast::Expr| { + for elt in elts { let annotated_elt_ty = annotated_elt_tys.by_ref().next(); let element_tcx = if can_use_type_context { let expected = if elt.is_starred_expr() { @@ -8712,51 +8954,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { TypeContext::default() }; - if tuple.len() > MAX_TUPLE_LENGTH_FOR_UNANNOTATED_LITERAL_INFERENCE { - // Promote literals for very large unannotated tuples, - // to avoid pathological performance issues - self.infer_expression(elt, element_tcx).promote(db, env) - } else { - self.infer_expression(elt, element_tcx) - } - }; - - let mut builder = TupleSpecBuilder::with_capacity(elts.len()); - - for element in elts { - if let ast::Expr::Starred(starred) = element { - let element_type = infer_element(element); - // Fine to use `iterate` rather than `try_iterate` here: - // errors from iterating over something not iterable will have been - // emitted in the `infer_element` call above. - let mut spec = element_type.iterate(db, env).into_owned(); - - let known_length = match &*starred.value { - ast::Expr::List(ast::ExprList { elts, .. }) - | ast::Expr::Set(ast::ExprSet { elts, .. }) => elts - .iter() - .all(|elt| !elt.is_starred_expr()) - .then_some(elts.len()), - ast::Expr::Dict(ast::ExprDict { items, .. }) => items - .iter() - .all(|item| item.key.is_some()) - .then_some(items.len()), - _ => None, - }; - - if let Some(known_length) = known_length { - spec = spec - .resize(db, env, TupleLength::Fixed(known_length)) - .unwrap_or(spec); - } - - builder = builder.concat(db, env, &spec); - } else { - builder.push(infer_element(element)); - } + self.infer_expression(elt, element_tcx); } - Type::tuple(TupleType::new(db, env, &builder.build())) + // Infer expressions once, in evaluation order and with their type context, before + // recovering literal positions. For `(*[(item := 1), item],)`, both list elements + // must be inferred before the traversal reads their types. + let inferred_type = |expression: &ast::Expr, promote| { + let ty = self.expression_type(expression); + if promote { ty.promote(db, env) } else { ty } + }; + let spec = sequence_from_literal_elements( + elts, + tuple_literal_needs_promotion(elts), + &inferred_type, + &|expression, promote, known_length| { + // Starred-expression inference has already reported iteration errors. + let spec = inferred_type(expression, promote) + .iterate(db, env) + .into_owned(); + known_length + .and_then(|length| spec.resize(db, env, TupleLength::Fixed(length)).ok()) + .unwrap_or(spec) + }, + &|builder, unpacked| builder.concat(db, env, unpacked), + ); + Type::tuple(TupleType::new(db, env, &spec)) } fn infer_list_expression(&mut self, list: &ast::ExprList, tcx: TypeContext<'db>) -> Type<'db> { @@ -8887,12 +9110,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut item_types = FxHashMap::default(); // Validate `TypedDict` dictionary literal assignments. - if let Some(annotation) = tcx - .annotation() - .map(|annotation| annotation.resolve_type_alias(self.db())) + if let Some(annotation) = + tcx.annotation().map( + |annotation| match annotation.resolve_type_alias(self.db()) { + Type::Union(union) if union.has_aliases(db) => union.expand_aliases(db, env), + annotation => annotation, + }, + ) { if let Some(typed_dict) = annotation.as_typed_dict() { - // If there is a single typed dict annotation, infer against it directly. + // If there is a single typed dict annotation, infer against it directly. Expanding + // first means a union whose arms all alias the same `TypedDict` reaches this + // branch rather than neither. if let Some(ty) = self.infer_typed_dict_expression(dict, typed_dict, &mut item_types) { @@ -9085,8 +9314,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Some((collection_alias, generic_context, elt_tys)) = elt_tys(collection_class) else { // Infer the element types without type context, and fallback to `Unknown` for // custom typesheds. - for (i, elt) in elts.iter().flatten().flatten().enumerate() { - infer_elt_expression(self, (i, elt, TypeContext::default())); + for elts in elts { + for (i, elt) in elts.iter().enumerate() { + let Some(elt) = elt else { continue }; + infer_elt_expression(self, (i, elt, TypeContext::default())); + } } return None; @@ -9095,7 +9327,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let constraints = ConstraintSetBuilder::new(); let inferable = generic_context.inferable_typevars(db); let identity_instance = Type::instance(db, env, ClassType::Generic(collection_alias)); - let mut builder = SpecializationBuilder::new(db, env, &constraints, inferable); + let mut builder = SpecializationBuilder::new(db, env, &constraints, generic_context); // Remove any union elements of that are unrelated to the collection type. // @@ -9103,7 +9335,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // `collection_ty` is `list`. let tcx = tcx.map(|annotation| { let collection_ty = collection_class.to_instance(db, env); - annotation.filter_disjoint_elements(db, env, collection_ty, inferable) + annotation + .discard_disjoint_union_elements(db, env, collection_ty, inferable) + .or_never() }); // Collect type constraints from the declared element types. @@ -9141,11 +9375,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .zip(specialization.types(self.db())) { let inferred_ty = inferred_ty - .filter_union(db, |ty| { + .filter_union(db, env, |ty| { !ty.as_typevar() .is_some_and(|tv| tv.is_inferable(self.db(), inferable)) }) - .filter_union(db, |ty| !ty.has_unspecialized_type_var(db, env)); + .filter_union(db, env, |ty| !ty.has_unspecialized_type_var(db, env)); if inferred_ty.has_unspecialized_type_var(db, env) { continue; } @@ -9167,7 +9401,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .entry(identity) .and_modify(|current| *current = current.join(variance)) .or_insert(variance); - PathBounds::default_solve(db, env, &constraints, path_bound) + PathBounds::preliminary_solve(db, env, &constraints, path_bound) }); match solutions { @@ -9178,7 +9412,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // inference. Solutions::Unsatisfiable | Solutions::Unconstrained => {} Solutions::Constrained(solutions) => { - for solution in &solutions { + for solution in solutions.as_slice() { for binding in solution { // The SequentMap's transitivity reasoning can inject // cross-typevar references into the solution bounds. @@ -9195,8 +9429,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Avoid inferring a preferred type based on partially specialized // type context from an outer generic call. If the type context is // a union, we try to keep any concrete elements. - let inferred_ty = inferred_ty - .filter_union(db, |ty| !ty.has_unspecialized_type_var(db, env)); + let inferred_ty = inferred_ty.filter_union(db, env, |ty| { + !ty.has_unspecialized_type_var(db, env) + }); if inferred_ty.has_unspecialized_type_var(db, env) { continue; } @@ -9506,8 +9741,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let class_type = collection_alias .origin(self.db()) .apply_specialization(db, |_| { - builder.build_with(generic_context, |current_typevar, bounds| { - let Some(lower) = bounds.and_then(|bounds| bounds.lower) else { + builder.build_merged_with(|current_typevar, bounds| { + // `PathBound` is private to `generics`, so the method cannot be named here + #[expect(clippy::redundant_closure_for_method_calls)] + let Some(lower) = bounds.and_then(|bounds| bounds.evidence_lower()) else { // In fluid mode, an element typevar with no constraints comes from an // empty collection literal (e.g. `a = []`). Solve it to `Never` — the // precise element type of an empty collection — rather than the gradual @@ -9524,33 +9761,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // constraint solver (~40x, ecosystem timeouts). Promoting here trades that // precision for tractable performance until the solver cost is addressed; // see the fluid-specialization performance investigation. - let lower = if is_empty_collection_type_context(tcx) { - // Constraints learned from later collection uses follow the same promotion - // policy as literal elements: promote element literal types in invariant - // position unless an explicit annotation made them unpromotable — and, - // like them, follow the file's numeric model, or a `float` element - // widens back to `int | float` and the buffer is lost - lower.promote_in(self.db(), env, self.file()) - } else { - lower - }; - - let lower = if tuple_size_promotion_constraints - .allow(current_typevar.identity(self.db())) - { - lower.promote_tuple_size_in_union(db, env) - } else { - lower - }; - - let lower = if is_empty_collection_type_context(tcx) { - lower - // Promote singleton types to `T | Unknown` in inferred type parameters, - // so that e.g. `[None]` is inferred as `list[None | Unknown]`. - .promote_singletons_recursively(db, env) - } else { - lower - }; + let lower = lower.promote_collection_element_type_in( + db, + env, + self.file(), + tuple_size_promotion_constraints.allow(current_typevar.identity(self.db())), + is_empty_collection_type_context(tcx), + ); Some(lower) }) @@ -9628,7 +9845,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let mut yield_tcx: Option> = None; - for solution in solutions { + for solution in solutions.into_vec() { for binding in solution { if binding.bound_typevar != yield_typevar { continue; @@ -10105,13 +10322,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return self.infer_expression(&named.value, TypeContext::default()); } // See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements - if named.target.is_name_expr() { + if named.target.is_name_expr() && !self.in_string_annotation() { let definition = self.index.expect_single_definition(named); let result = infer_definition_types(self.db(), definition); self.extend_definition(definition, result); result.binding_type(definition) } else { - // For syntactically invalid targets, we still need to run type inference: + // String annotations have no indexed definitions, and syntactically invalid targets + // cannot define a name. Both sides still need inference to preserve their diagnostics. self.infer_expression(&named.target, TypeContext::default()); self.infer_expression(&named.value, TypeContext::default()); Type::unknown() @@ -10289,10 +10507,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { (body_ty, orelse_ty) }; - let truthiness = match test_ty.try_bool(self.db(), env) { - Ok(truthiness) => { + let truthiness = match test_ty.try_bool(db, env) { + Ok(_) => { self.check_condition(test); - truthiness + analyze_condition_expression(test, &|node| { + self.comparison_truthiness + .get(&node.into()) + .copied() + .or_else(|| self.expression_type(node).bool_if_inhabited(db, env)) + }) + .unwrap_or(Truthiness::Ambiguous) } Err(err) => { err.report_diagnostic(&self.context, &**test); @@ -10327,13 +10551,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = lambda_expression; // In stub files, default values may reference names that are defined later in the file. - let in_stub = self.in_stub(); - let previous_deferred_state = std::mem::replace(&mut self.deferred_state, in_stub.into()); + let previous_deferred_state = self.replace_deferred_state(self.in_stub().into()); // TODO: We could perform multi-inference here if there are multiple `Callable` annotations // in the union/intersection. let callable_tcx = if let Some(tcx) = tcx.annotation() - && let Some(callable) = tcx.filter_union(db, Type::is_callable_type).as_callable() + && let Some(callable) = tcx + .filter_union(db, env, Type::is_callable_type) + .resolve_type_alias(db) + .as_callable() { match callable.signatures(self.db()).overloads.as_slice() { [signature] => Some(signature), @@ -10395,6 +10621,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .replace_parameter_defaults(self.db(), env) }); let parameter_base = Parameter::positional_only(Some(param.name().id.clone())) + .with_inferred_type(Type::Dynamic(DynamicType::UnknownLambdaParameter)) .with_optional_default_type(default_ty); if let Some(ty) = resolve_param_annotation(self, param, ctx_ty, default_ty) { parameter_base.with_annotated_type(ty) @@ -10413,6 +10640,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .replace_parameter_defaults(self.db(), env) }); let parameter_base = Parameter::positional_or_keyword(param.name().id.clone()) + .with_inferred_type(Type::Dynamic(DynamicType::UnknownLambdaParameter)) .with_optional_default_type(default_ty); if let Some(ty) = resolve_param_annotation(self, param, ctx_ty, default_ty) { parameter_base.with_annotated_type(ty) @@ -10421,10 +10649,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }) .collect::>(); - let variadic = parameters - .vararg - .as_ref() - .map(|param| Parameter::variadic(param.name().id.clone())); + let variadic = parameters.vararg.as_ref().map(|param| { + Parameter::variadic(param.name().id.clone()) + .with_inferred_type(Type::Dynamic(DynamicType::UnknownLambdaParameter)) + }); // `Callable[[...], R]` parameter types only apply to positional // parameters — keyword-only parameters never consume from // `parameter_types`. The explicit annotation on a typed lambda @@ -10439,6 +10667,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .replace_parameter_defaults(self.db(), env) }); let parameter_base = Parameter::keyword_only(param.name().id.clone()) + .with_inferred_type(Type::Dynamic(DynamicType::UnknownLambdaParameter)) .with_optional_default_type(default_ty); if let Some(ty) = resolve_param_annotation(self, param, None, default_ty) { parameter_base.with_annotated_type(ty) @@ -10447,10 +10676,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }) .collect::>(); - let keyword_variadic = parameters - .kwarg - .as_ref() - .map(|param| Parameter::keyword_variadic(param.name().id.clone())); + let keyword_variadic = parameters.kwarg.as_ref().map(|param| { + Parameter::keyword_variadic(param.name().id.clone()) + .with_inferred_type(Type::Dynamic(DynamicType::UnknownLambdaParameter)) + }); let parameters = positional_only .into_iter() @@ -10514,7 +10743,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.db(), CallableSignature::single(Signature::new(parameters, return_ty)), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::ImplicitReturn, )) } @@ -10528,6 +10756,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { argument_type: Type<'db>, argument: &'ast ast::ArgOrKeyword, ) -> Option> { + // Parsed string annotations are not indexed, so their keyword arguments have no + // use-definition information from which to narrow dictionary keys. + if self.in_string_annotation() { + return None; + } + let env = self.program_environment(); let db = self.db(); let file_scope_id = self.scope().file_scope_id(db); @@ -10607,7 +10841,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { db, CallableSignature::from_overloads(getitem_overloads), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, ), )], ); @@ -11048,8 +11281,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { TypeContext::default() }; - let callable_type = - self.infer_maybe_standalone_expression(&call_expression.func, callee_tcx); + let callable_type = self.infer_callee(&call_expression.func, callee_tcx); // basedpython `a?.b()`: the `?.` short-circuit covers the call too, matching the // `None if a is None else a.b()` lowering, so call the present-receiver callable and @@ -11167,6 +11399,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { RestrictedType::from_type_expression(db, env, TypeModifier::Final, return_type) } + /// Infer a callable expression without introducing new type variable bindings. + /// + /// An assignment such as `items = list[T]()` creates an instance, not a generic alias. + /// Unlike `Items = list[T]`, it requires `T` to be bound in an enclosing generic scope. + fn infer_callee(&mut self, expression: &ast::Expr, tcx: TypeContext<'db>) -> Type<'db> { + let previous_binding_context = self.typevar_binding_context.take(); + let previous_check_unbound = self + .context + .inference_flags + .replace(InferenceFlags::CHECK_UNBOUND_TYPEVARS, true); + let callable_type = self.infer_maybe_standalone_expression(expression, tcx); + self.context.inference_flags.set( + InferenceFlags::CHECK_UNBOUND_TYPEVARS, + previous_check_unbound, + ); + self.typevar_binding_context = previous_binding_context; + callable_type + } + fn infer_empty_list_or_set_constructor( &mut self, collection_class: KnownClass, @@ -11870,7 +12121,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return ty; } - if let Some(typed_dict_module) = TypedDictModule::from_type(self.db(), callable_type) { + if let Some(typed_dict_module) = + TypingModule::from_typed_dict_type(self.db(), callable_type) + { return self.infer_typeddict_call_expression(call_expression, None, typed_dict_module); } @@ -11892,6 +12145,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { diagnostic.set_concise_message( "`NotImplemented` is not callable - did you mean `NotImplementedError`?", ); + autofix_with_notimplementederror(&self.context, &mut diagnostic, func); } return Type::unknown(); } @@ -12221,7 +12475,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } } - Some(KnownClass::TypeAliasType) => { + Some(KnownClass::TypeAliasType | KnownClass::ExtensionsTypeAliasType) => { if let Some(builder) = self .context .report_lint(&INVALID_TYPE_ALIAS_TYPE, call_expression) @@ -12278,7 +12532,30 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { None => tcx, }; if has_prepared_typed_dict_constructor || has_django_lookup_expressions { - builder.get_or_infer_expression(expr, tcx) + return builder.get_or_infer_expression(expr, tcx); + } + // Permit bare ParamSpecs only in direct names and dotted attributes, so nested + // type expressions and calls retain their ordinary validation. + if matches!( + callable_type, + Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetLowerBound + | KnownBoundMethodType::ConstraintSetUpperBound + | KnownBoundMethodType::ConstraintSetEquality + | KnownBoundMethodType::ConstraintSetRange + ) + ) && is_dotted_name(expr) + { + let previously_allowed = builder + .context + .inference_flags + .replace(InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, true); + let ty = builder.infer_expression(expr, tcx); + builder.context.inference_flags.set( + InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, + previously_allowed, + ); + ty } else { builder.infer_expression(expr, tcx) } @@ -12344,7 +12621,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .iter_flat() .flat_map(CallableBinding::matching_overloads) .filter_map(|(_, identity_overload)| { - identity_overload.specialization(db, env) + identity_overload.merged_specialization(db, env) }) { // Record the constraints on the receiver's generic context formed by @@ -12376,6 +12653,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }; + // Explicit function references already report implementation deprecations. + // Other calls reference an object or class, not the implicitly invoked method. + let is_function_reference = matches!( + callable_type, + Type::FunctionLiteral(_) | Type::BoundMethod(_) | Type::Callable(_) + ); + self.report_deprecated_functions( + func.as_ref(), + bindings + .deprecated_functions(db) + .map(|(_, function)| function) + .filter(|function| function.is_overload(db) || !is_function_reference), + ); + // basedpython: `str`, `repr`, `format` and `print` put a value's // rendering in front of someone, so a value with no rendering of its // own is worth mentioning here as much as in an f-string @@ -12485,6 +12776,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { overload, &call_arguments, call_expression, + self.index, ); } self.check_regex_function_call(function_literal, overload, call_expression); @@ -12777,7 +13069,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .return_ty; let return_type_span = enclosing_function.spans(self.db()).return_type; - let Some(generator_type_params) = declared_return_ty.generator_types(db, env) else { + let Some(generator_type_params) = + declared_return_ty.generator_types(db, env, GeneratorTypeMode::IteratorDefaults) + else { let _ = self.infer_optional_expression(value.as_deref(), TypeContext::default()); return Type::unknown(); }; @@ -12825,7 +13119,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ) .return_ty; - let Some(outer_expected) = annotated_return_ty.generator_types(db, env) else { + let Some(outer_expected) = + annotated_return_ty.generator_types(db, env, GeneratorTypeMode::IteratorDefaults) + else { let _ = self.infer_expression(value, TypeContext::default()); return Type::unknown(); }; @@ -12858,11 +13154,47 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - if let Some(outer_send_ty) = outer_expected.send_ty { - let inner_send_ty = iterable_type - .generator_send_type(db, env) - .unwrap_or_else(|| Type::none(db, env)); - if !outer_send_ty.is_assignable_to(db, env, inner_send_ty) { + // `yield from x` delegates to `iter(x)`, so the send and return types of the + // expression are those of the *iterator*. If `x` is itself a generator, that's `x`. + // Otherwise, e.g. for an instance of a class whose `__iter__` method is a + // generator function, we look at the return type of `x.__iter__()`. + let inner_generator = iterable_type + .generator_types(db, env, GeneratorTypeMode::GeneratorOnly) + .map(|types| (iterable_type, types)) + .or_else(|| { + let iterator_type = match iterable_type.try_call_dunder( + db, + env, + "__iter__", + CallArguments::none(), + TypeContext::default(), + ) { + Ok(bindings) => Some(bindings.return_type(db, env)), + Err(CallDunderError::PossiblyUnbound { .. }) => { + // Iteration can fall back to `__getitem__` where `__iter__` is absent. + // The available `__iter__` bindings do not describe those alternatives. + None + } + Err(err) => err.return_type(db, env), + }?; + // `Iterator` has no type parameter for `StopIteration.value`. + iterator_type + .generator_types(db, env, GeneratorTypeMode::GeneratorOnly) + .map(|types| (iterator_type, types)) + }); + + // `Iterator` annotations constrain yielded values but do not expose a send method. + if let Some(outer_send_ty) = annotated_return_ty.generator_annotation_send_type(db, env) { + let incompatible_send_ty = match inner_generator { + Some((iterator_type, _)) => { + iterator_type.incompatible_yield_from_send_type(db, env, outer_send_ty) + } + None => { + let none = Type::none(db, env); + (!outer_send_ty.is_assignable_to(db, env, none)).then_some(none) + } + }; + if let Some(inner_send_ty) = incompatible_send_ty { report_invalid_generator_yield_type( &self.context, value.as_ref(), @@ -12874,8 +13206,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - iterable_type - .generator_return_type(db, env) + inner_generator + .and_then(|(_, generator_types)| generator_types.return_ty) .unwrap_or_else(Type::unknown) } @@ -12903,8 +13235,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && expected_yield_ty.is_fully_static(db, env) && !yielded_ty.is_pure_redundant_with(db, env, expected_yield_ty) { - // N.B. the implementation here is the ~same as for `UNSOUND_RETURN_STATEMENT`; - // update that too if updating this! + // N.B. the implementation here is the ~same as for `UNSOUND_RETURN_STATEMENT` and `UNSOUND_ASSIGNMENT`; + // update those too if updating this! report_unsound_yield( &self.context, yielded_value, @@ -12983,6 +13315,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let reachability_constraints = bindings.reachability_constraints(); let predicates = bindings.predicates(); let mut union = UnionBuilder::new(db, env); + let mut loop_header_fallbacks = FxHashMap::default(); for binding in bindings { let static_reachability = evaluate_reachability_with_cache( db, @@ -12998,7 +13331,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { DefinitionState::Defined(definition) if !is_discarded_dict_key_assignment(db, definition) => { - let binding_ty = binding_type(db, definition); + let mut binding_ty = binding_type(db, definition); + if definition.kind(db).is_loop_header() { + let fallback_ty = self.loop_header_fallback_type( + definition, + ty, + &mut loop_header_fallbacks, + ); + binding_ty = UnionType::from_elements( + db, + env, + [binding_ty, fallback_ty], + ); + } union.add_in_place( binding .narrowing_constraint @@ -13025,8 +13370,79 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ty } + /// Compute the type for reads such as `box.value` or `items[0]` in loop iterations after + /// `box` or `items` has been assigned a new object. + /// + /// A check on `box.value` before `box = Box()` describes the old box, not the new one. + /// We start again from the type given by attribute lookup, then apply any checks made + /// after the assignment. For example: + /// + /// ```py + /// class Box: + /// value: int | None + /// + /// def f(box: Box): + /// assert box.value is not None + /// for _ in range(2): + /// reveal_type(box.value) # revealed: int + /// box = Box() + /// assert box.value is not None + /// ``` + /// + /// The first assertion gives `int` for the first iteration. After `box = Box()`, the + /// new box's value could be `None`; the second assertion narrows it to `int` for the + /// next iteration. This helper computes that contribution from the previous iteration. + /// + /// `fallback_ty` is the starting type before applying those later checks: `int | None` + /// here. The caller obtains it when inferring the attribute or item read being checked, + /// including any narrowing already applied from enclosing scopes. This helper reuses + /// that type; it does not look it up at the assignment or at the end of the loop. + /// Recursive calls preserve checks made after the assignment within nested loops as well. + fn loop_header_fallback_type( + &self, + definition: Definition<'db>, + fallback_ty: Type<'db>, + cache: &mut FxHashMap, Type<'db>>, + ) -> Type<'db> { + // Inner headers can be reached through multiple containing headers. The fallback type + // is fixed for this traversal, so each definition's contribution only needs computing once. + if let Some(ty) = cache.get(&definition) { + return *ty; + } + + let db = self.db(); + let env = self.program_environment(); + let header = loop_header_reachability(db, definition); + let use_def = self.index.use_def_map(definition.file_scope(db)); + let place = definition.place(db); + let mut union = UnionBuilder::new(db, env); + + for constraint in &header.deleted_narrowing_constraints { + union.add_in_place(use_def.narrowing_evaluator(*constraint).narrow( + db, + env, + fallback_ty, + place, + )); + } + for binding in &header.reachable_bindings { + if binding.definition.kind(db).is_loop_header() { + let ty = self.loop_header_fallback_type(binding.definition, fallback_ty, cache); + union.add_in_place( + use_def + .narrowing_evaluator(binding.narrowing_constraint) + .narrow(db, env, ty, place), + ); + } + } + + let ty = union.build(); + cache.insert(definition, ty); + ty + } + /// Check if the given ty is `@deprecated` or not - fn check_deprecated(&self, ranged: T, ty: Type) { + fn check_deprecated(&self, ranged: T, ty: Type<'db>) { // First handle classes if let Type::ClassLiteral(class_literal) = ty { let Some(deprecated) = class_literal.deprecated(self.db()) else { @@ -13051,13 +13467,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let function = match ty { Type::FunctionLiteral(function) => function, Type::BoundMethod(bound) => bound.function(self.db()), + Type::Callable(callable) => { + self.report_deprecated_functions(ranged, callable.deprecated(self.db())); + return; + } _ => return, }; - // Currently we only check the final implementation for deprecation, as - // that check can be done on any reference to the function. Analysis of - // deprecated overloads needs to be done in places where we resolve the - // actual overloads being used. + // References to a function only check its implementation. Deprecated overloads are + // checked at call sites, after resolving which signatures accept the arguments. let Some(deprecated) = function.implementation_deprecated(self.db()) else { return; }; @@ -13078,6 +13496,123 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { diag.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); } + /// Report the distinct deprecated targets of one operation in a single diagnostic. + /// Deduplicate by source function or overload. Keep a shared deprecation message in the + /// primary annotation so it appears in concise output. Put differing messages in separate + /// subdiagnostics with their declarations so each message's prose and line breaks remain + /// readable. The summary names the possible deprecated targets in both output formats. + fn report_deprecated_functions( + &self, + ranged: impl Ranged, + functions: impl IntoIterator>, + ) { + let db = self.db(); + let functions: SmallVec<[_; 1]> = functions.into_iter().unique().collect(); + let Some(first) = functions.first() else { + return; + }; + let Some(builder) = self.context.report_lint(&diagnostic::DEPRECATED, ranged) else { + return; + }; + let shared_message = functions + .iter() + .filter_map(|function| function.deprecated(db)?.message) + .map(|message| message.value(db)) + .filter(|message| !message.is_empty()) + .all_equal_value() + .ok(); + let mut diagnostic = if functions.len() == 1 { + let kind = if first.is_overload(db) { + "overload of" + } else { + "function" + }; + builder.into_diagnostic(format_args!( + "The {kind} `{}` is deprecated", + first.name(db) + )) + } else { + let mut names = FxOrderSet::default(); + let mut all_methods = true; + for function in &functions { + let description = CallableDescription::from_overload(db, *function); + names.insert(description.name()); + all_methods &= description.kind() == Some("method"); + } + let kind = if all_methods { "method" } else { "function" }; + let plural = if names.len() == 1 { "" } else { "s" }; + let names = names + .iter() + .format_with(", ", |name, f| f(&format_args!("`{name}`"))); + let mut diagnostic = builder.into_diagnostic(format_args!( + "Possible use of deprecated {kind}{plural}: {names}" + )); + for function in &functions { + if shared_message.is_some() { + diagnostic.annotate(Annotation::secondary(function.spans(db).name)); + continue; + } + let message = function + .deprecated(db) + .and_then(|deprecated| deprecated.message) + .map(|message| message.value(db)) + .filter(|message| !message.is_empty()) + .unwrap_or("Deprecated function defined here"); + let mut sub = SubDiagnostic::new(SubDiagnosticSeverity::Info, message); + sub.annotate(Annotation::primary(function.spans(db).name)); + diagnostic.sub(sub); + } + diagnostic + }; + if let Some(message) = shared_message { + diagnostic.set_primary_annotation_message(message); + } + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); + } + + /// Report a deprecated callable only when its union alternative has no non-deprecated + /// intersection member that could provide the implementation instead. + fn check_deprecated_bindings(&self, ranged: &T, bindings: &Bindings<'db>) { + self.report_deprecated_functions( + ranged, + bindings + .deprecated_functions(self.db()) + .map(|(_, function)| function), + ); + } + + /// Check the accessor invoked by an attribute operation, using the deprecations + /// retained by member lookup or assignment validation. `access` describes the operation, + /// which may differ from the AST context: an augmented assignment also reads its target. + /// + /// ```python + /// from typing_extensions import deprecated + /// + /// class C: + /// @property + /// @deprecated("old getter") + /// def value(self) -> int: + /// return 0 + /// + /// @value.setter + /// def value(self, new: int) -> None: ... + /// + /// c = C() + /// c.value = 1 # Only invokes the non-deprecated setter. + /// c.value += 1 # Also invokes the deprecated getter. + /// ``` + fn check_deprecated_property( + &self, + attribute: &ast::ExprAttribute, + properties: PropertyDeprecations<'db>, + access: ExprContext, + ) { + self.report_deprecated_functions( + &attribute.attr, + properties.functions(self.db(), access).iter().copied(), + ); + } + /// basedpython: whether `name_node` reads the `it` a trailing-lambda block binds when /// the block's callback passes no argument for it. /// @@ -13634,11 +14169,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - pub(super) fn report_unresolved_reference( - &self, - expr_name_node: &ast::ExprName, - tcx: TypeContext<'db>, - ) { + fn report_unresolved_reference(&self, expr_name_node: &ast::ExprName, tcx: TypeContext<'db>) { let db = self.db(); let env = self.program_environment(); let Some(builder) = self @@ -13715,6 +14246,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(("", builtin_name)) = as_pep_585_generic("typing", id) { diagnostic .set_primary_annotation_message(format_args!("Did you mean `{builtin_name}`?")); + if SemanticModel::new(db, self.program_file()) + .definitely_has_builtin_binding(builtin_name, expr_name_node.into()) + { + diagnostic.help(format_args!("Replace with `{builtin_name}`")); + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( + builtin_name.to_string(), + expr_name_node.range(), + ))); + } } } @@ -14036,6 +14576,35 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_attribute_load_chained(attribute, value_type, in_chain) } + /// Reject access to a generic instance attribute through a class while retaining the normal + /// attribute type for recovery. Reads and writes use the same restriction. + fn validate_generic_class_attribute_access( + &self, + attribute: &ast::ExprAttribute, + object_ty: Type<'db>, + emit_diagnostics: bool, + ) -> bool { + if !object_ty.has_generic_instance_attribute( + self.db(), + self.program_environment(), + &attribute.attr.id, + ) { + return true; + } + if emit_diagnostics + && let Some(builder) = self + .context + .report_lint(&INVALID_ATTRIBUTE_ACCESS, attribute) + { + builder.into_diagnostic(format_args!( + "Cannot access generic instance attribute `{}` through a class", + attribute.attr.id, + )); + } + false + } + + /// Infer an attribute load on a known receiver, returning its recovery type if lookup fails. /// Infer an attribute load on a known receiver that does not continue a basedpython /// optional chain, returning its recovery type if lookup fails. fn infer_attribute_load_impl( @@ -14199,16 +14768,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { assigned_type = Some(ty); } } - - let mut fallback_place = value_type + let member_lookup = value_type .try_member_lookup(db, env, &attr.id) .unwrap_or_else(|error| { error.report_diagnostic(&self.context, value_type, attribute, assigned_type); error.fallback_member(db) - }) - .map_type(|ty| { - self.narrow_expr_with_applicable_constraints(attribute, ty, &constraint_keys) }); + let mut fallback_place = member_lookup.member(db).map_type(|ty| { + self.narrow_expr_with_applicable_constraints(attribute, ty, &constraint_keys) + }); + + // An augmented assignment also loads its target, but its write validation reports this + // error. Avoid reporting the same invalid access twice. + if !attribute.ctx.is_store() { + self.validate_generic_class_attribute_access(attribute, value_type, true); + } // basedpython: an attribute that resolves to no declared member may be // supplied by an `extension` in scope (this module's, or one from any @@ -14478,9 +15052,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(builder) = self.context.report_lint(&UNRESOLVED_ATTRIBUTE, attribute) { + let types = std::iter::once(union_like_type) + .chain(elements_missing_the_attribute.iter().copied()); + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, types); let missing_types = elements_missing_the_attribute .iter() - .map(|ty| format!("`{}`", ty.display(db, env))) + .map(|ty| { + format!("`{}`", ty.display_with(db, env, settings.clone())) + }) .collect::>() .join(", "); @@ -14488,7 +15068,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "Attribute `{attr_name}` is not defined on {} \ in union `{union_like_type}`", missing_types, - union_like_type = union_like_type.display(db, env), + union_like_type = + union_like_type.display_with(db, env, settings), )); } return type_when_bound; @@ -14511,6 +15092,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.check_deprecated(attr, resolved_type); + // Deleting an attribute does not invoke its getter. Augmented assignment, however, + // reads the property here even though its AST target has a store context. + if let Some(properties) = member_lookup.deprecated_properties(db) { + self.check_deprecated_property( + attribute, + properties, + if attribute.ctx == ExprContext::Del { + ExprContext::Del + } else { + ExprContext::Load + }, + ); + } + // basedpython: an attribute type stays symbolic until the type parameter is // substituted, so `B[A2]().x` reads `a` off `A2` rather than off the bound the // lookup above resolved it against. it still goes through the chain result, so @@ -14616,7 +15211,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// no extension supplies it, or when the resolved member does not accept /// the call — each of which leaves the operator unsupported, exactly as it /// is without the extension - pub(super) fn try_unary_extension_operator( + fn try_unary_extension_operator( &self, op: ast::UnaryOp, operand: Type<'db>, @@ -14635,7 +15230,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// basedpython: the type a binary operator evaluates to when an applicable /// extension supplies its dunder, on either operand - pub(super) fn try_binary_extension_operator( + fn try_binary_extension_operator( &self, left: Type<'db>, op: ast::Operator, @@ -14656,7 +15251,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// extension supplies its dunder. A membership test coerces /// `__contains__`'s result, so it is a `bool` whatever the extension /// declares - pub(super) fn try_comparison_extension_operator( + fn try_comparison_extension_operator( &self, left: Type<'db>, op: ast::CmpOp, @@ -14718,8 +15313,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { CallArguments::none(), TypeContext::default(), ) { - Ok(outcome) => outcome.return_type(db, env), + Ok(outcome) => { + self.check_deprecated_bindings(unary, &outcome); + outcome.return_type(db, env) + } Err(e) => { + let bindings = match &e { + CallDunderError::PossiblyUnbound { bindings, .. } => Some(bindings), + CallDunderError::CallError(_, bindings, _) => Some(bindings), + CallDunderError::MethodNotAvailable => None, + }; + if let Some(bindings) = bindings { + self.check_deprecated_bindings(unary, bindings); + } // basedpython: an applicable extension may supply the dunder if let Some(ty) = self.try_unary_extension_operator(op, operand_type) { return ty; @@ -14805,11 +15411,56 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { todo_type!("basedpython wrapped-type operator") } + (ast::UnaryOp::Invert, Type::LiteralValue(literal)) => match literal.kind() { + LiteralValueTypeKind::Int(value) => Type::int_literal(!value.as_i64()), + LiteralValueTypeKind::Bool(value) => { + // `~bool` is currently deprecated in typeshed. Technically we should + // similarly check for deprecation of dunder methods on all our literal + // type fast paths, but we choose not to pay that extra cost, since it is + // implausible that e.g. `int.__neg__` would ever be deprecated. + if let Some(dunder) = literal + .fallback_instance(db, env) + .member_lookup_with_policy( + db, + env, + "__invert__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + .ignore_possibly_undefined() + { + self.check_deprecated(unary, dunder); + } + Type::int_literal(!i64::from(value)) + } + _ => fallback_unary_expression_type(), + }, ( - ast::UnaryOp::UAdd | ast::UnaryOp::USub | ast::UnaryOp::Invert, + ast::UnaryOp::UAdd | ast::UnaryOp::USub, Type::LiteralValue(literal), - ) => binary_expressions::literal_unary_op(self.db(), env, op, literal) - .unwrap_or_else(fallback_unary_expression_type), + ) => { + // `~bool` is currently deprecated in typeshed. Technically we should similarly + // check for deprecation of dunder methods on all our literal type fast paths, + // but we choose not to pay that extra cost, since it is implausible that e.g. + // `int.__neg__` would ever be deprecated. + if op == ast::UnaryOp::Invert + && matches!(literal.kind(), LiteralValueTypeKind::Bool(_)) + && let Some(dunder) = literal + .fallback_instance(db, env) + .member_lookup_with_policy( + db, + env, + "__invert__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + .ignore_possibly_undefined() + { + self.check_deprecated(unary, dunder); + } + binary_expressions::literal_unary_op(self.db(), env, op, literal) + .unwrap_or_else(fallback_unary_expression_type) + } (ast::UnaryOp::Invert, Type::KnownInstance(KnownInstanceType::ConstraintSet(set))) => { let constraints = ConstraintSetBuilder::new(); @@ -14852,24 +15503,54 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { match tvar.typevar(self.db()).bound_or_constraints(db, env) { Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - match Self::map_constrained_typevar_constraints( + // Call the dunder method for every constraint up front so deprecation + // reporting doesn't depend on whether any constraint fails. + let outcomes: Vec<_> = constraints + .elements(db) + .iter() + .map(|constraint| { + constraint.try_call_dunder( + db, + env, + unary_dunder_method, + CallArguments::none(), + TypeContext::default(), + ) + }) + .collect(); + self.report_deprecated_functions( + unary, + outcomes + .iter() + .filter_map(|outcome| match outcome { + Ok(bindings) => Some(bindings), + // A method can be deprecated even if it is missing from some + // union members or its signature rejects the implicit call. + // Preserve those bindings so the deprecation is reported + // alongside the unsupported-operator diagnostic. + Err( + CallDunderError::PossiblyUnbound { bindings, .. } + | CallDunderError::CallError(_, bindings, _), + ) => Some(bindings.as_ref()), + // A completely missing method has no bindings to inspect. + Err(CallDunderError::MethodNotAvailable) => None, + }) + .flat_map(|bindings| bindings.deprecated_functions(db)) + .map(|(_, function)| function), + ); + + let mut outcomes = outcomes.into_iter(); + let result = Self::map_constrained_typevar_constraints( db, env, operand_type, constraints, - |constraint| { - constraint - .try_call_dunder( - db, - env, - unary_dunder_method, - CallArguments::none(), - TypeContext::default(), - ) - .map(|outcome| outcome.return_type(db, env)) - .ok() + |_constraint| { + let outcome = outcomes.next()?.ok()?; + Some(outcome.return_type(db, env)) }, - ) { + ); + match result { Some(ty) => ty, None => { // At least one constraint failed; report error. @@ -14943,6 +15624,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::Union(_) | Type::Intersection(_) // the dunder lookup resolves across the materializations @@ -14993,12 +15675,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { (ty, value.range()) }, ) + .value_type } /// Computes the output of a chain of (one) boolean operation, consuming as input an iterator /// of operations and calling the `infer_ty` for each to infer their types. /// The iterator is consumed even if the boolean evaluation can be short-circuited, /// in order to ensure the invariant that all expressions are evaluated when inferring types. + /// Returns the value type and the combined truthiness of all but the final operand, which + /// is not converted to a boolean when evaluating the chain as a value. /// /// `infer_ty` receives the unguarded union of previous operand types that may contribute to the /// result. This can be used as a type context without losing generic specialization information @@ -15010,16 +15695,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { track_peer_types: bool, operations: Iterator, needs_peer_type: NeedsPeerType, - infer_ty: InferType, - ) -> Type<'db> + mut infer_ty: InferType, + ) -> ChainedBooleanResult<'db> where Iterator: IntoIterator, NeedsPeerType: Fn(&Item) -> bool, - InferType: Fn(&mut Self, Item, Option>) -> (Type<'db>, TextRange), + InferType: FnMut(&mut Self, Item, Option>) -> (Type<'db>, TextRange), { let db = self.db(); let env = self.program_environment(); let mut done = false; + let mut preceding_truthiness = Truthiness::from(op.is_and()); let mut peer_types: Option> = None; let elements = operations @@ -15044,6 +15730,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { err.report_diagnostic(&self.context, range); err.fallback_truthiness() }); + preceding_truthiness = match op { + ast::BoolOp::And => preceding_truthiness.and(truthiness), + ast::BoolOp::Or => preceding_truthiness.or(truthiness), + }; if done { return Type::Never; @@ -15078,7 +15768,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }); - UnionType::from_elements(db, env, elements) + let value_type = UnionType::from_elements(db, env, elements); + ChainedBooleanResult { + value_type, + preceding_truthiness, + } } fn infer_compare_expression(&mut self, compare: &ast::ExprCompare) -> Type<'db> { @@ -15092,6 +15786,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = compare; self.infer_expression(left, TypeContext::default()); + let mut last_comparison_ty = Type::unknown(); // https://docs.python.org/3/reference/expressions.html#comparisons // > Formally, if `a, b, c, …, y, z` are expressions and `op1, op2, …, opN` are comparison @@ -15104,7 +15799,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // A chain like `a == True == b` is two comparisons over one literal: reporting each pair // would double up on that `True`, and "test the operand" is not the fix for the chain. let single_comparison = ops.len() == 1; - self.infer_chained_boolean_types( + let ChainedBooleanResult { + value_type: ty, + preceding_truthiness, + } = self.infer_chained_boolean_types( ast::BoolOp::And, false, std::iter::once(&**left) @@ -15177,9 +15875,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }); + last_comparison_ty = ty; (ty, range) }, - ) + ); + + if ops.len() > 1 { + // Individual comparisons within a chain have no expression nodes whose result types + // reachability can look up later. Retain their combined condition truthiness here; + // `and`/`or` conditions can instead be reconstructed by walking their operand nodes. + // See `ExpressionInferenceExtra::comparison_truthiness` for why the chain's value + // type is not sufficient. + // + // As a condition, the chain is truthy only if both its prefix and final comparison are + // truthy. Skip the final comparison's truthiness computation when the prefix is + // already always false. + let truthiness = preceding_truthiness + .and_then(|| last_comparison_ty.bool(db, self.program_environment())); + let expression = ast::ExprRef::Compare(compare).into(); + if truthiness != ty.bool(db, self.program_environment()) { + self.comparison_truthiness.insert(expression, truthiness); + } else { + self.comparison_truthiness.remove(&expression); + } + } + + ty } /// basedpython: type a keyword-form `is`/`is not` pair that performs an @@ -15495,6 +16216,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, expressions, + comparison_truthiness, qualifiers: _, type_expression_flags, collection_use_constraints, @@ -15515,6 +16237,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Ignored; only relevant to definition regions undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, // builder only state @@ -15544,6 +16267,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { FullExpressionCacheEntry { expressions, + comparison_truthiness, type_expression_flags, collection_use_constraints, fluid_adoptions, @@ -15567,6 +16291,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, expressions, + comparison_truthiness: _, qualifiers, type_expression_flags, fluid_creation: _, @@ -15587,6 +16312,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Ignored; only relevant to definition regions undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, // builder only state @@ -15696,6 +16422,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, expressions, + comparison_truthiness: _, bindings, called_functions, expression_cache: _, @@ -15717,6 +16444,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { dataclass_field_specifiers: _, slice_materialization: _, undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, typevar_binding_context: _, deferred_state: _, @@ -15753,6 +16481,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, expressions, + comparison_truthiness: _, qualifiers, type_expression_flags, fluid_creation, @@ -15768,6 +16497,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred, cycle_recovery, undecorated_type, + deferred_decorator_calls, discards_dict_key_assignments, called_functions, unsolved_typevar_calls: _, @@ -15799,6 +16529,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { + usize::from(!type_expression_flags.is_empty()) + usize::from(cycle_recovery.is_some()) + usize::from(!deferred.is_empty()) + + usize::from(!deferred_decorator_calls.is_empty()) + usize::from(!diagnostics.is_empty()) + usize::from(discards_dict_key_assignments) + usize::from(!qualifiers.is_empty()); @@ -15861,6 +16592,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred: deferred.into_boxed_slice(), diagnostics, undecorated_type, + deferred_decorator_calls: deferred_decorator_calls.into_iter().collect(), discards_dict_key_assignments, qualifiers: FrozenMap::from(qualifiers), }; @@ -15914,6 +16646,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { mut fluid_adoptions, mut collection_use_constraints, expressions, + comparison_truthiness: _, scope, cycle_recovery, qualifiers, @@ -15925,6 +16658,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Ignored; only relevant to definition regions undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, // Builder only state @@ -16007,6 +16741,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { basedpython_statement_expression_values: _, collection_use_constraints: _, expressions: _, + comparison_truthiness: _, string_annotations: _, unsolved_typevar_calls: _, expected_types: _, @@ -16017,6 +16752,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred: _, called_functions: _, undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, qualifiers: _, type_expression_flags: _, @@ -16068,6 +16804,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Self { context, expressions, + comparison_truthiness, type_expression_flags, fluid_creation: _, fluid_timeline: _, @@ -16087,6 +16824,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Ignored; only relevant to definition regions undecorated_type: _, + deferred_decorator_calls: _, discards_dict_key_assignments: _, // builder only state @@ -16115,7 +16853,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "speculative `TypeInferenceBuilder` should only be used for expression inference" ); - self.expressions.extend(expressions.iter()); + self.extend_expression_types(expressions); + self.comparison_truthiness.extend(comparison_truthiness); self.context.extend(&diagnostics); self.extend_cycle_recovery(cycle_recovery); self.string_annotations @@ -16153,6 +16892,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } +/// The inferred result of a boolean or comparison chain. +struct ChainedBooleanResult<'db> { + value_type: Type<'db>, + /// Combined truthiness of all operands except the last. + /// + /// For `a < b < c`, evaluating the chain as a value tests `a < b` to decide whether to + /// short-circuit, but returns `b < c` without testing it if evaluation continues. + /// Keeping the preceding checks separate lets comparison inference combine this result + /// with the final comparison's truthiness when analyzing the chain as a condition. + /// Using `value_type` instead would model testing the returned object again, which can + /// give a different answer when a comparison returns an object with mutable truthiness. + preceding_truthiness: Truthiness, +} + /// An expression cache shared across builders during multi-inference. /// /// This provides a cheap way of reusing inference results without the overhead @@ -16238,6 +16991,7 @@ enum ExpressionCacheEntry<'db> { /// that is otherwise performed for Salsa results. struct FullExpressionCacheEntry<'db> { expressions: FxHashMap>, + comparison_truthiness: FxHashMap, type_expression_flags: FxHashMap, collection_use_constraints: CollectionUseConstraints<'db>, fluid_adoptions: FxHashMap>, @@ -16266,6 +17020,7 @@ impl<'db> FullExpressionCacheEntry<'db> { fn is_single_expression(&self, expression: ExpressionNodeKey, ty: Type<'db>) -> bool { self.expressions.len() == 1 && self.expressions.get(&expression) == Some(&ty) + && self.comparison_truthiness.is_empty() && self.type_expression_flags.is_empty() && self.collection_use_constraints.is_empty() && self.fluid_adoptions.is_empty() @@ -16286,6 +17041,7 @@ impl<'db> FullExpressionCacheEntry<'db> { ) -> ExpressionInference<'db> { let extra = (!self.string_annotations.is_empty() || !self.unsolved_typevar_calls.is_empty() + || !self.comparison_truthiness.is_empty() || !self.type_expression_flags.is_empty() || !self.collection_use_constraints.is_empty() || !self.fluid_adoptions.is_empty() @@ -16315,6 +17071,7 @@ impl<'db> FullExpressionCacheEntry<'db> { fluid_adoptions: self.fluid_adoptions, fluid_creation: self.fluid_creation, fluid_timeline: self.fluid_timeline, + comparison_truthiness: FrozenMap::from(self.comparison_truthiness), expected_types: FrozenMap::from(self.expected_types), type_expression_flags: FrozenMap::from(self.type_expression_flags), bindings: self.bindings.into_boxed_slice(), @@ -16890,13 +17647,13 @@ impl IntoIterator for VecSet { #[must_use] struct AddBinding<'db, 'ast> { declared_ty: Option>, + /// The declaration the qualifiers came from, which a diagnostic about them + /// points back to. + declaration: Option>, binding: Definition<'db>, node: AnyNodeRef<'ast>, qualifiers: TypeQualifiers, is_local: bool, - /// The declaration the qualifiers came from, which a diagnostic about them - /// points back to. - declaration: Option>, } impl<'db, 'ast> AddBinding<'db, 'ast> { @@ -16983,7 +17740,15 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { } } - if bound_ty.is_assignable_to(db, env, declared_ty) { + if builder.validate_assignment_type( + self.node, + self.binding, + self.declaration, + declared_ty, + bound_ty, + ) { + // the assignment is valid, which is the only case `bool-as-int` speaks about: a + // `bool` *is* assignable to an `int`, and that is the point report_bool_as_int_assignment( &builder.context, self.node, @@ -16993,13 +17758,6 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { ); } else { builder.discard_dict_key_assignments_for(self.binding); - report_invalid_assignment( - &builder.context, - self.node, - self.binding, - declared_ty, - bound_ty, - ); // Allow declarations to override inference in case of invalid assignment. bound_ty = declared_ty; @@ -17009,11 +17767,14 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { let value_ty = builder.try_expression_type(value).unwrap_or_else(|| { builder.infer_maybe_standalone_expression(value, TypeContext::default()) }); - // If the member is a data descriptor, the RHS value may differ from the value actually assigned. + // Arbitrary data descriptors can transform the assigned value, but slot descriptors + // write it directly into instance storage. if assignment_attribute_members(db, env, value_ty, &attr.id) .and_then(AssignmentAttributeMembers::type_member) .and_then(|member| member.place.ignore_possibly_undefined()) - .is_some_and(|ty| ty.may_be_data_descriptor(db, env)) + .is_some_and(|ty| { + ty.may_be_data_descriptor(db, env) && !matches!(ty, Type::SlotDescriptor(_)) + }) { builder.discard_dict_key_assignments_for(self.binding); bound_ty = declared_ty; diff --git a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs index c612a9b596..54385802bd 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs @@ -84,7 +84,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { deferred_state }; - let previous_deferred_state = std::mem::replace(&mut self.deferred_state, state); + let previous_deferred_state = self.replace_deferred_state(state); let previous_check_unbound_typevars = self .context .inference_flags @@ -109,7 +109,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { fn infer_name_or_attribute<'db>( ty: Type<'db>, annotation: &ast::Expr, - builder: &TypeInferenceBuilder<'db, '_>, + builder: &mut TypeInferenceBuilder<'db, '_>, pep_613_policy: PEP613Policy, ) -> AnnotationExpressionInference<'db> { let special_case = match ty { diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs index 621304fd45..1458db9e56 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -12,11 +12,16 @@ use crate::types::call::{Bindings, CallArguments, CallDiagnosticOverride, CallEr use crate::types::class::FrozenDataclassDispatch; use crate::types::dedicated::pydantic; use crate::types::diagnostic::{ - INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, UNRESOLVED_ATTRIBUTE, report_bad_dunder_set_call, - report_bool_as_int, report_invalid_attribute_assignment, report_possibly_missing_attribute, + DEPRECATED, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, MISSING_SLOT, UNRESOLVED_ATTRIBUTE, + report_bad_dunder_set_call, report_bool_as_int, report_invalid_attribute_assignment, + report_possibly_missing_attribute, }; use crate::types::safe_variance::private_member_view; -use crate::types::{CallDunderError, MemberLookupPolicy, Type, TypeContext, TypeQualifiers}; +use crate::types::{ + CallDunderError, DisplaySettings, MemberLookupPolicy, PropertyDeprecations, Type, TypeContext, + TypeQualifiers, +}; +use crate::{Db, ProgramEnvironment}; impl<'db> TypeInferenceBuilder<'db, '_> { /// Make sure that the attribute assignment `obj.attribute = value` is valid. @@ -70,6 +75,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; let requirement = attribute_write_requirement(db, env, write_receiver, attribute); + let mut deprecation = (emit_diagnostics && self.context.is_lint_enabled(&DEPRECATED)) + .then_some(AttributeDeprecation::Missing); let mut evaluator = AssignmentAttributeWriteEvaluator { builder: self, target, @@ -78,7 +85,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { attribute, infer_value_ty: MultiInferenceGuard::new(infer_value_ty), }; - evaluator.evaluate(&requirement, emit_diagnostics) + let valid = evaluator.evaluate(&requirement, emit_diagnostics, deprecation.as_mut()); + if let Some(AttributeDeprecation::Deprecated(properties)) = deprecation { + self.check_deprecated_property(target, properties, ast::ExprContext::Store); + } + valid } } @@ -116,6 +127,107 @@ enum ContextualInference { Speculate, } +/// Whether a resolved write target contributes or suppresses a property deprecation. +#[derive(Clone, Copy)] +enum AttributeDeprecation<'db> { + /// No declared member provides an alternative to another member's deprecated accessor. + Missing, + /// A non-deprecated member can provide the implementation in an intersection. + NotDeprecated, + /// Accessor deprecations checked at the assignment site. + Deprecated(PropertyDeprecations<'db>), +} + +impl<'db> AttributeDeprecation<'db> { + /// Inspect a resolved requirement without inferring or validating the assigned value. + /// Only union and intersection children need further attribute lookups. + fn from_requirement( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + requirement: &AttributeWriteRequirement<'db>, + attribute: &str, + ) -> Self { + match requirement { + AttributeWriteRequirement::All { element_tys, .. } => { + element_tys.iter().fold(Self::Missing, |deprecation, ty| { + let requirement = attribute_write_requirement(db, env, *ty, attribute); + deprecation.union(db, Self::from_requirement(db, env, &requirement, attribute)) + }) + } + AttributeWriteRequirement::Any { element_tys, .. } => { + let mut deprecation = Self::Missing; + for ty in element_tys { + let requirement = attribute_write_requirement(db, env, *ty, attribute); + deprecation = deprecation + .intersection(db, Self::from_requirement(db, env, &requirement, attribute)); + if matches!(deprecation, Self::NotDeprecated) { + break; + } + } + deprecation + } + AttributeWriteRequirement::ProtocolMember { + write: Some(ProtocolMemberWriteRequirement::Descriptor { descriptor_ty, .. }), + .. + } + | AttributeWriteRequirement::Instance { + member: + InstanceAttributeWriteMember::Explicit { + member: ExplicitAttributeWriteRequirement::Descriptor { descriptor_ty, .. }, + .. + }, + .. + } + | AttributeWriteRequirement::Class { + member: + ClassAttributeWriteMember::Explicit { + member: ExplicitAttributeWriteRequirement::Descriptor { descriptor_ty, .. }, + .. + }, + .. + } if let Some(properties) = descriptor_ty.property_deprecations(db) => { + Self::Deprecated(properties) + } + AttributeWriteRequirement::Instance { + member: InstanceAttributeWriteMember::SetAttr, + .. + } + | AttributeWriteRequirement::Class { + member: ClassAttributeWriteMember::Unresolved { .. }, + .. + } + | AttributeWriteRequirement::Module(None) => Self::Missing, + _ => Self::NotDeprecated, + } + } + + /// A union can invoke either target, so either target can contribute a deprecation. + fn union(self, db: &'db dyn Db, other: Self) -> Self { + match (self, other) { + (Self::Deprecated(left), Self::Deprecated(right)) => { + Self::Deprecated(left.union(db, right)) + } + (deprecated @ Self::Deprecated(_), _) | (_, deprecated @ Self::Deprecated(_)) => { + deprecated + } + (Self::NotDeprecated, _) | (_, Self::NotDeprecated) => Self::NotDeprecated, + (Self::Missing, Self::Missing) => Self::Missing, + } + } + + /// An intersection can use a non-deprecated member instead, but an absent member cannot + /// provide an alternative implementation. + fn intersection(self, db: &'db dyn Db, other: Self) -> Self { + match (self, other) { + (Self::NotDeprecated, _) | (_, Self::NotDeprecated) => Self::NotDeprecated, + (Self::Deprecated(left), Self::Deprecated(right)) => { + Self::Deprecated(left.intersection(db, right)) + } + (Self::Missing, other) | (other, Self::Missing) => other, + } + } +} + struct AssignmentAttributeWriteEvaluator<'a, 'db, 'ast, 'infer> { builder: &'a mut TypeInferenceBuilder<'db, 'ast>, target: &'a ast::ExprAttribute, @@ -190,60 +302,112 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { (setattr_result, value_ty) } + /// Validate the assignment and optionally record its accessor deprecations. + /// After validation short-circuits, a pure collector inspects the remaining alternatives + /// without changing the inference context selected for the assigned value. + /// When provided, `deprecation` receives the result even if validation fails. fn evaluate( &mut self, requirement: &AttributeWriteRequirement<'db>, emit_diagnostics: bool, + mut deprecation: Option<&mut AttributeDeprecation<'db>>, ) -> bool { let db = self.builder.db(); let env = self.builder.program_environment(); + if let Some(deprecation) = deprecation.as_deref_mut() { + *deprecation = match requirement { + AttributeWriteRequirement::All { .. } | AttributeWriteRequirement::Any { .. } => { + AttributeDeprecation::Missing + } + _ => AttributeDeprecation::from_requirement(db, env, requirement, self.attribute), + }; + } + match requirement { AttributeWriteRequirement::All { object_ty, element_tys, } => { let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); - let mut valid = true; - for element_ty in *element_tys { - let requirement = - attribute_write_requirement(db, env, *element_ty, self.attribute); - if !self.evaluate(&requirement, false) { - valid = false; - break; + let attribute = self.attribute; + let mut requirements = element_tys + .iter() + .map(|ty| attribute_write_requirement(db, env, *ty, attribute)); + let valid = requirements.by_ref().all(|requirement| { + let mut current = AttributeDeprecation::Missing; + let valid = self.evaluate( + &requirement, + false, + deprecation.as_ref().map(|_| &mut current), + ); + if let Some(deprecation) = deprecation.as_deref_mut() { + *deprecation = deprecation.union(db, current); } + valid + }); + if let Some(deprecation) = deprecation { + *deprecation = requirements.fold(*deprecation, |deprecation, requirement| { + deprecation.union( + db, + AttributeDeprecation::from_requirement( + db, + env, + &requirement, + attribute, + ), + ) + }); } if valid { self.validate_composite_final_assignment(*object_ty, emit_diagnostics); - true - } else { - if emit_diagnostics { - self.report( - AssignmentAttributeWriteDiagnostic::InvalidCompositeAssignment { - object_ty: *object_ty, - value_ty, - }, - ); - } - false + } else if emit_diagnostics { + self.report( + AssignmentAttributeWriteDiagnostic::InvalidCompositeAssignment { + object_ty: *object_ty, + value_ty, + }, + ); } + valid } AttributeWriteRequirement::Any { object_ty, element_tys, } => { - let mut valid = false; - for element_ty in element_tys { - let requirement = - attribute_write_requirement(db, env, *element_ty, self.attribute); - if self.evaluate(&requirement, false) { - valid = true; - break; + let attribute = self.attribute; + let mut requirements = element_tys + .iter() + .map(|ty| attribute_write_requirement(db, env, *ty, attribute)); + let valid = requirements.by_ref().any(|requirement| { + let mut current = AttributeDeprecation::Missing; + let valid = self.evaluate( + &requirement, + false, + deprecation.as_ref().map(|_| &mut current), + ); + if let Some(deprecation) = deprecation.as_deref_mut() { + *deprecation = deprecation.intersection(db, current); + } + valid + }); + if let Some(deprecation) = deprecation { + while !matches!(deprecation, AttributeDeprecation::NotDeprecated) + && let Some(requirement) = requirements.next() + { + *deprecation = deprecation.intersection( + db, + AttributeDeprecation::from_requirement( + db, + env, + &requirement, + attribute, + ), + ); } } if valid { self.infer_with_last_context(emit_diagnostics); self.validate_composite_final_assignment(*object_ty, emit_diagnostics); - true } else { let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); if emit_diagnostics { @@ -254,8 +418,8 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { }, ); } - false } + valid } AttributeWriteRequirement::Unconstrained => { self.infer_value(TypeContext::default(), emit_diagnostics); @@ -852,6 +1016,13 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { TypeContext::new(Some(*ty)), matches!(inference, ContextualInference::Commit) && emit_diagnostics, ); + if !self.builder.validate_generic_class_attribute_access( + self.target, + object_ty, + emit_diagnostics, + ) { + return false; + } if !self.final_assignment_is_valid(object_ty, *qualifiers, emit_diagnostics) { return false; } @@ -881,11 +1052,16 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { .context .report_lint(&INVALID_ASSIGNMENT, self.target) { + let settings = DisplaySettings::from_possibly_ambiguous_types( + db, + env, + [value_ty, object_ty], + ); builder.into_diagnostic(format_args!( "Object of type `{}` is not assignable to attribute `{}` on type `{}`", - value_ty.display(db, env), + value_ty.display_with(db, env, settings.clone()), self.attribute, - object_ty.display(db, env), + object_ty.display_with(db, env, settings), )); } } @@ -999,6 +1175,31 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { }, ); } + AssignmentAttributeWriteDiagnostic::Unresolved { with_period: false } + if self + .object_ty + .nominal_class(db, env) + .and_then(|class| class.static_class_literal(db)) + .is_some_and(|(class, _)| class.lacks_instance_storage(db, self.attribute)) + && !self + .object_ty + .class_member(db, env, self.attribute) + .place + .is_undefined() => + { + if let Some(builder) = self.builder.context.report_lint(&MISSING_SLOT, self.target) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Cannot assign to attribute `{}`: `{}` has no slot or instance dictionary", + self.attribute, + self.object_ty.display(db, env), + )); + diagnostic.info(format_args!( + "Attribute `{}` is declared but is not included in `__slots__`", + self.attribute, + )); + } + } AssignmentAttributeWriteDiagnostic::Unresolved { with_period } => { if let Some(builder) = self .builder diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index b41b17aabb..7286853ca2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs @@ -11,6 +11,7 @@ use crate::types::deferred::{is_integer_operand, is_symbolic_operand}; use crate::types::diagnostic::{ DIVISION_BY_ZERO, report_unsupported_augmented_assignment, report_unsupported_binary_operation, }; +use crate::types::function::OverloadLiteral; use crate::types::inferred_signature::gradual_hole; use crate::types::set_theoretic::RecursivelyDefined; use crate::types::tuple::Tuple; @@ -30,6 +31,13 @@ enum BinaryExpressionOperandTypes<'db> { type BinaryExpressionVisitor<'db> = CycleDetector<'db, ast::Operator, (Type<'db>, ast::Operator, Type<'db>), Option>, 1>; +/// Diagnostic state shared across the alternatives of one binary or augmented operation. +#[derive(Default)] +pub(crate) struct BinaryInferenceState<'db> { + pub(super) emitted_division_by_zero_diagnostic: bool, + pub(super) deprecated_functions: Vec>, +} + impl<'db> TypeInferenceBuilder<'db, '_> { pub(super) fn infer_binary_expression( &mut self, @@ -82,14 +90,17 @@ impl<'db> TypeInferenceBuilder<'db, '_> { BinaryExpressionOperandTypes::Inferred(left_ty, right_ty) => (left_ty, right_ty), }; - self.infer_binary_expression_type(binary.into(), false, left_ty, right_ty, *op, tcx) + let mut state = BinaryInferenceState::default(); + let return_type = self + .infer_binary_expression_type(binary.into(), left_ty, right_ty, *op, tcx, &mut state) // basedpython: an applicable extension may supply the left // operand's dunder, or the right operand's reflected one - .or_else(|| self.try_binary_extension_operator(left_ty, *op, right_ty)) - .unwrap_or_else(|| { - report_unsupported_binary_operation(&self.context, binary, left_ty, right_ty, *op); - Type::unknown() - }) + .or_else(|| self.try_binary_extension_operator(left_ty, *op, right_ty)); + self.report_deprecated_functions(binary, state.deprecated_functions); + return_type.unwrap_or_else(|| { + report_unsupported_binary_operation(&self.context, binary, left_ty, right_ty, *op); + Type::unknown() + }) } fn infer_pep_604_union_type_alias( @@ -319,23 +330,48 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }) } + /// Collect deprecations from the selected operator methods while reusing cached resolution. + fn infer_binary_dunder( + &self, + state: &mut BinaryInferenceState<'db>, + left_ty: Type<'db>, + op: ast::Operator, + right_ty: Type<'db>, + tcx: TypeContext<'db>, + ) -> Option> { + let result = Type::try_call_bin_op_result_with_tcx( + self.db(), + self.program_environment(), + left_ty, + op, + right_ty, + tcx, + )?; + state + .deprecated_functions + .extend(&result.deprecated_functions); + Some(result.return_type) + } + + /// Infer the result type and collect deprecated methods for the enclosing operation. + /// The caller reports them together after expanding union operands and in-place fallbacks. pub(super) fn infer_binary_expression_type( &mut self, node: AnyNodeRef<'_>, - emitted_division_by_zero_diagnostic: bool, left_ty: Type<'db>, right_ty: Type<'db>, op: ast::Operator, tcx: TypeContext<'db>, + state: &mut BinaryInferenceState<'db>, ) -> Option> { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, left_ty, right_ty, op, &BinaryExpressionVisitor::new(Some(Type::Never)), tcx, + state, ) } @@ -343,19 +379,19 @@ impl<'db> TypeInferenceBuilder<'db, '_> { fn infer_binary_expression_type_impl( &mut self, node: AnyNodeRef<'_>, - mut emitted_division_by_zero_diagnostic: bool, left_ty: Type<'db>, right_ty: Type<'db>, op: ast::Operator, visitor: &BinaryExpressionVisitor<'db>, tcx: TypeContext<'db>, + state: &mut BinaryInferenceState<'db>, ) -> Option> { let env = self.program_environment(); let db = self.db(); // Check for division by zero; this doesn't change the inferred type for the expression, but // may emit a diagnostic - if !emitted_division_by_zero_diagnostic + if !state.emitted_division_by_zero_diagnostic && matches!( op, ast::Operator::Div | ast::Operator::FloorDiv | ast::Operator::Mod @@ -364,7 +400,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { literal.as_bool() == Some(false) || literal.as_int() == Some(0) }) { - emitted_division_by_zero_diagnostic = self.check_division_by_zero(node, op, left_ty); + state.emitted_division_by_zero_diagnostic = + self.check_division_by_zero(node, op, left_ty); } match (left_ty, right_ty, op) { @@ -373,12 +410,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, overlapping.value_type(db, env), right_ty, op, visitor, tcx, + state, ) }) } @@ -387,12 +424,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, restricted.value_type(db), right_ty, op, visitor, tcx, + state, ) }) } @@ -400,12 +437,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, left_ty, restricted.value_type(db), op, visitor, tcx, + state, ) }) } @@ -413,12 +450,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, left_ty, overlapping.value_type(db, env), op, visitor, tcx, + state, ) }) } @@ -433,12 +470,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, gradual_hole(db, env, left).unwrap_or(left), gradual_hole(db, env, right).unwrap_or(right), op, visitor, tcx, + state, ) }) } @@ -464,45 +501,45 @@ impl<'db> TypeInferenceBuilder<'db, '_> { (Type::Deferred(deferred), _, _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, deferred.reduced(db, env), right_ty, op, visitor, tcx, + state, ) }), (_, Type::Deferred(deferred), _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, left_ty, deferred.reduced(db, env), op, visitor, tcx, + state, ) }), (Type::Union(lhs_union), rhs, _) => lhs_union.try_map(db, env, |lhs_element| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, *lhs_element, rhs, op, visitor, tcx, + state, ) }), (lhs, Type::Union(rhs_union), _) => rhs_union.try_map(db, env, |rhs_element| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, lhs, *rhs_element, op, visitor, tcx, + state, ) }), @@ -516,12 +553,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .filter_map(|lhs_element| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, *lhs_element, rhs, op, visitor, tcx, + state, ) }) .collect(); @@ -535,12 +572,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .filter_map(|rhs_element| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, lhs, *rhs_element, op, visitor, tcx, + state, ) }) .collect(); @@ -551,24 +588,24 @@ impl<'db> TypeInferenceBuilder<'db, '_> { (Type::TypeAlias(alias), rhs, _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, alias.value_type(db), rhs, op, visitor, tcx, + state, ) }), (lhs, Type::TypeAlias(alias), _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, lhs, alias.value_type(db), op, visitor, tcx, + state, ) }), @@ -605,8 +642,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { (unknown @ Type::Dynamic(DynamicType::UnknownGeneric(_)), _, _) | (_, unknown @ Type::Dynamic(DynamicType::UnknownGeneric(_)), _) => Some(unknown), - (typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _, _) - | (_, typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _) => Some(typevar), + ( + placeholder @ Type::Dynamic( + DynamicType::UnspecializedTypeVar | DynamicType::UnknownLambdaParameter, + ), + _, + _, + ) + | ( + _, + placeholder @ Type::Dynamic( + DynamicType::UnspecializedTypeVar | DynamicType::UnknownLambdaParameter, + ), + _, + ) => Some(placeholder), // When both operands are the same constrained TypeVar (e.g., `T: (int, str)`), // we check if the operation is valid for each constraint paired with itself. @@ -632,18 +681,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { constraints, |constraint| { self.infer_binary_expression_type( - node, - emitted_division_by_zero_diagnostic, - constraint, - constraint, - op, - tcx, + node, constraint, constraint, op, tcx, state, ) }, ) } // For bounded TypeVars or unconstrained TypeVars, fall through to the default handling. - _ => Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty), + _ => self.infer_binary_dunder(state, left_ty, op, right_ty, tcx), } } @@ -664,19 +708,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { constraints, |constraint| { self.infer_binary_expression_type_impl( - node, - emitted_division_by_zero_diagnostic, - constraint, - rhs, - op, - visitor, - tcx, + node, constraint, rhs, op, visitor, tcx, state, ) }, ) } // For bounded TypeVars or unconstrained TypeVars, fall through to the default handling. - _ => Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty), + _ => self.infer_binary_dunder(state, left_ty, op, right_ty, tcx), } } @@ -692,19 +730,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { constraints, |constraint| { self.infer_binary_expression_type_impl( - node, - emitted_division_by_zero_diagnostic, - lhs, - constraint, - op, - visitor, - tcx, + node, lhs, constraint, op, visitor, tcx, state, ) }, ) } // For bounded TypeVars or unconstrained TypeVars, fall through to the default handling. - _ => Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty), + _ => self.infer_binary_dunder(state, left_ty, op, right_ty, tcx), } } @@ -714,32 +746,32 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // get the same `int | float` and `int | float | complex` special treatment that the // positional arguments get. In those cases we need to explicitly delegate to the base // type, so that it hits the `Type::Union` branches above. - (Type::NewTypeInstance(newtype), rhs, _) => { - Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty).or_else(|| { + (Type::NewTypeInstance(newtype), rhs, _) => self + .infer_binary_dunder(state, left_ty, op, right_ty, tcx) + .or_else(|| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, newtype.concrete_base_type(db), rhs, op, visitor, tcx, + state, ) - }) - } - (lhs, Type::NewTypeInstance(newtype), _) => { - Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty).or_else(|| { + }), + (lhs, Type::NewTypeInstance(newtype), _) => self + .infer_binary_dunder(state, left_ty, op, right_ty, tcx) + .or_else(|| { self.infer_binary_expression_type_impl( node, - emitted_division_by_zero_diagnostic, lhs, newtype.concrete_base_type(db), op, visitor, tcx, + state, ) - }) - } + }), (todo @ Type::Dynamic(DynamicType::Todo(_)), _, _) | (_, todo @ Type::Dynamic(DynamicType::Todo(_)), _) => Some(todo), @@ -754,8 +786,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } else { RecursivelyDefined::No }; - let result = - literal_binary_op(db, env, left_ty, right_ty, op, self.is_basedpython_file()); + let result = literal_binary_op( + db, + env, + left_ty, + right_ty, + op, + self.is_basedpython_file(), + state, + ); result.map(|result| match result { Type::LiteralValue(literal) => { @@ -894,7 +933,14 @@ impl<'db> TypeInferenceBuilder<'db, '_> { MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ) .ok() - .map(|binding| binding.return_type(db, env)), + .map(|binding| { + state.deprecated_functions.extend( + binding + .deprecated_functions(db) + .map(|(_, function)| function), + ); + binding.return_type(db, env) + }), // fold `(a, b) * n` (and `n * (a, b)`) into a fixed-length tuple with the // elements repeated `n` times, matching the runtime behaviour of @@ -903,24 +949,21 @@ impl<'db> TypeInferenceBuilder<'db, '_> { (Type::NominalInstance(_), _, ast::Operator::Mult) if right_ty.as_int_like_literal().is_some() => { - fold_tuple_repeat(db, env, left_ty, right_ty).or_else(|| { - Type::try_call_bin_op_return_type_with_tcx(db, env, left_ty, op, right_ty, tcx) - }) + fold_tuple_repeat(db, env, left_ty, right_ty) + .or_else(|| self.infer_binary_dunder(state, left_ty, op, right_ty, tcx)) } (_, Type::NominalInstance(_), ast::Operator::Mult) if left_ty.as_int_like_literal().is_some() => { - fold_tuple_repeat(db, env, right_ty, left_ty).or_else(|| { - Type::try_call_bin_op_return_type_with_tcx(db, env, left_ty, op, right_ty, tcx) - }) + fold_tuple_repeat(db, env, right_ty, left_ty) + .or_else(|| self.infer_binary_dunder(state, left_ty, op, right_ty, tcx)) } // fold `(a, b) + (c,)` into `(a, b, c)`. as with `*`, typeshed's `tuple.__add__` // otherwise widens the concatenation to `tuple[T, ...]` (Type::NominalInstance(_), Type::NominalInstance(_), ast::Operator::Add) => { - fold_tuple_concat(db, env, left_ty, right_ty).or_else(|| { - Type::try_call_bin_op_return_type_with_tcx(db, env, left_ty, op, right_ty, tcx) - }) + fold_tuple_concat(db, env, left_ty, right_ty) + .or_else(|| self.infer_binary_dunder(state, left_ty, op, right_ty, tcx)) } // We've handled all of the special cases that we support for literals, so we need to @@ -942,6 +985,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::Intersection(_) | Type::EnumComplement(_) | Type::AlwaysTruthy @@ -969,6 +1013,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::Intersection(_) | Type::EnumComplement(_) | Type::AlwaysTruthy @@ -981,7 +1026,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { | Type::TypeForm(_) | Type::TypedDict(_), op, - ) => Type::try_call_bin_op_return_type_with_tcx(db, env, left_ty, op, right_ty, tcx), + ) => self.infer_binary_dunder(state, left_ty, op, right_ty, tcx), } } @@ -1052,7 +1097,7 @@ fn as_complex_components<'db>( } /// basedpython literal-arithmetic outcome -pub(crate) enum LiteralArithOutcome<'db> { +enum LiteralArithOutcome<'db> { /// A literal value was computed Literal(Type<'db>), /// Arithmetic is defined but the result is undefined at runtime (NaN, division by zero). @@ -1253,6 +1298,7 @@ pub(crate) fn literal_binary_op<'db>( right_ty: Type<'db>, op: ast::Operator, is_basedpython: bool, + state: &mut BinaryInferenceState<'db>, ) -> Option> { let (Type::LiteralValue(left), Type::LiteralValue(right)) = (left_ty, right_ty) else { return None; @@ -1435,6 +1481,7 @@ pub(crate) fn literal_binary_op<'db>( right_ty, op, is_basedpython, + state, ), (LiteralValueTypeKind::Int(_), LiteralValueTypeKind::Bool(b2), op) => literal_binary_op( @@ -1444,6 +1491,7 @@ pub(crate) fn literal_binary_op<'db>( Type::int_literal(i64::from(b2)), op, is_basedpython, + state, ), (LiteralValueTypeKind::Int(n), LiteralValueTypeKind::Int(m), ast::Operator::LShift) @@ -1534,7 +1582,11 @@ pub(crate) fn literal_binary_op<'db>( Some(widened) } Some(LiteralArithOutcome::Unsupported) | None => { - Type::try_call_bin_op_return_type(db, env, left_ty, op, right_ty) + let result = Type::try_call_bin_op_result(db, env, left_ty, op, right_ty)?; + state + .deprecated_functions + .extend(&result.deprecated_functions); + Some(result.return_type) } } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/class.rs b/crates/ty_python_semantic/src/types/infer/builder/class.rs index 3b4df1d10b..f038cc78ec 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/class.rs @@ -1,14 +1,12 @@ use crate::Db; use crate::ProgramEnvironment; -use crate::place::Place; use crate::reified::{UnansweredReason, reified_class_reads}; use crate::types::diagnostic::{INVALID_VARIANCE_DECLARATION, REIFIED_WITHOUT_RECEIVER}; use crate::types::{ CallArguments, ClassLiteralFlags, DataclassFlags, DataclassParams, KnownClass, - KnownInstanceType, MemberLookupPolicy, SpecialFormType, StaticClassLiteral, SubclassOfType, - Type, TypeContext, TypedDictModule, + KnownInstanceType, SpecialFormType, StaticClassLiteral, SubclassOfType, Type, TypeContext, + TypingModule, call::CallError, - callable::CallableFunctionProvenance, function::KnownFunction, infer::{ InferenceFlags, TypeInferenceBuilder, @@ -120,8 +118,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if class.arguments.is_some() { let defer_class_args = self.in_stub() || self.is_basedpython_file(); - let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, defer_class_args.into()); + let previous_deferred_state = self.replace_deferred_state(defer_class_args.into()); // PEP 695 class headers are inferred in the type-parameter scope, before the completed // class type is available. Infer the bases first because `extra_items=T` is an @@ -142,7 +139,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.infer_expression(base, TypeContext::default()) }; is_typed_dict |= match ty { - ty if TypedDictModule::from_type(self.db(), ty).is_some() => true, + ty if TypingModule::from_typed_dict_type(self.db(), ty).is_some() => true, Type::ClassLiteral(class) => class.is_typed_dict(self.db()), Type::GenericAlias(alias) => alias.is_typed_dict(self.db()), _ => false, @@ -336,11 +333,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { )), } }; - let decorator_call_ty = |decorator: &ast::Decorator| match &decorator.expression { - ast::Expr::Call(call) => Some(self.expression_type(&call.func)), - _ => None, - }; - // In the first pass, collect metadata decorators that shape the original class object. // Once an inner decorator replaces the public binding, outer decorators are ordinary // runtime applications only: they cannot retroactively add metadata to the original class. @@ -453,17 +445,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Ok(return_ty) => *return_ty, Err(error) => error.return_type(db, env), }; - if is_unknown_decorator_result(db, decorated_ty) { - if !preserve_binding_for_unknown_result( - db, - env, - decorator_ty, - decorator_call_ty(decorator), - decorated_ty, - ) { - metadata_applies_to_original_class = false; - } - } else if !type_retains_original_class(db, env, original_class_ty, decorated_ty) { + if !is_unknown_decorator_result(db, decorated_ty) + && !type_retains_original_class(db, env, original_class_ty, decorated_ty) + { metadata_applies_to_original_class = false; } @@ -502,7 +486,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let decorated_ty = match decorator_result { Ok(return_ty) => return_ty, Err(CallError(_, bindings)) => { - bindings.report_diagnostics(&self.context, decorator_node.into()); + self.defer_decorator_call(decorator_node, inferred_ty); bindings.return_type(db, env) } }; @@ -510,17 +494,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Type::DataclassDecorator(_) | Type::DataclassTransformer(_) => Type::unknown(), decorated_ty => decorated_ty, }; - // If a class decorator application loses all precision, preserve the original class - // binding for decorators known to preserve unknown results. - let should_preserve_binding = is_unknown_decorator_result(db, decorated_ty) - && preserve_binding_for_unknown_result( - db, - env, - decorator_ty, - decorator_call_ty(decorator_node), - decorated_ty, - ); - inferred_ty = if should_preserve_binding { + inferred_ty = if is_unknown_decorator_result(db, decorated_ty) { inferred_ty } else if class_decorator_preserves_class_binding( db, @@ -558,8 +532,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // In stub files (and basedpython files, where self-refs are auto-quoted by the // transpiler), keyword values may reference names that are defined later. let defer_class_args = self.in_stub() || self.is_basedpython_file(); - let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, defer_class_args.into()); + let previous_deferred_state = self.replace_deferred_state(defer_class_args.into()); for keyword in class_node.keywords() { if keyword.arg.as_deref() != Some("extra_items") { self.infer_expression(&keyword.value, TypeContext::default()); @@ -786,218 +759,18 @@ fn type_retains_original_class<'db>( } } -/// Return true if an unknown class-decorator result should leave the current class type in place. -/// -/// This handles both direct decorators and decorator factories: -/// ```python -/// def decorator(cls): -/// return cls -/// -/// def decorator_factory(): -/// return decorator -/// -/// @decorator_factory() -/// class C: ... -/// ``` -/// -/// The factory case needs the type of the call target, because the type of -/// `@decorator_factory()` is the returned decorator, while the expression type of -/// `decorator_factory` carries the static information that tells us whether an unknown result can -/// be preserved. -fn preserve_binding_for_unknown_result<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - decorator_ty: Type<'db>, - decorator_call_ty: Option>, - decorator_result_ty: Type<'db>, -) -> bool { - ClassDecoratorUnknownResultPolicy::from_decorator(db, env, decorator_ty, decorator_result_ty) - == ClassDecoratorUnknownResultPolicy::PreserveBinding - || decorator_call_ty.is_some_and(|ty| { - ClassDecoratorUnknownResultPolicy::from_decorator(db, env, ty, decorator_result_ty) - == ClassDecoratorUnknownResultPolicy::PreserveBinding - }) -} - -/// Return true if applying a class decorator produced no useful replacement type. -fn is_unknown_decorator_result<'db>(db: &'db dyn Db, ty: Type<'db>) -> bool { - ty.is_unknown() || is_unknown_class_object_decorator_result(db, ty) -} - -/// Return true if applying a class decorator produced an unknown class-object type. +/// Return true if a class-decorator result should leave the current binding unchanged. /// -/// Besides plain `Unknown`, class decorators can produce unknown class-object types such as -/// `type[Any]`. Those are represented as a `SubclassOf` dynamic type, but they should trigger the -/// same preservation fallback as an unknown result: -/// ```python -/// from typing import Any -/// -/// def decorator(cls) -> type[Any]: ... -/// -/// @decorator -/// class C: ... -/// ``` -fn is_unknown_class_object_decorator_result<'db>(db: &'db dyn Db, ty: Type<'db>) -> bool { - let Type::SubclassOf(subclass_of) = ty.resolve_type_alias(db) else { - return false; - }; - - subclass_of - .subclass_of() - .into_dynamic() - .is_some_and(|dynamic| Type::Dynamic(dynamic).is_unknown()) -} - -/// Policy for class decorators whose application result is unknown. -/// -/// This is only consulted after applying the decorator produced no useful replacement type. If the -/// decorator itself statically suggests an unannotated identity-preserving shape, we keep the -/// current class binding; if it explicitly promises a replacement type, or if the decorator is -/// unknown, we let the unknown result replace the binding. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -enum ClassDecoratorUnknownResultPolicy { - /// Preserve the current class binding when the decorator result is unknown. - PreserveBinding, - /// Use the unknown decorator result as the public binding. - ReplaceBinding, -} - -impl ClassDecoratorUnknownResultPolicy { - /// Infer the unknown-result policy from the decorator's own type. - /// - /// Unannotated function and method decorators are treated as class-preserving when their - /// application result is unknown. Explicit return annotations are trusted as replacement - /// intent. - fn from_decorator<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - decorator_ty: Type<'db>, - decorator_result_ty: Type<'db>, - ) -> Self { - if decorator_ty.is_unknown() { - return Self::ReplaceBinding; - } - - Self::known_from_decorator(db, env, decorator_ty, decorator_result_ty) - .unwrap_or(Self::ReplaceBinding) - } - - /// Return the known preservation policy for a class decorator, if one can be read statically. - /// - /// For unknown decorator results, unannotated functions are treated as likely - /// identity-preserving: - /// ```python - /// def decorator(cls): - /// return cls - /// ``` - /// - /// Explicit return annotations are trusted instead: - /// ```python - /// def decorator(cls) -> object: - /// return object() - /// ``` - /// - /// Callable instances and protocols delegate the decision to their `__call__` member, because - /// the decorator value itself is not the function that receives the class. - fn known_from_decorator<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - decorator_ty: Type<'db>, - decorator_result_ty: Type<'db>, - ) -> Option { - match decorator_ty { - Type::FunctionLiteral(function) => { - Some(if function.has_explicit_return_annotation(db) { - Self::ReplaceBinding - } else { - Self::PreserveBinding - }) - } - Type::BoundMethod(method) => { - Some(if method.function(db).has_explicit_return_annotation(db) { - Self::ReplaceBinding - } else { - Self::PreserveBinding - }) - } - Type::NominalInstance(_) | Type::ProtocolInstance(_) => { - let call_symbol = decorator_ty - .member_lookup_with_policy( - db, - env, - "__call__", - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) - .place; - - if let Place::Defined(place) = call_symbol - && place.is_definitely_defined() - { - Some( - Self::known_from_decorator(db, env, place.ty, decorator_result_ty) - .unwrap_or(Self::ReplaceBinding), - ) - } else { - Some(Self::ReplaceBinding) - } - } - Type::Union(union) => Some( - if union.elements(db).iter().all(|element| { - Self::known_from_decorator(db, env, *element, decorator_result_ty) - == Some(Self::PreserveBinding) - }) { - Self::PreserveBinding - } else { - Self::ReplaceBinding - }, - ), - Type::TypeAlias(alias) => Some( - Self::known_from_decorator(db, env, alias.value_type(db), decorator_result_ty) - .unwrap_or(Self::ReplaceBinding), - ), - Type::Callable(callable) => Some(match callable.provenance(db) { - // An unannotated function preserves the class binding when applying it loses the - // concrete return type: - // ```python - // decorator = lambda cls: cls - // - // @decorator - // class C: ... - // ``` - CallableFunctionProvenance::ImplicitReturn => Self::PreserveBinding, - // An explicit return annotation can intentionally replace the class binding: - // ```python - // def decorator[T](cls) -> T: ... - // - // @decorator - // class C: ... - // ``` - CallableFunctionProvenance::ExplicitReturn => Self::ReplaceBinding, - // Generic class-preserving decorator factories can lose the concrete class in - // their returned `Callable`, while still producing an unknown class-object result: - // ```python - // def identity_factory[T]() -> Callable[[type[T]], type[T]]: ... - // - // @identity_factory() - // class C: ... - // ``` - CallableFunctionProvenance::None - if is_unknown_class_object_decorator_result(db, decorator_result_ty) => - { - Self::PreserveBinding - } - // An ordinary `Callable` replacement result has no function provenance to justify - // the unannotated-function preservation fallback: - // ```python - // def replacement_factory[T]() -> Callable[[type[object]], T]: ... - // - // @replacement_factory() - // class C: ... - // ``` - CallableFunctionProvenance::None => Self::ReplaceBinding, - }), - _ => None, - } +/// This also handles `type[Unknown]` results from generic decorator factories whose type +/// variables are specialized before the returned decorator receives the class. Explicit `Any` +/// results do not trigger this fallback. +fn is_unknown_decorator_result<'db>(db: &'db dyn Db, result_ty: Type<'db>) -> bool { + match result_ty.resolve_type_alias(db) { + Type::SubclassOf(subclass_of) => subclass_of + .subclass_of() + .into_dynamic() + .is_some_and(|dynamic| Type::Dynamic(dynamic).is_unknown()), + result_ty => result_ty.is_unknown(), } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/conditions.rs b/crates/ty_python_semantic/src/types/infer/builder/conditions.rs index 9869dcfcb9..640049dd0b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/conditions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/conditions.rs @@ -1,12 +1,24 @@ use std::collections::VecDeque; +use ruff_db::parsed::parsed_module; use ruff_python_ast::{self as ast, helpers::any_over_expr}; -use ruff_text_size::TextRange; +use ruff_text_size::{Ranged, TextRange}; +use rustc_hash::FxHashSet; use ty_module_resolver::KnownModule; +use ty_python_core::definition::{Definition, DefinitionKind}; +use ty_python_core::predicate::PatternSubject; +use ty_python_core::scope::ScopeId; +use ty_python_core::{ProgramFile, place_table, semantic_index, use_def_map}; use ty_python_core::{Truthiness, place::PlaceExpr}; use crate::place::Place; use crate::types::ProgramEnvironment; +use crate::types::TypeContext; +use crate::types::definition_expression_type; +use crate::types::definition_resolution::{ + ImportAliasResolution, ResolvedDefinition, scoped_definitions_for_name, +}; +use crate::types::infer::infer_scope_types; use crate::types::{ ClassLiteral, IntersectionBuilder, KnownClass, Type, diagnostic::{OVERLAPPING_CONDITION, REDUNDANT_BOOLEAN_COMPARISON, REDUNDANT_CONDITION}, @@ -98,6 +110,361 @@ impl ConditionPolarity { } } +/// The scope an expression was written in, which is where its type was worked out. +fn expression_scope<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + expr: &ast::Expr, +) -> ScopeId<'db> { + semantic_index(db, file) + .expression_scope_id(expr) + .to_scope_id(db, file) +} + +/// Where an expression's type is to be found. +/// +/// A definition's own value is typed by that definition — which is also what reaches an expression +/// it borrows from an enclosing scope, such as a comprehension's first iterable. A statement +/// *around* a definition belongs to no definition of its own, so its scope answers for it. +#[derive(Debug, Clone, Copy)] +enum ExpressionTypes<'db> { + Of(Definition<'db>), + InScope(ProgramFile<'db>), +} + +impl<'db> ExpressionTypes<'db> { + fn file(self, db: &'db dyn Db) -> ProgramFile<'db> { + match self { + Self::Of(definition) => definition.program_file(db), + Self::InScope(file) => file, + } + } + + fn of(self, db: &'db dyn Db, expr: &ast::Expr) -> Type<'db> { + if let Self::Of(definition) = self { + let ty = definition_expression_type(db, definition, expr); + if !ty.is_unknown() { + return ty; + } + // an unpacked assignment evaluates its right-hand side once, on its own, so the + // targets sharing it do not carry the parts — the scope that ran it does + } + let file = self.file(db); + infer_scope_types(db, expression_scope(db, file, expr), TypeContext::default()) + .expression_type(expr) + } +} + +/// The free form of [`TypeInferenceBuilder::is_environment_fact`], for an expression in another +/// scope — or another module — than the condition that led here. +fn expression_is_environment_fact<'db>( + db: &'db dyn Db, + types: ExpressionTypes<'db>, + expr: &ast::Expr, +) -> bool { + let is_version_info = |expr: &ast::Expr| { + matches!( + types.of(db, expr), + Type::NominalInstance(instance) if instance.is_sys_version_info() + ) + }; + match expr { + ast::Expr::Name(name) => name.id == "TYPE_CHECKING" || is_version_info(expr), + ast::Expr::Attribute(attribute) => { + if is_version_info(expr) { + return true; + } + let Type::ModuleLiteral(module) = types.of(db, &attribute.value) else { + return false; + }; + let module = module.module(db); + match &*attribute.attr { + "version_info" | "platform" => module.is_known(db, KnownModule::Sys), + "name" => module.is_known(db, KnownModule::Os), + "TYPE_CHECKING" => { + module.is_known(db, KnownModule::Typing) + || module.is_known(db, KnownModule::TypingExtensions) + } + _ => false, + } + } + _ => false, + } +} + +/// Whether the value `definition` binds is decided by the build environment rather than by the +/// program. +/// +/// This is the alias half of [`is_environment_fact`]: `IS_PY314 = sys.version_info >= (3, 14)` is +/// as much a fact about the environment as the `sys.version_info` it reads, and so is every name +/// that goes on to stand for it, in this module or another. Tracked because following one alias +/// reaches whatever module declared it, and a name is asked about once per condition that tests +/// it. +/// +/// [`is_environment_fact`]: TypeInferenceBuilder::is_environment_fact +#[salsa::tracked(returns(copy), cycle_initial=|_, _, _| false, heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn definition_is_environment_derived<'db>( + db: &'db dyn Db, + definition: Definition<'db>, +) -> bool { + let mut visited = FxHashSet::default(); + definition_is_environment_derived_inner(db, definition, &mut visited) +} + +fn definition_is_environment_derived_inner<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + visited: &mut FxHashSet>, +) -> bool { + if !visited.insert(definition) { + return false; + } + let module = parsed_module(db, definition.python_file(db)).load(db); + // an import is followed by name resolution before it ever reaches here, so what is left is + // the value a binding was written with + let value: &ast::Expr = match definition.kind(db) { + DefinitionKind::Assignment(assignment) => assignment.value(&module), + DefinitionKind::AnnotatedAssignment(assignment) => { + let Some(value) = assignment.value(&module) else { + return false; + }; + value + } + DefinitionKind::NamedExpression(named) => &named.node(&module).value, + DefinitionKind::For(for_stmt) => for_stmt.iterable(&module), + DefinitionKind::Comprehension(comprehension) => comprehension.iterable(&module), + DefinitionKind::WithItem(with_item) => with_item.context_expr(&module), + // a capture stands for whatever was matched, so it carries the subject's origin the way + // an assignment carries its value's + DefinitionKind::MatchPattern(pattern) => { + let subject_is_derived = match pattern.predicate().subject(db) { + PatternSubject::Expression(expression) => expression_is_environment_derived( + db, + ExpressionTypes::Of(definition), + expression.node_ref(db).node(&module), + visited, + ), + PatternSubject::Binder(binder) => { + definition_is_environment_derived_inner(db, binder, visited) + } + }; + return subject_is_derived || definition_is_environment_gated(db, definition, visited); + } + _ => return false, + }; + expression_is_environment_derived(db, ExpressionTypes::Of(definition), value, visited) + || definition_is_environment_gated(db, definition, visited) +} + +/// Whether `definition` is only reached when the build environment says so. +/// +/// `line_prefix` below is written twice, and neither value mentions the environment — but which of +/// them the program ever performs is settled before it runs, and so is the `Literal[""]` a reader +/// is then told the name has: +/// +/// ```python +/// if sys.platform == "win32": +/// line_prefix = "\n" +/// else: +/// line_prefix = "" +/// ``` +/// +/// So the statements enclosing the binding are asked, the same question the value was asked. Only +/// this module is walked: a test written in another scope has no type here, which makes it no +/// fact, and a missed guard reports a condition rather than hiding one. +fn definition_is_environment_gated<'db>( + db: &'db dyn Db, + definition: Definition<'db>, + visited: &mut FxHashSet>, +) -> bool { + let file = definition.program_file(db); + let module = parsed_module(db, definition.python_file(db)).load(db); + let target = definition.full_range(db, &module).range(); + statements_gate_range(db, file, &module.syntax().body, target, visited) +} + +/// Whether any statement in `body` that encloses `target` decides it on the environment. +fn statements_gate_range<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + body: &[ast::Stmt], + target: TextRange, + visited: &mut FxHashSet>, +) -> bool { + let Some(statement) = body + .iter() + .find(|statement| statement.range().contains_range(target)) + else { + return false; + }; + let gated = |test: &ast::Expr, visited: &mut FxHashSet>| { + expression_is_environment_derived(db, ExpressionTypes::InScope(file), test, visited) + }; + match statement { + ast::Stmt::If(if_statement) => { + if gated(&if_statement.test, visited) { + return true; + } + for clause in &if_statement.elif_else_clauses { + if let Some(test) = &clause.test + && gated(test, visited) + { + return true; + } + } + std::iter::once(&if_statement.body) + .chain( + if_statement + .elif_else_clauses + .iter() + .map(|clause| &clause.body), + ) + .any(|body| statements_gate_range(db, file, body, target, visited)) + } + ast::Stmt::Match(match_statement) => { + if gated(&match_statement.subject, visited) { + return true; + } + match_statement + .cases + .iter() + .any(|case| statements_gate_range(db, file, &case.body, target, visited)) + } + ast::Stmt::While(while_statement) => { + gated(&while_statement.test, visited) + || std::iter::once(&while_statement.body) + .chain(std::iter::once(&while_statement.orelse)) + .any(|body| statements_gate_range(db, file, body, target, visited)) + } + // every other statement that holds a block: the guard, if there is one, is further in + ast::Stmt::For(for_statement) => [&for_statement.body, &for_statement.orelse] + .into_iter() + .any(|body| statements_gate_range(db, file, body, target, visited)), + ast::Stmt::With(with_statement) => { + statements_gate_range(db, file, &with_statement.body, target, visited) + } + ast::Stmt::Try(try_statement) => [ + &try_statement.body, + &try_statement.orelse, + &try_statement.finalbody, + ] + .into_iter() + .chain(try_statement.handlers.iter().map(|handler| { + let ast::ExceptHandler::ExceptHandler(handler) = handler; + &handler.body + })) + .any(|body| statements_gate_range(db, file, body, target, visited)), + ast::Stmt::FunctionDef(function) => { + statements_gate_range(db, file, &function.body, target, visited) + } + ast::Stmt::ClassDef(class) => statements_gate_range(db, file, &class.body, target, visited), + _ => false, + } +} + +/// Whether `expr`, as it is written in `scope`, is decided by the build environment. +/// +/// Every part of the expression is asked, so a guard stays recognisable through the shapes a +/// program builds around it — a comparison, a conditional expression, a tuple that an assignment +/// then unpacks. +fn expression_is_environment_derived<'db>( + db: &'db dyn Db, + types: ExpressionTypes<'db>, + expr: &ast::Expr, + visited: &mut FxHashSet>, +) -> bool { + let mut found = false; + any_over_expr(expr, &mut |part: &ast::Expr| { + if found { + return true; + } + if leaf_is_environment_derived(db, types, part, visited) { + found = true; + } + found + }); + found +} + +/// Whether one leaf of an expression names an environment fact, directly or through an alias. +fn leaf_is_environment_derived<'db>( + db: &'db dyn Db, + types: ExpressionTypes<'db>, + expr: &ast::Expr, + visited: &mut FxHashSet>, +) -> bool { + if expression_is_environment_fact(db, types, expr) { + return true; + } + let scope = expression_scope(db, types.file(db), expr); + match expr { + ast::Expr::Name(name) => { + scoped_definitions_for_name(db, scope, &name.id, ImportAliasResolution::ResolveAliases) + .into_iter() + .filter_map(|resolved| match resolved { + ResolvedDefinition::Definition(definition) => Some(definition), + ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => None, + }) + .any(|definition| definition_is_environment_derived_inner(db, definition, visited)) + } + // a member read off a class or an instance of one, such as a `Final` flag a module + // computed once from the platform it is being checked for + ast::Expr::Attribute(attribute) => { + let receiver = types.of(db, &attribute.value); + member_definitions(db, scope, receiver, &attribute.attr) + .into_iter() + .any(|definition| definition_is_environment_derived_inner(db, definition, visited)) + } + _ => false, + } +} + +/// The definitions of `name` on `receiver`, for the members a condition can be written against. +fn member_definitions<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + receiver: Type<'db>, + name: &str, +) -> Vec> { + let env = ProgramEnvironment::from_scope(scope); + let mut definitions = Vec::new(); + for element in receiver.union_elements(db) { + // an intersection narrows the receiver to one of its members, and it is that member's + // declaration a read lands on + let element = element + .as_intersection() + .and_then(|intersection| intersection.positive(db).iter().next().copied()) + .unwrap_or(element); + let Some(class) = (match element { + Type::NominalInstance(instance) => Some(instance.class_literal(db, &env)), + Type::ClassLiteral(class) => Some(class), + _ => None, + }) + .and_then(ClassLiteral::as_static) else { + continue; + }; + for ancestor in class.iter_mro(db, None) { + let Some(body_scope) = ancestor + .into_class() + .map(|class| class.class_literal(db)) + .and_then(ClassLiteral::as_static) + .map(|class| class.body_scope(db)) + else { + continue; + }; + let Some(symbol) = place_table(db, body_scope).symbol_id(name) else { + continue; + }; + definitions.extend( + use_def_map(db, body_scope) + .end_of_scope_symbol_bindings(symbol) + .filter_map(|binding| binding.binding.definition()), + ); + } + } + definitions +} + /// Strip the `not`s off a condition, returning the expression whose truthiness is really tested /// and the half of it the condition selects. /// @@ -249,8 +616,53 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// Whether any part of `test` is a build-environment fact, which makes the whole condition's /// constant outcome the checker's doing rather than the program's. + /// + /// A fact reaches a condition under whatever name the program gave it, so a name is followed + /// to what it stands for — through this module and any other. The walk is only ever reached + /// for a condition that is already constant, which is the rare case. fn is_artificial(&self, test: &ast::Expr) -> bool { - any_over_expr(test, &|expr: &ast::Expr| self.is_environment_fact(expr)) + let mut visited = FxHashSet::default(); + any_over_expr(test, &mut |expr: &ast::Expr| { + self.is_environment_fact(expr) || self.reads_environment_alias(expr, &mut visited) + }) + } + + /// Whether `expr` names something that stands for an environment fact. + /// + /// The types this needs are the ones being inferred right now, so they are read off this + /// builder rather than by asking for the scope's inference — which is this. Only the + /// definitions a name resolves to are followed outwards, and each of those is somewhere else. + fn reads_environment_alias( + &self, + expr: &ast::Expr, + visited: &mut FxHashSet>, + ) -> bool { + let db = self.db(); + match expr { + ast::Expr::Name(name) => scoped_definitions_for_name( + db, + self.scope(), + &name.id, + ImportAliasResolution::ResolveAliases, + ) + .into_iter() + .filter_map(|resolved| match resolved { + ResolvedDefinition::Definition(definition) => Some(definition), + ResolvedDefinition::Module(_) | ResolvedDefinition::FileWithRange(_) => None, + }) + .any(|definition| definition_is_environment_derived_inner(db, definition, visited)), + ast::Expr::Attribute(attribute) => { + let Some(receiver) = self.try_expression_type(&attribute.value) else { + return false; + }; + member_definitions(db, self.scope(), receiver, &attribute.attr) + .into_iter() + .any(|definition| { + definition_is_environment_derived_inner(db, definition, visited) + }) + } + _ => false, + } } /// Whether `expr` reads a fact about the environment ty is checking *for*, as opposed to a diff --git a/crates/ty_python_semantic/src/types/infer/builder/dict.rs b/crates/ty_python_semantic/src/types/infer/builder/dict.rs index fecb2c838e..123e632a4a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/dict.rs @@ -27,7 +27,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // is `TypedDict`-shaped. if let Some(tcx) = call_expression_tcx.annotation() && let Some(typed_dict) = tcx - .filter_union(self.db(), Type::is_typed_dict) + .filter_union(self.db(), self.program_environment(), Type::is_typed_dict) .as_typed_dict() { // Only speculate the `**kwargs` applicability check. Assignability handles inputs that @@ -113,5 +113,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { &mut infer_elt_ty, call_expression_tcx, ) + .or_else(|| { + // Empty calls still need constructor validation and have no inferred values to preserve. + if arguments.is_empty() { + return None; + } + + // Without generic definitions, collection inference still checks all values. Return + // `Unknown` in that case to avoid inferring them again. Specialization failures need + // ordinary call checking to report argument errors. + KnownClass::Dict + .try_to_class_literal(db, self.program_environment()) + .and_then(|class| class.generic_context(db)) + .is_none() + .then(Type::unknown) + }) } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/dynamic_class.rs b/crates/ty_python_semantic/src/types/infer/builder/dynamic_class.rs index 2eae58737a..f3407d55ed 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/dynamic_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/dynamic_class.rs @@ -1,8 +1,8 @@ use itertools::Itertools; -use ruff_python_ast::{self as ast, name::Name}; +use ruff_python_ast::{self as ast, HasNodeIndex, NodeIndex, name::Name}; use ruff_text_size::Ranged; -use crate::types::class::DynamicClassLiteral; +use crate::types::class::{DynamicClassLiteral, DynamicClassScopeOffset}; use crate::types::context::InferContext; use crate::types::diagnostic::{ CYCLIC_CLASS_DEFINITION, DUPLICATE_BASE, INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_BASE, @@ -10,7 +10,7 @@ use crate::types::diagnostic::{ report_inconsistent_generic_bases, }; use crate::types::enums::is_enum_class_by_inheritance; -use crate::types::infer::builder::TypeInferenceBuilder; +use crate::types::infer::builder::{DeferredExpressionState, TypeInferenceBuilder}; use crate::types::mro::{DynamicMroError, DynamicMroErrorKind}; use crate::types::{ClassBase, KnownClass, Type, extract_fixed_length_iterable_element_types}; @@ -36,6 +36,44 @@ impl DynamicClassKind { } impl<'db> TypeInferenceBuilder<'db, '_> { + /// Identify a dangling dynamic-class call without retaining an absolute source position. + pub(super) fn dynamic_class_scope_offset( + &self, + call: &ast::ExprCall, + ) -> DynamicClassScopeOffset { + let scope_anchor = self + .scope() + .node(self.db()) + .node_index() + .unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("scope anchor should not be NodeIndex::NONE"); + let relative_index = |index: NodeIndex| { + index + .as_u32() + .expect("dynamic class anchor should not be NodeIndex::NONE") + - anchor_u32 + }; + + if let DeferredExpressionState::InStringAnnotation(enclosing_node_key) = self.deferred_state + { + let enclosing_index = enclosing_node_key.index(); + let string: &ast::ExprStringLiteral = self + .module() + .get_by_index(enclosing_index) + .try_into() + .expect("string annotation key should point to ExprStringLiteral"); + + DynamicClassScopeOffset::StringAnnotation { + offset: relative_index(enclosing_index), + range: call.range() - string.start(), + } + } else { + DynamicClassScopeOffset::Node(relative_index(call.node_index().load())) + } + } + /// Extract base classes from the bases argument of a `type()` or `types.new_class()` call. /// /// Emits a diagnostic if `bases_type` is not a valid bases iterable for the given kind. diff --git a/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs b/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs index 39cf9db71e..8f28fb6b4b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs @@ -1,6 +1,6 @@ use compact_str::ToCompactString; use ruff_python_ast::name::Name; -use ruff_python_ast::{self as ast, NodeIndex, PythonVersion}; +use ruff_python_ast::{self as ast, PythonVersion}; use rustc_hash::FxHashSet; use ty_python_core::definition::Definition; @@ -637,23 +637,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ) -> DynamicEnumAnchor<'db> { match definition { Some(definition) => DynamicEnumAnchor::Definition { definition, spec }, - None => { - let db = self.db(); - let call_node_index = call_expr.node_index.load(); - let scope = self.scope(); - let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("scope anchor should not be NodeIndex::NONE"); - let call_u32 = call_node_index - .as_u32() - .expect("call node should not be NodeIndex::NONE"); - DynamicEnumAnchor::ScopeOffset { - scope, - offset: call_u32 - anchor_u32, - spec, - } - } + None => DynamicEnumAnchor::ScopeOffset { + scope: self.scope(), + offset: self.dynamic_class_scope_offset(call_expr), + spec, + }, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/fluid.rs b/crates/ty_python_semantic/src/types/infer/builder/fluid.rs index bcd37f2e8b..ae5f9dfb97 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/fluid.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/fluid.rs @@ -56,21 +56,21 @@ use crate::types::{KnownFunction, Type, TypeContext, TypeVarVariance}; /// the constraining and locking events of a fluid candidate binding that can /// have executed before a given program point -pub(super) struct FluidConstraints<'db> { +struct FluidConstraints<'db> { /// constraint types learned from widening uses, in flow order - pub(super) constraints: Vec>, + constraints: Vec>, /// whether the specialization is locked at this point - pub(super) locked: bool, + locked: bool, /// whether the lock promotes literal types: an escape promotes (the unknown /// observer sees the promoted type), while an adopting lock uses the observer's /// exact view, which is already part of `constraints` - pub(super) promote_on_lock: bool, + promote_on_lock: bool, } impl FluidConstraints<'_> { /// whether the specialization at this point is exactly the creation-time /// specialization, with literals retained - pub(super) fn is_creation(&self) -> bool { + fn is_creation(&self) -> bool { self.constraints.is_empty() && !self.locked } } @@ -229,7 +229,6 @@ struct FluidView<'db> { struct FluidFold<'db, 'c> { builder: SpecializationBuilder<'db, 'c>, identity_instance: Type<'db>, - generic_context: GenericContext<'db>, /// the file being inferred, so a solved element type can honour `strict-float` file: ruff_db::files::File, /// whether an earlier constraint failed to fold: every solution from that @@ -281,19 +280,17 @@ impl<'db> FluidFold<'db, '_> { promote: bool, ) -> Type<'db> { let file = self.file; - let specialization = self - .builder - .build_with(self.generic_context, |typevar, bounds| { - let lower = bounds?.lower?; - Some(if promote && typevar.widens_literal_solutions(db) { - // see the note in `solve_fluid_specialization` - lower - .promote_in(db, env, file) - .promote_singletons_recursively(db, env) - } else { - lower - }) - }); + let specialization = self.builder.build_merged_with(|typevar, bounds| { + let lower = bounds?.evidence_lower()?; + Some(if promote && typevar.widens_literal_solutions(db) { + // see the note in `solve_fluid_specialization` + lower + .promote_in(db, env, file) + .promote_singletons_recursively(db, env) + } else { + lower + }) + }); self.identity_instance .apply_specialization(db, specialization) } @@ -341,7 +338,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// whether this definition can have a fluid specialization: a single unannotated /// assignment whose place has no declared type (a declared place has a declared /// specialization, which is never fluid) - pub(super) fn is_fluid_candidate(&self, candidate_def: Definition<'db>) -> bool { + fn is_fluid_candidate(&self, candidate_def: Definition<'db>) -> bool { let db = self.db(); if !self.fluid_specializations_enabled() { @@ -457,7 +454,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { continue; } - let Some(specialization) = overload.specialization(db, env) else { + let Some(specialization) = overload.merged_specialization(db, env) else { continue; }; let Some(matched) = overload @@ -513,13 +510,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if let Some(signature_context) = overload.signature.generic_context { let eventual = binding_type(db, candidate_def); let constraints = ConstraintSetBuilder::new(); - let inferable = signature_context.inferable_typevars(db); - let mut builder = - SpecializationBuilder::new(db, env, &constraints, inferable); + let mut builder = SpecializationBuilder::new( + db, + env, + &constraints, + signature_context, + ); if builder.infer(parameter_ty, eventual).is_ok() { let eventual_specialization = - builder.build_with(signature_context, |_, bounds| { - let lower = bounds?.lower?; + builder.build_merged_with(|_, bounds| { + let lower = bounds?.evidence_lower()?; Some(lower) }); overload.return_ty = @@ -546,7 +546,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// /// this is the fallback for the rare views that [`Self::fluid_view_at`] /// cannot resolve from the recorded timeline - pub(super) fn gather_fluid_constraints( + fn gather_fluid_constraints( &self, candidate_def: Definition<'db>, identity_instance: Type<'db>, @@ -708,7 +708,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// this mirrors [`Self::gather_fluid_constraints`] with no `upto`, doing the /// expensive per-event work (statement inference reads, typevar-binding /// checks, solving) exactly once per event instead of once per use - pub(super) fn build_fluid_timeline( + fn build_fluid_timeline( &self, candidate_def: Definition<'db>, identity_instance: Type<'db>, @@ -723,11 +723,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.fluid_creation_constraint(identity_instance, generic_context, creation); let constraint_sets = ConstraintSetBuilder::new(); - let inferable = generic_context.inferable_typevars(db); let mut fold = FluidFold { - builder: SpecializationBuilder::new(db, env, &constraint_sets, inferable), + builder: SpecializationBuilder::new(db, env, &constraint_sets, generic_context), identity_instance, - generic_context, file: self.file(), poisoned: false, events: Vec::new(), @@ -989,16 +987,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let env = self.program_environment(); let db = self.db(); let constraints = ConstraintSetBuilder::new(); - let inferable = generic_context.inferable_typevars(db); - let mut builder = SpecializationBuilder::new(db, env, &constraints, inferable); + let mut builder = SpecializationBuilder::new(db, env, &constraints, generic_context); if builder.infer(identity_instance, constraint).is_err() { // An incompatible context still hands the value to another observer. return true; } - let specialization = builder.build_with(generic_context, |_, bounds| { - let lower = bounds?.lower?; + let specialization = builder.build_merged_with(|_, bounds| { + let lower = bounds?.evidence_lower()?; Some(lower) }); @@ -1010,7 +1007,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// solve the candidate's specialization from the given constraint instances. /// literal types are promoted only once the specialization is locked - pub(super) fn solve_fluid_specialization( + fn solve_fluid_specialization( &self, identity_instance: Type<'db>, generic_context: GenericContext<'db>, @@ -1020,16 +1017,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let env = self.program_environment(); let db = self.db(); let constraints = ConstraintSetBuilder::new(); - let inferable = generic_context.inferable_typevars(db); - let mut builder = SpecializationBuilder::new(db, env, &constraints, inferable); + let mut builder = SpecializationBuilder::new(db, env, &constraints, generic_context); for constraint in constraint_instances { builder.infer(identity_instance, constraint).ok()?; } let file = self.file(); - let specialization = builder.build_with(generic_context, |typevar, bounds| { - let lower = bounds?.lower?; + let specialization = builder.build_merged_with(|typevar, bounds| { + let lower = bounds?.evidence_lower()?; Some(if promote && typevar.widens_literal_solutions(db) { // Match the promotion policy of collection-literal inference: promote // literal types in invariant position, and promote singleton types to @@ -1269,8 +1265,18 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // places no requirement on the specialization and observes the narrow // type as-is; a structured one (`def f[T](t: list[T])`) solves its // typevars against the promoted view. - if annotation.has_unspecialized_type_var(db, env) - && annotation.class_specialization(db, env).is_some() + // + // A parameter is recognized as parametric before its typevars are solved. + // By the time its context reaches the argument they have been, so an + // annotation like `Wrapper[Callable[Concatenate[object, P], R]]` arrives + // spelled out and would otherwise pass for a concrete declared type: the + // binding would adopt exactly the shape the parameter asked for, and an + // invariant `Wrapper` would accept a type argument that does not match it. + // [`TypeContext::prescribes_type_arguments`] remembers what the parameter + // looked like before it was solved. + if tcx.prescribes_type_arguments() + || (annotation.has_unspecialized_type_var(db, env) + && annotation.class_specialization(db, env).is_some()) { if let (Some(timeline), Some(index)) = (timeline, snapshot) { return timeline.solution(index, true).unwrap_or(fallback); diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index b6af0d12de..a3a73c7159 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -2,8 +2,8 @@ use crate::types::any_over_type; use crate::{ Db, ProgramEnvironment, types::{ - KnownClass, KnownInstanceType, ParamSpecAttrKind, SubclassOfInner, SubclassOfType, Type, - TypeContext, TypeVarKind, UnionType, + DynamicType, KnownClass, KnownInstanceType, ParamSpecAttrKind, SubclassOfInner, + SubclassOfType, Type, TypeContext, TypeVarKind, UnionType, class::ClassLiteral, constraints::ConstraintSetBuilder, dedicated::pytest, @@ -31,14 +31,15 @@ use crate::{ DeclaredAndInferredType, DeferredExpressionState, TypeAndRange, TypeParamReification, validate_paramspec_components, }, - function_known_decorator_flags, function_known_decorators, infer_statement_types, - nearest_enclosing_function, original_class_type, + function_known_decorator_flags, function_known_decorators, infer_deferred_types, + infer_function_default_types, infer_statement_types, nearest_enclosing_function, + original_class_type, }, - infer_definition_types, infer_expression_types, infer_scope_types, + infer_definition_types, infer_expression_types, inferred_signature::{can_implicitly_return_none, return_type_from_body}, lifetimes::InheritedBorrow, relation::TypeRelation, - signatures::ReturnCallableTypeVarScope, + signatures::{ReturnCallableTypeVarScope, function_signature_expression_type}, trailing_lambda::{ UnbindableParameters, trailing_lambda_it_borrow, trailing_lambda_it_type, }, @@ -59,18 +60,21 @@ use ruff_python_ast::helpers::{ReturnGuardForm, return_guards}; use ruff_text_size::Ranged; use rustc_hash::FxHashSet; -fn parameters_have_annotations(parameters: &ast::Parameters) -> bool { +fn parameters_have_defaults(parameters: &ast::Parameters) -> bool { parameters .iter_non_variadic_params() - .any(|param| param.parameter.annotation.is_some()) - || parameters - .vararg - .as_deref() - .is_some_and(|param| param.annotation.is_some()) - || parameters - .kwarg - .as_deref() - .is_some_and(|param| param.annotation.is_some()) + .any(|param| param.default.is_some()) +} + +fn function_has_deferred_annotations(function: &ast::StmtFunctionDef) -> bool { + function.type_params.is_none() + && (function.returns.is_some() + // basedpython: a `raises` clause is a type expression of the signature too + || function.raises.is_some() + || function + .parameters + .iter() + .any(|param| param.annotation().is_some())) } /// Whether a non-static method receives an instance or the class itself. @@ -446,8 +450,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { expected_return_ty, ) { - // N.B. the implementation here is the ~same as for `UNSOUND_YIELD`; - // update that too if updating this! + // N.B. the implementation here is the ~same as for `UNSOUND_YIELD` and `UNSOUND_ASSIGNMENT`; + // update those too if updating this! report_unsound_return_statement( &self.context, return_statement.range, @@ -554,8 +558,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeRelation::Redundancy { pure: true }, ) { - // N.B. the implementation here is the ~same as for `UNSOUND_YIELD`; - // update that too if updating this! + // N.B. the implementation here is the ~same as for `UNSOUND_YIELD` and `UNSOUND_ASSIGNMENT`; + // update those too if updating this! report_unsound_return_statement( &self.context, return_statement.range, @@ -825,7 +829,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { node_index: _, is_async: _, name, - type_params, + type_params: _, parameters, returns: _, raises: _, @@ -1024,20 +1028,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )); } - let has_defaults = parameters - .iter_non_variadic_params() - .any(|param| param.default.is_some()); - // If there are type params, parameters and returns are evaluated in that scope. Otherwise, // we defer the inference of any parameter and return annotations. That ensures that we do // not add any spurious salsa cycles when applying decorators below. (Applying a decorator // requires getting the signature of this function definition, which in turn requires // (lazily) inferring the parameter and return types.) If defaults exist, we also defer so // they can be inferred once with type context in the enclosing scope. - let has_signature_annotations = function.returns.is_some() - || function.raises.is_some() - || parameters_have_annotations(parameters); - if (type_params.is_none() && has_signature_annotations) || has_defaults { + if function_has_deferred_annotations(function) || parameters_have_defaults(parameters) { self.deferred.insert(definition); } @@ -1111,22 +1108,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } for (decorator_ty, decorator_node) in decorator_types_and_nodes.iter().rev() { - inferred_ty = if let Type::KnownInstance(KnownInstanceType::Deprecated(deprecated)) = - decorator_ty - && let Type::FunctionLiteral(function) = inferred_ty - { - Type::FunctionLiteral(function.with_deprecated(db, *deprecated)) - } else { - self.apply_decorator(*decorator_ty, inferred_ty, decorator_node) - }; + if let Type::KnownInstance(KnownInstanceType::Deprecated(deprecated)) = decorator_ty { + match inferred_ty { + Type::FunctionLiteral(function) => { + inferred_ty = + Type::FunctionLiteral(function.with_deprecated(db, *deprecated)); + continue; + } + Type::Callable(callable) => { + inferred_ty = Type::Callable(callable.with_deprecated( + db, + overload_literal.with_deprecated(db, *deprecated), + )); + continue; + } + _ => {} + } + } + inferred_ty = self.apply_decorator( + *decorator_ty, + inferred_ty, + decorator_node, + (!is_decorated_overload_implementation).then_some(function), + ); } if is_decorated_overload_implementation { - let function_type = if let Type::FunctionLiteral(function) = inferred_ty { + let last_definition = match inferred_ty { + Type::FunctionLiteral(function) => Some(function.literal(db).last_definition), + Type::Callable(callable) => callable.deprecated(db), + _ => None, + }; + let function_type = if let Some(last_definition) = last_definition { FunctionType::new( db, - function_literal - .with_last_definition_metadata(db, function.literal(db).last_definition), + function_literal.with_last_definition_metadata(db, last_definition), None, ) } else { @@ -1187,97 +1203,83 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - pub(super) fn infer_function_deferred( + pub(super) fn extend_function_deferred( &mut self, definition: Definition<'db>, function: &ast::StmtFunctionDef, ) { let db = self.db(); - let mut prev_in_no_type_check = self - .context - .inference_flags - .replace(InferenceFlags::IN_NO_TYPE_CHECK, true); - for decorator in &function.decorator_list { - let decorator_type = self.infer_decorator(decorator); - if let Type::FunctionLiteral(function) = decorator_type - && let Some(KnownFunction::NoTypeCheck) = function.known(db) - { - // If the function is decorated with the `no_type_check` decorator, - // we need to suppress any errors that come after the decorators. - prev_in_no_type_check = true; - break; - } + if function_has_deferred_annotations(function) { + self.extend_definition(definition, infer_deferred_types(db, definition)); } - self.context - .inference_flags - .set(InferenceFlags::IN_NO_TYPE_CHECK, prev_in_no_type_check); + if parameters_have_defaults(&function.parameters) { + self.extend_definition(definition, infer_function_default_types(db, definition)); + } + } - let has_type_params = function.type_params.is_some(); - let has_defaults = function - .parameters - .iter_non_variadic_params() - .any(|param| param.default.is_some()); + pub(super) fn infer_function_annotations( + &mut self, + definition: Definition<'db>, + function: &ast::StmtFunctionDef, + ) { + // PEP 695 annotations are inferred in the function's type-parameter scope. + if !function_has_deferred_annotations(function) { + return; + } + + self.suppress_errors_for_no_type_check(definition, function); + let previous_typevar_binding_context = self.typevar_binding_context.replace(definition); + self.infer_function_signature_annotations(function, definition); + self.typevar_binding_context = previous_typevar_binding_context; + } + + pub(super) fn infer_function_defaults( + &mut self, + definition: Definition<'db>, + function: &ast::StmtFunctionDef, + ) { + let db = self.db(); + if !parameters_have_defaults(&function.parameters) { + return; + } + self.suppress_errors_for_no_type_check(definition, function); let previous_typevar_binding_context = self.typevar_binding_context.replace(definition); - if !has_type_params { - self.infer_function_signature_annotations(function, definition); - self.infer_raises_clause(function); - } - - if has_defaults { - // In stub files, default values may reference names that are defined later in the file. - let in_stub = self.in_stub(); - let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, in_stub.into()); - - // For generic functions, only defaults are inferred here; annotation types come from - // the type-params scope. - if has_type_params { - let type_params_scope = self - .index - .node_scope(NodeWithScopeRef::FunctionTypeParameters(function)) - .to_scope_id(db, self.program_file()); - let type_params_inference = - infer_scope_types(self.db(), type_params_scope, TypeContext::default()); - - for param_with_default in function.parameters.iter_non_variadic_params() { - let Some(default) = param_with_default.default.as_deref() else { - continue; - }; - let tcx = param_with_default - .parameter - .annotation - .as_deref() - .map(|annotation| { - TypeContext::new(Some( - type_params_inference.expression_type(annotation), - )) - }) - .unwrap_or_else(TypeContext::default); - self.infer_expression(default, tcx); - } - } else { - for param_with_default in function.parameters.iter_non_variadic_params() { - let Some(default) = param_with_default.default.as_deref() else { - continue; - }; - let tcx = param_with_default - .parameter - .annotation - .as_deref() - .map(|annotation| TypeContext::new(Some(self.expression_type(annotation)))) - .unwrap_or_else(TypeContext::default); - self.infer_expression(default, tcx); - } - } + // In stub files, default values may reference names that are defined later in the file. + let previous_deferred_state = self.replace_deferred_state(self.in_stub().into()); - self.deferred_state = previous_deferred_state; + // Borrow annotation types from their own inference result instead of copying that result + // into this query. Scope inference merges both regions when checking the whole function. + for param_with_default in function.parameters.iter_non_variadic_params() { + let Some(default) = param_with_default.default() else { + continue; + }; + let annotation = param_with_default + .annotation() + .map(|annotation| function_signature_expression_type(db, definition, annotation)); + self.infer_expression(default, TypeContext::new(annotation)); } + self.deferred_state = previous_deferred_state; self.typevar_binding_context = previous_typevar_binding_context; } + fn suppress_errors_for_no_type_check( + &mut self, + definition: Definition<'db>, + function: &ast::StmtFunctionDef, + ) { + if !function.decorator_list.is_empty() + && function_known_decorator_flags(self.db(), definition) + .contains(FunctionDecorators::NO_TYPE_CHECK) + { + // Decorator expressions and their diagnostics belong to their own inference query. + // Signature and default inference only need to know whether errors are suppressed. + self.context.inference_flags |= InferenceFlags::IN_NO_TYPE_CHECK; + } + } + /// basedpython: infer the `raises` clause's type expression. /// /// `raises ...` is the gradual exception set rather than a type expression, @@ -1376,6 +1378,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); self.infer_return_type_annotation(function); + self.infer_raises_clause(function); if let Some(type_params) = function.type_params.as_deref() { // basedpython: a `type def` is not a runtime function — the transpiler erases the // declaration, so there is no closure for the specialization step to rebuild @@ -1704,7 +1707,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Avoid duplicate diagnostics: invalid TypedDict literals already emit specific errors. let suppress_invalid_default = - is_invalid_typed_dict_literal(db, declared_ty, default_expr.into()); + is_invalid_typed_dict_literal(db, env, declared_ty, default_expr.into()); if !default_ty.is_assignable_to(db, env, declared_ty) && !suppress_invalid_default && !((self.in_stub() @@ -2307,20 +2310,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = parameter_with_default; let env = &self.program_environment(); - let default_expr = default.as_ref(); + let _default_expr = default.as_ref(); let ty = if let Some(parameter_type) = self.annotated_lambda_parameter_type(index, lambda) { parameter_type - } else if let Some(default_expr) = default_expr { + } else if let Some(default_expr) = default { let default_ty = self.file_expression_type(default_expr); if self.settings().sound_types { // basedpython: same rule as an unannotated function parameter with a default — // the parameter takes the default's promoted type instead of folding in `Unknown` default_ty.promote(self.db(), env) } else { - UnionType::from_two_elements(self.db(), env, Type::unknown(), default_ty) + UnionType::from_two_elements( + self.db(), + env, + Type::Dynamic(DynamicType::UnknownLambdaParameter), + default_ty, + ) } } else { - Type::unknown() + Type::Dynamic(DynamicType::UnknownLambdaParameter) }; // basedpython typed lambdas (`lambda (a: int) -> int: ...`) make the @@ -2357,7 +2365,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let ty = if let Some(parameter_type) = self.annotated_lambda_parameter_type(index, lambda) { parameter_type } else { - Type::homogeneous_tuple(db, self.program_environment(), Type::unknown()) + Type::homogeneous_tuple( + db, + self.program_environment(), + Type::Dynamic(DynamicType::UnknownLambdaParameter), + ) }; // see `infer_lambda_parameter_definition` — annotated `*args` is a // `DeclarationAndBinding`, which doesn't populate @@ -2386,7 +2398,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let inferred_ty = KnownClass::Dict.to_specialized_instance( db, env, - &[KnownClass::Str.to_instance(db, env), Type::unknown()], + &[ + KnownClass::Str.to_instance(db, env), + Type::Dynamic(DynamicType::UnknownLambdaParameter), + ], ); if parameter.annotation.is_some() { @@ -2420,12 +2435,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let parameter_type = signature.parameters().as_slice()[index as usize].annotated_type(); - if parameter_type.is_unknown() - || parameter_type.has_unspecialized_type_var(db, self.program_environment()) - { - None - } else { - Some(parameter_type) - } + (!parameter_type.has_provisional_marker(db, self.program_environment())) + .then_some(parameter_type) } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/imports.rs b/crates/ty_python_semantic/src/types/infer/builder/imports.rs index 4b39039f55..df38d09ef5 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/imports.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/imports.rs @@ -8,6 +8,7 @@ use ty_module_resolver::{ use crate::{ TypeQualifiers, add_inferred_python_version_hint_to_diagnostic, dependencies::{self, GroupName, ImportStanding}, + dependency::{DependencyProjectKind, missing_direct_dependency}, place::{ DefinedPlace, Definedness, Place, PlaceAndQualifiers, TypeOrigin, basedpython_typing_added_in, basedpython_warnings_added_in, explicit_global_symbol, @@ -17,9 +18,10 @@ use crate::{ ModuleLiteralType, Type, TypeAndQualifiers, dedicated::EXTERNALLY_STUBBED_FRAMEWORKS, diagnostic::{ - INVALID_STATIC_RESOURCE, MISPLACED_DEPENDENCY, MISSING_FRAMEWORK_STUBS, - POSSIBLY_MISSING_IMPORT, PRIVATE_IMPORT, UNDECLARED_DEPENDENCY, UNRESOLVED_IMPORT, - UNUSABLE_RESOURCE_KEY, hint_if_stdlib_attribute_exists_on_other_versions, + INVALID_STATIC_RESOURCE, MISPLACED_DEPENDENCY, MISSING_DIRECT_DEPENDENCY, + MISSING_FRAMEWORK_STUBS, POSSIBLY_MISSING_IMPORT, PRIVATE_IMPORT, + UNDECLARED_DEPENDENCY, UNRESOLVED_IMPORT, UNUSABLE_RESOURCE_KEY, + hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, }, infer::{TypeInferenceBuilder, builder::DeclaredAndInferredType}, @@ -44,6 +46,56 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + fn check_direct_dependency(&self, module: Module<'db>, range: TextRange) { + if !self.context.is_lint_enabled(&MISSING_DIRECT_DEPENDENCY) + || self.in_stub() + || self.is_in_type_checking_block(self.scope(), range) + || self + .settings() + .replace_imports_with_any + .matches(module.name(self.db())) + .is_include() + { + return; + } + + let db = self.db(); + let Some(missing) = missing_direct_dependency(db, self.program_file(), module) else { + return; + }; + + let Some(builder) = self.context.report_lint(&MISSING_DIRECT_DEPENDENCY, range) else { + return; + }; + + let mut diagnostic = builder.into_diagnostic(format_args!( + "Import of `{}` requires a direct dependency on `{}`", + module.name(db), + missing.distribution_name, + )); + match missing.project_kind { + DependencyProjectKind::Project => diagnostic.help(format_args!( + "Declare `{}` in `project.dependencies` or `project.optional-dependencies` in your `pyproject.toml`", + missing.distribution_name, + )), + DependencyProjectKind::Script => diagnostic.help(format_args!( + "Declare `{}` in the script's inline `dependencies` metadata", + missing.distribution_name, + )), + } + if missing.group_dependency { + diagnostic.info("Dependency groups are only available to non-package files"); + } + diagnostic.info(match missing.project_kind { + DependencyProjectKind::Project => { + "See https://docs.astral.sh/uv/concepts/projects/dependencies/" + } + DependencyProjectKind::Script => { + "See https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies" + } + }); + } + fn report_unresolved_import( &self, range: TextRange, @@ -221,6 +273,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.check_framework_stubs(alias.range(), &full_module_name); self.check_dependency_declaration(alias.range(), &full_module_name); + if let Type::ModuleLiteral(module) = full_module_ty { + self.check_direct_dependency(module.module(self.db()), alias.range()); + } + let binding_ty = if asname.is_some() { // If we are renaming the imported module via an `as` clause, then we bind the resolved // module's type to that name, even if that module is nested. @@ -362,9 +418,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); - self.check_import_from_module_is_resolvable(import); + let module = self.check_import_from_module_is_resolvable(import); + let import_range = import.module.as_ref().map_or(import.range(), Ranged::range); for alias in names { + let mut checked_dependency = false; for definition in self.index.definitions(alias) { let inferred = infer_definition_types(self.db(), *definition); // Check non-star imports for deprecations @@ -372,16 +430,49 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // In the initial cycle, `declaration_types()` is empty, so no deprecation check is performed. for ty in inferred.declaration_types() { self.check_deprecated(alias, ty.inner); + + // `from namespace import child` can import a distribution other than the + // namespace's other children. Use inference's attribute-versus-submodule + // decision, and do not follow values re-exported from unrelated modules. + if self.context.is_lint_enabled(&MISSING_DIRECT_DEPENDENCY) + && let Some(parent) = module + { + let imported_module = if let Type::ModuleLiteral(literal) = ty.inner + && let child = literal.module(db) + && let child_name = child.name(db) + && child_name.parent().as_ref() == Some(parent.name(db)) + && child_name.components().next_back() == Some(alias.name.as_str()) + { + child + } else { + parent + }; + self.check_direct_dependency(imported_module, import_range); + checked_dependency = true; + } } } self.extend_definition(*definition, inferred); } + + // Star imports can have no definitions, and cycle recovery can omit declarations. + if !checked_dependency && let Some(parent) = module { + self.check_direct_dependency(parent, import_range); + } } } - /// Resolve the [`ModuleName`], and the type of the module, being referred to by an - /// [`ast::StmtImportFrom`] node. Emit a diagnostic if the module cannot be resolved. - fn check_import_from_module_is_resolvable(&mut self, import_from: &ast::StmtImportFrom) { + /// Resolve and return the module referred to by the `from` clause of an + /// [`ast::StmtImportFrom`] node. For `from package import child`, this returns + /// `package`, not `child`. Relative imports are resolved to an absolute module name. + /// + /// Return `None` if the module name is invalid or the module cannot be resolved. + /// Emit an unresolved-import diagnostic for resolution failures; syntax errors are + /// reported elsewhere. + fn check_import_from_module_is_resolvable( + &mut self, + import_from: &ast::StmtImportFrom, + ) -> Option> { let ast::StmtImportFrom { module, level, .. } = import_from; let db = self.db(); @@ -410,7 +501,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Err(ModuleNameResolutionError::InvalidSyntax) => { tracing::debug!("Failed to resolve import due to invalid syntax"); // Invalid syntax diagnostics are emitted elsewhere. - return; + return None; } Err(ModuleNameResolutionError::TooManyDots) => { tracing::debug!( @@ -418,7 +509,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { format_import_from_module(*level, module), ); self.report_unresolved_import(module_ref.range(), *level, module, None); - return; + return None; } Err(ModuleNameResolutionError::UnknownCurrentModule) => { tracing::debug!( @@ -428,16 +519,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.file().path(db) ); self.report_unresolved_import(module_ref.range(), *level, module, None); - return; + return None; } }; - if resolve_module(db, importing_file, &module_name).is_none() { + let resolved = resolve_module(db, importing_file, &module_name); + if resolved.is_none() { self.report_unresolved_import(module_ref.range(), *level, module, Some(&module_name)); } else { self.check_framework_stubs(module_ref.range(), &module_name); self.check_dependency_declaration(module_ref.range(), &module_name); } + + resolved } /// basedpython: resolve a version-gated `typing`/`warnings` member from @@ -708,7 +802,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // First try loading the requested attribute from the module. if !skip_self_referential_member_lookup { - let mut member = module_literal.static_member(db, env, name); + let result = module_literal.static_member(db, env, name); + let error = result.err(); + let mut member = result + .unwrap_or_else(|error| error.fallback_member(db)) + .member(db); // basedpython: version-gated `typing`/`warnings` members are always // available. When the member is missing at the target Python version, // fall back to `typing_extensions`, mirroring the transpiler's import @@ -743,7 +841,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } if qualifiers.contains(TypeQualifiers::FROM_MODULE_GETATTR) { - from_module_getattr = Some((ty, qualifiers, source_provenance)); + from_module_getattr = Some((ty, qualifiers, source_provenance, error)); } else { self.add_declaration_with_binding( alias.into(), @@ -801,7 +899,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // We've checked for a submodule, so now we can go ahead and use a type from module // `__getattr__`. - if let Some((ty, qualifiers, source_provenance)) = from_module_getattr { + if let Some((ty, qualifiers, source_provenance, error)) = from_module_getattr { + if let Some(error) = error { + error.report_module_getattr_import_diagnostic( + &self.context, + module_literal, + alias, + name, + ); + } self.add_declaration_with_binding( alias.into(), definition, diff --git a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs index febb1b79f6..099da7e8f3 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs @@ -381,18 +381,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } }, None => { - let call_node_index = call_expr.node_index.load(); let scope = self.scope(); - let scope_anchor = scope - .node(db) - .node_index() - .unwrap_or(ast::NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("scope anchor should not be NodeIndex::NONE"); - let call_u32 = call_node_index - .as_u32() - .expect("call node should not be NodeIndex::NONE"); let spec = match kind { NamedTupleKind::Collections => self.infer_collections_namedtuple_fields( rename_type, @@ -404,7 +393,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; DynamicNamedTupleAnchor::ScopeOffset { scope, - offset: call_u32 - anchor_u32, + offset: self.dynamic_class_scope_offset(call_expr), spec, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/new_class.rs b/crates/ty_python_semantic/src/types/infer/builder/new_class.rs index ab3ae0b03e..25b03eb133 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/new_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/new_class.rs @@ -13,7 +13,7 @@ use crate::types::infer::builder::{ }, }; use crate::types::{KnownClass, SubclassOfType, Type, TypeContext, definition_expression_type}; -use ruff_python_ast::{self as ast, HasNodeIndex, NodeIndex}; +use ruff_python_ast as ast; use ty_python_core::definition::Definition; impl<'db> TypeInferenceBuilder<'db, '_> { @@ -111,15 +111,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.deferred.insert(def); DynamicClassAnchor::Definition(def) } else { - let call_node_index = call_expr.node_index().load(); - let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("scope anchor should not be NodeIndex::NONE"); - let call_u32 = call_node_index - .as_u32() - .expect("call node should not be NodeIndex::NONE"); - // Use [Unknown] as fallback if bases extraction failed (e.g., not a tuple). let anchor_bases = explicit_bases .clone() @@ -127,7 +118,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { DynamicClassAnchor::ScopeOffset { scope, - offset: call_u32 - anchor_u32, + offset: self.dynamic_class_scope_offset(call_expr), explicit_bases: anchor_bases, } }; diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/decorator.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/decorator.rs new file mode 100644 index 0000000000..1536b81f1b --- /dev/null +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/decorator.rs @@ -0,0 +1,30 @@ +use ruff_python_ast as ast; +use ty_python_core::definition::Definition; + +use crate::types::call::{CallArguments, CallError}; +use crate::types::context::InferContext; +use crate::types::infer::infer_definition_types; + +pub(crate) fn check_decorator_calls<'db>( + context: &InferContext<'db, '_>, + definition: Definition<'db>, + decorators: &[ast::Decorator], +) { + if decorators.is_empty() { + return; + } + + let db = context.db(); + let env = context.program_environment(); + let inference = infer_definition_types(db, definition); + for decorator in decorators.iter().rev() { + let Some(input_ty) = inference.deferred_decorator_input_type(&decorator.expression) else { + continue; + }; + let decorator_ty = inference.expression_type(&decorator.expression); + let arguments = CallArguments::positional([input_ty]); + if let Err(CallError(_, bindings)) = decorator_ty.try_call(db, env, &arguments) { + bindings.report_diagnostics(context, decorator.into()); + } + } +} diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs index 8a88629cfe..e7a08e005e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs @@ -1,17 +1,19 @@ use crate::{ diagnostic::format_enumeration, types::{ - KnownInstanceType, Signature, Type, TypeVarKind, + KnownInstanceType, Signature, Type, TypeVarKind, TypeVarVariance, context::InferContext, diagnostic::{ - INVALID_LEGACY_POSITIONAL_PARAMETER, INVALID_TYPE_VARIABLE_DEFAULT, - UNBOUND_TYPE_VARIABLE, + INVALID_GENERIC_CLASS, INVALID_LEGACY_POSITIONAL_PARAMETER, + INVALID_TYPE_VARIABLE_DEFAULT, UNBOUND_TYPE_VARIABLE, }, function::{FunctionDecorators, OverloadLiteral}, generics::GenericContext, + infer::nearest_enclosing_class, infer_definition_types, signatures::ReturnCallableTypeVarScope, typevar::TypeVarInstance, + variance::VarianceInferable, visitor::find_over_type, }, }; @@ -22,7 +24,7 @@ use ruff_db::{ }; use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange}; -use ty_python_core::definition::Definition; +use ty_python_core::{definition::Definition, semantic_index}; pub(crate) fn check_function_definition<'db>( context: &InferContext<'db, '_>, @@ -47,6 +49,153 @@ pub(crate) fn check_function_definition<'db>( check_pep695_function_legacy_typevars(context, last_definition, file_expression_type); check_legacy_typevar_defaults(context, last_definition, &signature, file_expression_type); check_legacy_typevar_ordering(context, last_definition, &signature, file_expression_type); + // Variance depends on the complete overload set: a broader overload can cover an otherwise + // incompatible signature. + // TODO: Account for that coverage in shared variance inference before + // checking overloaded methods here. + if !function_type.has_known_decorator(db, FunctionDecorators::OVERLOAD) { + check_method_typevar_variance(context, last_definition, &signature); + } +} + +/// Check that a method respects the declared variance of its class's type parameters. +/// Constructors are excluded because their parameters establish the class specialization. +/// Recursively checks type variables nested in containers, unions, and callables as well as bare uses. +fn check_method_typevar_variance<'db>( + context: &InferContext<'db, '_>, + last_definition: OverloadLiteral<'db>, + signature: &Signature<'db>, +) { + let db = context.db(); + let body_scope = last_definition.body_scope(db); + if !context.is_lint_enabled(&INVALID_GENERIC_CLASS) + || !body_scope.is_method_scope(db) + || matches!(last_definition.name(db).as_str(), "__init__" | "__new__") + { + return; + } + + let index = semantic_index(db, body_scope.program_file(db)); + let Some(class) = nearest_enclosing_class(db, index, body_scope) else { + return; + }; + // Protocols require declared variance to match the inferred variance, including for explicitly + // invariant type variables. Nominal classes can be more conservative, so they only reject uses + // incompatible with a declared covariance or contravariance. Both checks share recursive + // variance inference, but only nominal classes currently skip overloads and independently + // generic methods to avoid false positives. + // TODO: Handle these cases in shared variance inference so both checks can account for them. + if class.is_protocol(db) { + return; + } + let Some(generic_context) = class.generic_context(db) else { + return; + }; + if !generic_context.variables(db).any(|typevar| { + matches!( + typevar.typevar(db).explicit_variance(db), + Some(TypeVarVariance::Covariant | TypeVarVariance::Contravariant) + ) + }) { + return; + } + + // Independent method type parameters can make an occurrence of a class parameter redundant. + // TODO: Account for those relationships instead of just composing each occurrence's variance. + // Use the lexical context so that type parameters moved into a returned callable also count. + let lexical_signature = last_definition.raw_signature(db, ReturnCallableTypeVarScope::Lexical); + if lexical_signature.generic_context.is_some_and(|context| { + context + .variables(db) + .any(|typevar| !typevar.typevar(db).is_self(db)) + }) { + return; + } + let env = context.program_environment(); + let signature = if last_definition.has_implicit_receiver(db) { + // The implicit receiver does not consume the class's type parameters. + // TODO: Account for specialized receivers that make an otherwise incompatible occurrence + // redundant, such as `self: C[int]` with a parameter annotated as `T_co | int`. + signature.bind_self(db, env, None) + } else { + signature.clone() + }; + + // basedpython: a private method is invisible to anything holding a widened reference, so it + // cannot be used to tell two specializations apart and constrains the class's variance not at + // all — the same reasoning that exempts a private attribute + if last_definition.has_known_decorator(db, FunctionDecorators::PRIVATE) { + return; + } + + // TODO: Validate the final class interface: decorators can replace a method, and later + // statements in the class body can delete or overwrite it. + for typevar in generic_context.variables(db) { + let Some(declared_variance) = typevar.typevar(db).explicit_variance(db) else { + continue; + }; + if declared_variance == TypeVarVariance::Invariant { + continue; + } + let required_variance = (&signature) + .variance_of(db, env, typevar.identity(db)) + .evaluate(db); + if declared_variance.join(required_variance) == declared_variance { + continue; + } + let node = last_definition.node(db, context.file(), context.module()); + let range = signature + .parameters() + .iter() + .find_map(|parameter| { + // `P.args` and `P.kwargs` both consume `P`, despite having distinct identities. + let parameter_type = match parameter.annotated_type() { + Type::TypeVar(typevar) if typevar.paramspec_attr(db).is_some() => { + Type::TypeVar(typevar.without_paramspec_attr(db)) + } + ty => ty, + }; + let variance = parameter_type + .with_polarity(TypeVarVariance::Contravariant) + .variance_of(db, env, typevar.identity(db)) + .evaluate(db); + if declared_variance.join(variance) == declared_variance { + return None; + } + node.parameters + .iter() + .nth(parameter.source_parameter_index()?)? + .annotation() + .map(Ranged::range) + }) + .or_else(|| { + node.returns + .as_deref() + .filter(|_| { + declared_variance.join( + signature + .return_ty + .variance_of(db, env, typevar.identity(db)) + .evaluate(db), + ) != declared_variance + }) + .map(Ranged::range) + }) + .unwrap_or_else(|| node.name.range()); + if let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, range) { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Variance of type variable `{}` is incompatible with method `{}`", + typevar.name(db), + node.name, + )); + diagnostic.info(format_args!( + "Type variable `{}` is declared as {}, but this method requires it to be {}", + typevar.name(db), + declared_variance.as_str(), + required_variance.as_str(), + )); + } + } } /// Check that a function using PEP 695 syntax does not also introduce legacy type variables. diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/mod.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/mod.rs index 7d3b8bd064..f6046cdc52 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/mod.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/mod.rs @@ -1,6 +1,7 @@ //! A home for deferred checks that must be done after the `TypeInferenceBuilder` has done an initial //! inference pass over the whole scope. +pub(super) mod decorator; pub(super) mod dynamic_class; pub(super) mod final_variable; pub(super) mod function; diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 6383249e59..0ccd78894b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -1,12 +1,12 @@ use crate::Db; -use itertools::Itertools; +use itertools::{Either, Itertools}; use ruff_db::{ diagnostic::{Annotation, SubDiagnostic, SubDiagnosticSeverity}, source::source_text, }; use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::{self as ast, PythonVersion, name::Name}; -use ruff_text_size::{Ranged, TextRange, TextSize}; +use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use crate::{ @@ -14,9 +14,10 @@ use crate::{ diagnostic::format_enumeration, place::{DefinedPlace, Place, TypeOrigin, place_from_bindings, place_from_declarations}, types::{ - CallArguments, ClassBase, ClassLiteral, ClassType, DataclassFlags, KnownClass, - KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, Parameters, Signature, - SpecialFormType, StaticClassLiteral, Type, TypeVarVariance, TypedDictModule, binding_type, + CallArguments, ClassBase, ClassLiteral, ClassType, DataclassFlags, DisplaySettings, + KnownClass, KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, Parameters, + Signature, SpecialFormType, StaticClassLiteral, Type, TypeVarVariance, TypingModule, + binding_type, call::Argument, class::{ AbstractMethod, CodeGeneratorKind, Field, FieldKind, MetaclassErrorKind, @@ -28,9 +29,9 @@ use crate::{ diagnostic::{ ABSTRACT_METHOD_IN_FINAL_CLASS, AbstractMethodAnnotationPolicy, CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DATACLASS_FIELD_ORDER, DUPLICATE_KW_ONLY, FINAL_WITHOUT_VALUE, - INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_BASE, INVALID_DATACLASS, - INVALID_GENERIC_CLASS, INVALID_GENERIC_ENUM, INVALID_METACLASS, INVALID_NAMED_TUPLE, - INVALID_PROTOCOL, INVALID_TYPED_DICT_HEADER, IncompatibleBases, + INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_BASE, + INVALID_DATACLASS, INVALID_GENERIC_CLASS, INVALID_GENERIC_ENUM, INVALID_METACLASS, + INVALID_NAMED_TUPLE, INVALID_PROTOCOL, INVALID_TYPED_DICT_HEADER, IncompatibleBases, SUBCLASS_OF_DATACLASS_WITH_ORDER, SUBCLASS_OF_FINAL_CLASS, SUBCLASS_OF_SEALED_CLASS, UNKNOWN_ARGUMENT, report_bad_frozen_dataclass_inheritance, report_conflicting_metaclass_from_bases, report_duplicate_bases, @@ -64,6 +65,73 @@ use ty_python_core::{ SemanticIndex, attribute_scopes, definition::DefinitionKind, scope::ScopeId, semantic_index, }; +/// Rejects slot layouts that fail while Python constructs the runtime class. +/// +/// ```python +/// class Example: +/// __slots__ = ("value",) +/// value = 1 # This class binding conflicts with the generated slot descriptor. +/// ``` +/// +/// Stub declarations do not execute and therefore cannot create runtime class-namespace conflicts. +fn check_class_slots<'db>( + context: &InferContext<'db, '_>, + class: StaticClassLiteral<'db>, + index: &SemanticIndex<'db>, +) { + let db = context.db(); + let has_explicit_slots = class.has_explicit_slots(db); + + if has_explicit_slots + && class + .dataclass_params(db) + .is_some_and(|parameters| parameters.flags(db).contains(DataclassFlags::SLOTS)) + { + if let Some(builder) = context.report_lint(&INVALID_DATACLASS, class.header_range(db)) { + builder.into_diagnostic(format_args!( + "Dataclass `{}` cannot combine `slots=True` with manually assigned `__slots__`", + class.name(db), + )); + } + return; + } + + if !has_explicit_slots || context.in_stub() { + return; + } + + let Some(slot_names) = class.slot_names(db) else { + return; + }; + + let scope_id = class.body_scope(db).file_scope_id(db); + let table = index.place_table(scope_id); + let use_def = index.use_def_map(scope_id); + + for name in slot_names { + let Some(symbol) = table.symbol_id(name) else { + continue; + }; + + for binding in use_def.end_of_scope_symbol_bindings(symbol) { + if let Some(definition) = binding.binding.definition() + && !index.is_in_type_checking_block( + scope_id, + definition.kind(db).full_range(context.module()), + ) + && let Some(builder) = context.report_lint( + &INVALID_ASSIGNMENT, + definition.focus_range(db, context.module()), + ) + { + builder.into_diagnostic(format_args!( + "Class variable `{name}` conflicts with an instance slot" + )); + } + } + } +} + /// Iterate over all static class definitions (created using `class` statements) to check that /// the definition is semantically valid and will not cause an exception to be raised at runtime. /// This needs to be done after most other types in the scope have been inferred, due to the fact @@ -123,6 +191,8 @@ pub(crate) fn check_static_class_definitions<'db>( let env = context.program_environment(); + check_class_slots(context, class, index); + // Check that the class is not an enum and generic if is_enum_class_by_inheritance(db, env, class) && class.generic_context(db).is_some() { if let Some(builder) = context.report_lint(&INVALID_GENERIC_ENUM, class_node) { @@ -336,11 +406,11 @@ pub(crate) fn check_static_class_definitions<'db>( ); if let ast::Expr::Subscript(node) = node { let source = source_text(db, context.file()); - let type_params_range = TextRange::new( - type_params.start().saturating_add(TextSize::new(1)), - type_params.end().saturating_sub(TextSize::new(1)), - ); - if source[node.slice.range()] == source[type_params_range] { + // The parser can recover a type parameter list without a closing bracket. + if let Some(type_params) = source[type_params.range()].strip_prefix('[') + && let Some(type_params) = type_params.strip_suffix(']') + && type_params == &source[node.slice.range()] + { diagnostic.help("Remove the type parameters from the `Protocol` base"); diagnostic.set_fix(Fix::unsafe_edit(Edit::range_deletion( TextRange::new(node.value.end(), node.end()), @@ -362,8 +432,9 @@ pub(crate) fn check_static_class_definitions<'db>( if declared_variance == TypeVarVariance::Invariant { return None; } - let required_variance = - base_alias.variance_of(db, env, typevar.identity(db)); + let required_variance = base_alias + .variance_of(db, env, typevar.identity(db)) + .evaluate(db); if declared_variance.join(required_variance) != declared_variance { Some((typevar, declared_variance, required_variance)) } else { @@ -664,41 +735,55 @@ pub(crate) fn check_static_class_definitions<'db>( } } MetaclassErrorKind::Conflict { - candidate1: + candidate: MetaclassCandidate { metaclass: metaclass1, - explicit_metaclass_of: class1, - }, - candidate2: - MetaclassCandidate { - metaclass: metaclass2, - explicit_metaclass_of: class2, + base: base1, }, - candidate1_is_base_class, + base_metaclass: metaclass2, + base: base2, } => { - if *candidate1_is_base_class { + if let Some(base1) = base1 { report_conflicting_metaclass_from_bases( context, class_node.into(), class.name(db), *metaclass1, - class1.name(db), + base1.name(db), *metaclass2, - class2.name(db), + base2.name(db), ); } else if let Some(builder) = context.report_lint(&CONFLICTING_METACLASS, class_node) { + let types = [ + Type::from(class), + Type::from(*metaclass1), + Type::from(*metaclass2), + Type::from(*base2), + ]; + let settings = DisplaySettings::from_possibly_ambiguous_types(db, env, types); + let base = if let ClassBase::Class(base) = base2 { + Either::Left( + base.class_literal(db) + .display_with(db, env, settings.clone()), + ) + } else { + Either::Right(base2.display_with(db, env, settings.clone())) + }; builder.into_diagnostic(format_args!( "The metaclass of a derived class (`{class}`) \ must be a subclass of the metaclasses of all its bases, \ but `{metaclass_of_class}` (metaclass of `{class}`) \ and `{metaclass_of_base}` (metaclass of base class `{base}`) \ have no subclass relationship", - class = class.name(db), - metaclass_of_class = metaclass1.name(db), - metaclass_of_base = metaclass2.name(db), - base = class2.name(db), + class = ClassLiteral::Static(class).display_with(db, env, settings.clone()), + metaclass_of_class = + metaclass1 + .class_literal(db) + .display_with(db, env, settings.clone()), + metaclass_of_base = + metaclass2.class_literal(db).display_with(db, env, settings), )); } } @@ -710,7 +795,7 @@ pub(crate) fn check_static_class_definitions<'db>( if let Some(args) = class_node.arguments.as_deref() { if class_kind == Some(CodeGeneratorKind::TypedDict) { let supports_pep_728 = context.in_stub() - || class.typed_dict_module(db) == Some(TypedDictModule::TypingExtensions) + || class.typed_dict_module(db) == Some(TypingModule::TypingExtensions) || env.python_version(db) >= PythonVersion::PY315; for keyword in &args.keywords { @@ -802,7 +887,8 @@ pub(crate) fn check_static_class_definitions<'db>( .ignore_possibly_undefined(); if let Some(init_subclass) = init_subclass_type { - let call_args = call_args.with_self(Some(Type::from(class))); + let call_args = + call_args.with_self(Some(Type::from(class.identity_specialization(db)))); if let Err(call_error) = init_subclass.try_call(db, env, &call_args) { report_subclass_of_class_with_non_callable_init_subclass( context, call_error, class, class_node, @@ -1115,6 +1201,7 @@ pub(crate) fn check_static_class_definitions<'db>( if let Some(protocol) = class.into_protocol_class(db) { protocol.validate_members(context); + protocol.validate_type_parameter_variance(context); } if class.is_typed_dict(db) { @@ -1134,7 +1221,9 @@ pub(crate) fn check_static_class_definitions<'db>( /// type and so pins it to invariance. /// /// An incompatible *base class* is reported against that base, so this only looks at the -/// variance the class's own members require ([`StaticClassLiteral::own_variance_of`]). +/// variance the class's own non-method members require +/// ([`StaticClassLiteral::own_non_method_variance_of`]) — a method that contradicts its class's +/// declared variance is reported against the method itself. fn check_declared_variance_usage<'db>( context: &InferContext<'db, '_>, class: StaticClassLiteral<'db>, @@ -1146,6 +1235,21 @@ fn check_declared_variance_usage<'db>( return; }; + // A protocol's variance is judged against its structural interface, and a mismatch there is + // already reported as `invalid-protocol`. The member walk below reads the class body instead, + // where a decorated or property member looks like a mutable attribute and so demands + // invariance that the protocol itself never requires. + if class.is_protocol(db) { + return; + } + + // A `TypedDict`'s variance follows from its items, and the typing spec does not ask for + // this check on one — no other checker reports it either. Reading the class body instead + // would call every item a mutable attribute and demand invariance. + if class.is_typed_dict(db) { + return; + } + for bound_typevar in generic_context.variables(db) { let typevar = bound_typevar.typevar(db); // An invariant declaration accepts every usage, and an inferred variance is by @@ -1156,7 +1260,7 @@ fn check_declared_variance_usage<'db>( continue; }; - let required_variance = class.own_variance_of(db, bound_typevar.identity(db)); + let required_variance = class.own_non_method_variance_of(db, bound_typevar.identity(db)); if declared_variance.join(required_variance) == declared_variance { continue; } @@ -1263,7 +1367,7 @@ fn check_class_namespace_against_metaclass_members<'db>( ) { let db = context.db(); let env = context.program_environment(); - let metaclass = class.metaclass(db); + let metaclass = class.inferred_metaclass(db).for_inheritance(db, env); if metaclass == KnownClass::Type.to_class_literal(db, env) { return; } diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs index e11416ef88..d08dbcf325 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs @@ -96,7 +96,9 @@ pub(crate) fn check_declared_alias_variance<'db>( continue; }; - let required = alias.variance_of(db, env, bound_typevar.identity(db)); + let required = alias + .variance_of(db, env, bound_typevar.identity(db)) + .evaluate(db); if declared.join(required) == declared { continue; } diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 9edae2edef..8b4f3867d1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -34,14 +34,14 @@ use crate::types::tuple::{Tuple, TupleSpecBuilder, TupleType, VariableSegment}; use crate::types::typed_dict::{ TypedDictAssignmentKind, TypedDictExtraItems, TypedDictKeyAssignment, }; -use crate::types::typevar::TypeVarSet; use crate::types::typevar::pack_bound_violation; +use crate::types::typevar::{BindingContext, TypeVarSet}; use crate::types::{ BoundTypeVarInstance, CallArguments, CallDunderError, CallableBinding, CycleDetector, - DynamicType, InternedType, KnownClass, KnownInstanceType, LintDiagnosticGuard, + DisplaySettings, DynamicType, InternedType, KnownClass, KnownInstanceType, LintDiagnosticGuard, MemberLookupPolicy, Parameter, Parameters, SpecialFormType, StaticClassLiteral, Type, - TypeAliasType, TypeAndQualifiers, TypeContext, TypeVarBoundOrConstraints, UnionType, - UnionTypeInstance, any_over_type, todo_type, + TypeAliasType, TypeAndQualifiers, TypeContext, TypeMapping, TypeVarBoundOrConstraints, + UnionType, UnionTypeInstance, any_over_type, todo_type, }; use crate::{Db, FxOrderSet, ProgramEnvironment}; use ty_python_core::definition::Definition; @@ -169,9 +169,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); let constraints = ConstraintSetBuilder::new(); + // TODO consider just accepting the given specialization without checking + // against bounds/constraints, but recording the expression for deferred + // checking at end of scope. This would avoid a lot of cycles caused by eagerly + // doing assignment checks here. + let lower_bound = typevar.typevar(db).lower_bound(db); + let bound_or_constraints = typevar.typevar(db).bound_or_constraints(db, env); + let provided_type = if lower_bound.is_some() || bound_or_constraints.is_some() { + // Defaults such as `Box[T]` may be inferred before `T` has a binding context. + // Bind only the copy used for validation, so the original default can later + // be bound to each generic that uses it. + provided_type.apply_type_mapping( + db, + env, + &TypeMapping::BindLegacyTypevars(BindingContext::Synthetic(env.program(db))), + TypeContext::default(), + ) + } else { + provided_type + }; + // basedpython: a bound range `T: Lower..Upper` also puts a floor under the argument, // so check that end before the upper end below - if let Some(lower_bound) = typevar.typevar(db).lower_bound(db) + if let Some(lower_bound) = lower_bound && lower_bound .when_assignable_to(db, env, provided_type, &constraints, TypeVarSet::None) .is_never_satisfied(db, env) @@ -191,11 +211,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return Some(ExplicitSpecializationError::UnsatisfiedBound); } - // TODO consider just accepting the given specialization without checking - // against bounds/constraints, but recording the expression for deferred - // checking at end of scope. This would avoid a lot of cycles caused by eagerly - // doing assignment checks here. - match typevar.typevar(db).bound_or_constraints(db, env) { + match bound_or_constraints { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { if provided_type .when_assignable_to(db, env, bound, &constraints, TypeVarSet::None) @@ -520,10 +536,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - let tuple_generic_alias = |env: &ProgramEnvironment<'db>, tuple: Option>| { - let tuple = tuple.unwrap_or_else(|| TupleType::homogeneous(db, env, Type::unknown())); - Type::from(tuple.to_class_type(db)) - }; + let tuple_generic_alias = |tuple: TupleType<'db>| Type::from(tuple.to_class_type(db)); match value_ty { Type::ClassLiteral(class) => { @@ -551,7 +564,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // special cases, too. if class.is_tuple(db) { return Ok(tuple_generic_alias( - env, self.infer_tuple_type_expression(subscript), )); } else if class.is_known(db, KnownClass::Type) { @@ -619,7 +631,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::SpecialForm(special_form) => match special_form { SpecialFormType::Tuple => { return Ok(tuple_generic_alias( - env, self.infer_tuple_type_expression(subscript), )); } @@ -949,7 +960,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { specialized } - pub(super) fn infer_explicit_function_specialization( + fn infer_explicit_function_specialization( &mut self, subscript: &ast::ExprSubscript, value_ty: Type<'db>, @@ -2353,7 +2364,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .iter_flat() .flat_map(CallableBinding::matching_overloads) .filter_map(|(_, identity_overload)| { - identity_overload.specialization(db, env) + identity_overload.merged_specialization(db, env) }) { // Record the constraints on the receiver's generic context formed by @@ -2695,14 +2706,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target.range.cover(rhs_value_node.range()), ) { - let assigned_d = rhs_value_ty.display(db, env); - let object_d = object_ty.display(db, env); + let settings = + DisplaySettings::from_possibly_ambiguous_types( + db, + env, + [rhs_value_ty, object_ty, slice_ty], + ); + let assigned_d = + rhs_value_ty.display_with(db, env, settings.clone()); + let object_d = + object_ty.display_with(db, env, settings.clone()); let mut diagnostic = builder.into_diagnostic(format_args!( "Invalid subscript assignment with key of type `{}` \ and value of type `{assigned_d}` \ on object of type `{object_d}`", - slice_ty.display(db, env), + slice_ty.display_with(db, env, settings), )); // Special diagnostic for dictionaries diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_call.rs b/crates/ty_python_semantic/src/types/infer/builder/type_call.rs index 41c4fb9030..b0b70027fe 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_call.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_call.rs @@ -12,7 +12,7 @@ use crate::types::infer::builder::{ }, }; use crate::types::{KnownClass, SubclassOfType, Type, TypeContext, definition_expression_type}; -use ruff_python_ast::{self as ast, HasNodeIndex, NodeIndex}; +use ruff_python_ast as ast; use ty_python_core::definition::Definition; impl<'db> TypeInferenceBuilder<'db, '_> { @@ -231,22 +231,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // Create the anchor for identifying this dynamic class. // - For assigned `type()` calls, the Definition uniquely identifies the class, // and bases inference is deferred. - // - For dangling calls, compute a relative offset from the scope's node index, + // - For dangling calls, locate the call relative to the enclosing scope, // and store the explicit bases directly (since they were inferred eagerly). let anchor = if let Some(def) = definition { // Register for deferred inference to infer bases and validate later. self.deferred.insert(def); DynamicClassAnchor::Definition(def) } else { - let call_node_index = call_expr.node_index().load(); - let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("scope anchor should not be NodeIndex::NONE"); - let call_u32 = call_node_index - .as_u32() - .expect("call node should not be NodeIndex::NONE"); - // Use [Unknown] as fallback if bases extraction failed (e.g., not a tuple). let anchor_bases = explicit_bases .clone() @@ -254,7 +245,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { DynamicClassAnchor::ScopeOffset { scope, - offset: call_u32 - anchor_u32, + offset: self.dynamic_class_scope_offset(call_expr), explicit_bases: anchor_bases, } }; diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 13e82810e5..7b03151fe6 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1,11 +1,15 @@ use compact_str::CompactString; use itertools::Either; +use ruff_db::source::source_text; +use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::helpers::{ UseSiteVariance, is_dotted_name, is_top_star_marker, top_star_marker_ranges_in_slice, top_star_slice_elements, type_modifier_marker, use_site_variance_marker, }; use ruff_python_ast::name::Name; +use ruff_python_ast::token::parenthesized_range; use ruff_python_ast::{self as ast, ParameterBorrow, PythonVersion}; +use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; use super::{DeferredExpressionState, TypeInferenceBuilder}; @@ -19,6 +23,7 @@ use crate::types::diagnostic::{ report_unsupported_binary_operation, }; use crate::types::function::{FunctionDecorators, FunctionType}; +use crate::types::infer::builder::binary_expressions::BinaryInferenceState; use crate::types::infer::builder::subscript::AnnotatedExprContext; use crate::types::infer::{InferenceFlags, TypeExpressionFlags}; use crate::types::signatures::{ConcatenateTail, Signature}; @@ -30,18 +35,20 @@ use crate::types::type_fn::{ TypeFnArguments, TypeFnOutcome, arity_mismatch, declared_return_type, evaluate_type_fn, first_bound_violation, }; +use ty_python_core::definition::DefinitionKind; use ty_python_core::scope::ScopeKind; use crate::types::ProgramEnvironment; use crate::types::{ BindingContext, BoundTypeVarInstance, CallableType, DeferredOperation, DeferredType, - DynamicType, GenericContext, InternedType, IntersectionBuilder, IntersectionType, KnownClass, - KnownInstanceType, LintDiagnosticGuard, LiteralValueTypeKind, OverlappingType, - ParamSpecAttrKind, Parameter, Parameters, RestrictedType, SpecialFormType, SubclassOfType, - Type, TypeContext, TypeFormType, TypeGuardType, TypeIsType, TypeMapping, TypeVarKind, - UnionBuilder, UnionType, UnsafeUnionType, any_over_type, todo_type, + DynamicType, GenericContext, InternedType, IntersectionBuilder, IntersectionType, + InvalidTypeExpression, KnownClass, KnownInstanceType, LintDiagnosticGuard, + LiteralValueTypeKind, OverlappingType, ParamSpecAttrKind, Parameter, Parameters, + RestrictedType, SpecialFormType, SubclassOfType, Type, TypeContext, TypeFormType, + TypeGuardType, TypeIsType, TypeMapping, TypeVarKind, UnionBuilder, UnionType, UnsafeUnionType, + any_over_type, todo_type, }; -use crate::{FxOrderSet, add_inferred_python_version_hint_to_diagnostic}; +use crate::{FxOrderSet, SemanticModel, add_inferred_python_version_hint_to_diagnostic}; /// Type expressions impl<'db> TypeInferenceBuilder<'db, '_> { @@ -79,16 +86,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .inference_flags .replace(InferenceFlags::IN_TYPE_EXPRESSION, true); - // `DeferredExpressionState::InStringAnnotation` takes precedence over other states. - // However, if it's not a stringified annotation, we must still ensure that annotation expressions - // are always deferred in stub files. - match previous_deferred_state { - DeferredExpressionState::None => { - if self.in_stub() { - self.deferred_state = DeferredExpressionState::Deferred; - } - } - DeferredExpressionState::InStringAnnotation(_) | DeferredExpressionState::Deferred => {} + // Annotation expressions are always deferred in stub files. + if self.in_stub() { + self.replace_deferred_state(DeferredExpressionState::Deferred); } let ty = self.infer_type_expression_no_store(expression); @@ -112,7 +112,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { expression: &ast::Expr, deferred_state: DeferredExpressionState, ) -> Type<'db> { - let previous_deferred_state = std::mem::replace(&mut self.deferred_state, deferred_state); + let previous_deferred_state = self.replace_deferred_state(deferred_state); let annotation_ty = self.infer_type_expression(expression); self.deferred_state = previous_deferred_state; annotation_ty @@ -180,7 +180,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } pub(super) fn infer_name_or_attribute_type_expression( - &self, + &mut self, ty: Type<'db>, annotation: &ast::Expr, ) -> Type<'db> { @@ -218,9 +218,17 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.inference_flags(), ) .unwrap_or_else(|error| { + if error.invalid_expressions.iter().any(|invalid| { + matches!(invalid, InvalidTypeExpression::InvalidBareTypeVarTuple(_)) + }) { + self.store_type_expression_flags( + annotation, + TypeExpressionFlags::INVALID_BARE_TYPE_VAR_TUPLE, + ); + } error.into_fallback_type(&self.context, annotation, self.inference_flags()) }); - self.check_for_unbound_type_variable(annotation, result_ty) + self.check_type_variable_scope(annotation, result_ty) } /// basedpython: a `ParamSpec`'s two halves are unpacked with stars — `*args: *P` and @@ -490,6 +498,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // doesn't resolve, so dispatch on the flag directly let value_ty = if *is_typeof { Type::SpecialForm(crate::types::SpecialFormType::TypeOf) + } else if !is_dotted_name(value) && self.in_string_annotation() { + // a string annotation subscripting anything but a name is already an error, + // and inferring what it subscripts would report every name written inside it + // as unresolved on top of that + Type::unknown() } else { self.infer_expression(value, TypeContext::default()) }; @@ -535,6 +548,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ty } else { if !self.in_string_annotation() { + // `value` is already inferred above — the basedpython arms need its type + // before this point, so unlike upstream it is not computed inside the + // dotted-name branch. inferring it again would store a second type for + // every expression inside it self.infer_expression(slice, TypeContext::default()); } self.report_invalid_type_expression( @@ -587,14 +604,26 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // we also check for the case where one of the operands is a class-literal type // or generic-alias type and the other is a string literal. The normal dunder lookup // fails to catch this error, since typeshed annotates `type.__(r)or__` as accepting `Any`. + // ABCMeta and _ProtocolMeta inherit these operators unchanged. The typeshed + // protocol fallback does not establish a custom operator either. let should_emit_error = if dunder_fails { true } else { let literal = match (left_type_value, right_type_value) { (Type::ClassLiteral(class), Type::LiteralValue(literal)) | (Type::LiteralValue(literal), Type::ClassLiteral(class)) - if class.metaclass(db) - == KnownClass::Type.to_class_literal(db, env) => + if matches!( + class + .inferred_metaclass(db) + .for_inheritance(db, env) + .to_class_type(db) + .and_then(|metaclass| metaclass.known(db)), + Some( + KnownClass::Type + | KnownClass::ABCMeta + | KnownClass::ProtocolMeta + ) + ) => { Some(literal) } @@ -759,13 +788,14 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Box::new(operands), ); } + let mut state = BinaryInferenceState::default(); if let Some(result) = self.infer_binary_expression_type( binary.into(), - false, left_ty, right_ty, op, TypeContext::default(), + &mut state, ) { return result; } @@ -937,6 +967,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { hinted_type.display(db, env), )); } + + if !self.in_string_annotation() + && env.python_version(db) >= PythonVersion::PY39 + && !single_element.is_starred_expr() + && !source_text(db, self.file()).contains_line_break(list.range()) + && SemanticModel::new(db, self.program_file()) + .definitely_has_builtin_binding("list", expression.into()) + { + diagnostic.help("Replace with `list[...]`"); + diagnostic.set_fix(Fix::unsafe_edit(Edit::insertion( + "list".to_string(), + expression.start(), + ))); + } } Type::unknown() } @@ -1033,6 +1077,47 @@ impl<'db> TypeInferenceBuilder<'db, '_> { hinted_type.display(db, env), )); } + + if !self.in_string_annotation() + && !source_text(db, self.file()).contains_line_break(tuple.range()) + && env.python_version(db) >= PythonVersion::PY39 + && !tuple.elts.iter().any(ast::Expr::is_starred_expr) + && SemanticModel::new(db, self.program_file()) + .definitely_has_builtin_binding("tuple", tuple.into()) + { + diagnostic.help("Replace with `tuple[...]`"); + if let (Some(first_elt), Some(last_elt)) = + (tuple.elts.first(), tuple.elts.last()) + { + let first_range = parenthesized_range( + first_elt.into(), + tuple.into(), + self.module().tokens(), + ) + .unwrap_or(first_elt.range()); + let last_range = parenthesized_range( + last_elt.into(), + tuple.into(), + self.module().tokens(), + ) + .unwrap_or(last_elt.range()); + diagnostic.set_fix(Fix::unsafe_edits( + Edit::range_replacement( + "tuple[".to_string(), + TextRange::new(tuple.start(), first_range.start()), + ), + [Edit::range_replacement( + "]".to_string(), + TextRange::new(last_range.end(), tuple.end()), + )], + )); + } else { + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( + "tuple[()]".to_string(), + tuple.range(), + ))); + } + } } } else { for element in tuple { @@ -1323,6 +1408,36 @@ impl<'db> TypeInferenceBuilder<'db, '_> { hinted_type.display(db, env), )); } + if !self.in_string_annotation() + && env.python_version(db) >= PythonVersion::PY39 + && !source_text(db, self.file()).contains_line_break(dict.range()) + && SemanticModel::new(db, self.program_file()) + .definitely_has_builtin_binding("dict", dict.into()) + { + let key_range = + parenthesized_range(key.into(), dict.into(), self.module().tokens()) + .unwrap_or(key.range()); + let value_range = + parenthesized_range(value.into(), dict.into(), self.module().tokens()) + .unwrap_or(value.range()); + diagnostic.help("Replace with `dict[...]`"); + diagnostic.set_fix(Fix::unsafe_edits( + Edit::range_replacement( + "dict[".to_string(), + TextRange::new(dict.start(), key_range.start()), + ), + [ + Edit::range_replacement( + ", ".to_string(), + TextRange::new(key_range.end(), value_range.start()), + ), + Edit::range_replacement( + "]".to_string(), + TextRange::new(value_range.end(), dict.end()), + ), + ], + )); + } } Type::unknown() } @@ -1351,6 +1466,32 @@ impl<'db> TypeInferenceBuilder<'db, '_> { hinted_type.display(db, env), )); } + + if !self.in_string_annotation() + && env.python_version(db) >= PythonVersion::PY39 + && !single_element.is_starred_expr() + && !source_text(db, self.file()).contains_line_break(set.range()) + && SemanticModel::new(db, self.program_file()) + .definitely_has_builtin_binding("set", set.into()) + { + let element_range = parenthesized_range( + single_element.into(), + set.into(), + self.module().tokens(), + ) + .unwrap_or(single_element.range()); + diagnostic.help("Replace with `set[...]`"); + diagnostic.set_fix(Fix::unsafe_edits( + Edit::range_replacement( + "set[".to_string(), + TextRange::new(set.start(), element_range.start()), + ), + [Edit::range_replacement( + "]".to_string(), + TextRange::new(element_range.end(), set.end()), + )], + )); + } } Type::unknown() } @@ -2169,7 +2310,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .context .inference_flags .replace(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, true); - let starred_type = self.infer_type_expression(value); + let starred_type = self.infer_type_expression(value).resolve_type_alias(db); self.context.inference_flags.set( InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, previously_in_unpack_type_argument, @@ -2588,6 +2729,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } else { // TODO: emit a diagnostic } + } else if self + .type_expression_flags(element) + .contains(TypeExpressionFlags::INVALID_BARE_TYPE_VAR_TUPLE) + { + // Do not count recovery as another explicit unpack. + element_types = + element_types.concat(self.db(), env, &TupleSpec::homogeneous(Type::unknown())); } else { element_types.push(element_ty); } @@ -2600,10 +2748,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// /// This method assumes that a type has already been inferred and stored for the `value` /// of the subscript passed in. + /// + /// Recovers a bare `TypeVarTuple` as `*tuple[Unknown, ...]`, preserving surrounding elements. + /// An enclosing `tuple[tuple[Ts]]` still has exactly one element. pub(super) fn infer_tuple_type_expression( &mut self, tuple: &ast::ExprSubscript, - ) -> Option> { + ) -> TupleType<'db> { let db = self.db(); let env = self.program_environment(); match &*tuple.slice { @@ -2631,8 +2782,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ); } let result = TupleType::homogeneous(db, env, element_ty); - self.store_expression_type(&tuple.slice, Type::tuple(Some(result))); - return Some(result); + self.store_expression_type(&tuple.slice, Type::tuple(result)); + return result; } let element_types = self.infer_fixed_tuple_elements( @@ -2661,7 +2812,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ); } self.store_expression_type(single_element, Type::unknown()); - return TupleType::heterogeneous(db, env, std::iter::once(Type::unknown())); + return TupleType::heterogeneous(db, env, [Type::unknown()]); } let previously_in_valid_unpack_context = self .context @@ -2672,6 +2823,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { InferenceFlags::IN_VALID_UNPACK_CONTEXT, previously_in_valid_unpack_context, ); + if self + .type_expression_flags(single_element) + .contains(TypeExpressionFlags::INVALID_BARE_TYPE_VAR_TUPLE) + { + return TupleType::homogeneous(db, env, Type::unknown()); + } let single_element_is_unpack = matches!(single_element, ast::Expr::Starred(_)) || matches!( single_element, @@ -2687,16 +2844,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } else if let Type::TypeVar(typevar) = single_element_ty && typevar.is_typevartuple(self.db()) { - return TupleType::new( - db, - env, - &TupleSpecBuilder::with_capacity(0) - .concat_variadic_typevar(db, env, typevar) - .build(), - ); + return TupleType::unpacked_typevartuple(db, env, typevar); } } - TupleType::heterogeneous(db, env, std::iter::once(single_element_ty)) + TupleType::heterogeneous(db, env, [single_element_ty]) } } } @@ -2781,8 +2932,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if class_literal.is_tuple(self.db()) { let class_type = self .infer_tuple_type_expression(subscript) - .map(|tuple_type| tuple_type.to_class_type(self.db())) - .unwrap_or_else(|| class_literal.default_specialization(db)); + .to_class_type(self.db()); SubclassOfType::from(db, env, class_type) } else { match class_literal.generic_context(db) { @@ -2811,15 +2961,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ) } None => { - self.infer_expression(parameters, TypeContext::default()); - if let Some(builder) = - self.context.report_lint(&NOT_SUBSCRIPTABLE, subscript) - { - builder.into_diagnostic(format_args!( - "Cannot subscript non-generic type `{}`", - value_ty.display(db, self.program_environment()) - )); + if !self.in_string_annotation() { + self.infer_expression(parameters, TypeContext::default()); } + self.report_invalid_type_expression( + subscript, + format_args!( + "Non-generic class `{}` cannot be specialized in a type expression", + class_literal.name(db) + ), + ); Type::unknown() } } @@ -2845,7 +2996,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { subclass_of_type_argument(self, slice, slice_ty) } _ => { - self.infer_expression(parameters, TypeContext::default()); + self.infer_type_expression(parameters); todo_type!("unsupported nested subscript in type[X]") } }; @@ -2853,7 +3004,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { parameters_ty } _ => { - self.infer_expression(slice, TypeContext::default()); + self.infer_type_expression(slice); todo_type!("unsupported type[X] special form") } } @@ -2896,7 +3047,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // instead of two. So until we properly support these, specialize all remaining type // variables with a `@Todo` type (since we don't know which of the type arguments // belongs to the remaining type variables). - if any_over_type(db, env, value_ty, true, |ty| ty.is_divergent()) { + // + // A lazily inferred class member can contain its own unrelated recursive type, so only + // inspect the alias structure and generic arguments when checking whether it is recursive. + if any_over_type(db, env, value_ty, false, |ty| ty.is_divergent()) { let value_ty = value_ty.apply_specialization( db, generic_context.specialize( @@ -3302,15 +3456,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .unwrap_or(Type::unknown()) } _ => { - self.infer_expression(slice, TypeContext::default()); - if let Some(builder) = - self.context.report_lint(&NOT_SUBSCRIPTABLE, subscript) - { - builder.into_diagnostic(format_args!( - "Cannot subscript non-generic type `{}`", - value_ty.display(db, self.program_environment()) - )); + if !self.in_string_annotation() { + self.infer_expression(slice, TypeContext::default()); } + self.report_invalid_type_expression( + subscript, + format_args!( + "Non-generic class `{}` cannot be specialized in a type expression", + class.name(db) + ), + ); Type::unknown() } } @@ -3456,6 +3611,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { "Did you mean `Callable[..., {}]`?", returns.display(db, builder.program_environment()) )); + if !builder.in_string_annotation() + && !source_text(db, builder.file()) + .contains_line_break(first_argument.range()) + { + diagnostic.help("Replace `[...]` with `...`"); + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( + "...".to_string(), + first_argument.range(), + ))); + } } } Type::single_callable( @@ -4104,6 +4269,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return inner_ty; } + let inner_ty = inner_ty.resolve_type_alias(db); + // Preserve valid unpack targets so that `Unpack[...]` follows the same // argument-binding path as an equivalent starred annotation. if let Some(target) = unpack_target(self.db(), inner_ty) { @@ -4160,35 +4327,55 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } SpecialFormType::LiteralString => { let arguments = self.infer_expression(arguments_slice, TypeContext::default()); - if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { let mut diag = builder.into_diagnostic("`LiteralString` expects no type parameter"); - let arguments_as_tuple = arguments.exact_tuple_instance_spec(db); + let argument_elements = if self.in_string_annotation() { + let argument_expressions = match arguments_slice { + ast::Expr::Tuple(tuple) => tuple.elts.as_slice(), + _ => std::slice::from_ref(arguments_slice), + }; + let mut builder = self.speculate_without_diagnostics(); + argument_expressions + .iter() + .map(|argument| { + builder + .infer_literal_parameter_type(argument) + .unwrap_or(Type::unknown()) + }) + .collect::>() + } else { + let arguments_as_tuple = arguments.exact_tuple_instance_spec(db); + arguments_as_tuple.as_ref().map_or_else( + || vec![arguments], + |tuple| tuple.iter_element_types(db).collect(), + ) + }; - let argument_elements = arguments_as_tuple.as_ref().map_or_else( - || vec![arguments], - |tuple| tuple.iter_element_types(db).collect(), - ); - let mut argument_elements = argument_elements.into_iter(); - - let probably_meant_literal = argument_elements.all(|ty| match ty { - Type::LiteralValue(literal) - if matches!( - literal.kind(), - LiteralValueTypeKind::String(_) - | LiteralValueTypeKind::Bytes(_) - | LiteralValueTypeKind::Enum(_) - | LiteralValueTypeKind::Bool(_) - ) => - { - true - } - Type::NominalInstance(instance) => { - instance.has_known_class(db, KnownClass::NoneType) - } - _ => false, + let probably_meant_literal = argument_elements.into_iter().all(|ty| { + let elements = match ty { + Type::Union(union) => union.elements(db), + _ => std::slice::from_ref(&ty), + }; + + elements.iter().all(|ty| match ty { + Type::LiteralValue(literal) + if matches!( + literal.kind(), + LiteralValueTypeKind::String(_) + | LiteralValueTypeKind::Bytes(_) + | LiteralValueTypeKind::Enum(_) + | LiteralValueTypeKind::Bool(_) + ) => + { + true + } + Type::NominalInstance(instance) => { + instance.has_known_class(db, KnownClass::NoneType) + } + _ => false, + }) }); if probably_meant_literal { @@ -4557,8 +4744,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .insert(ruff_python_ast::ExprRef::StringLiteral(string).into()); let node_key = self.enclosing_node_key(string.into()); - let previous_deferred_state = std::mem::replace( - &mut self.deferred_state, + let previous_deferred_state = self.replace_deferred_state( DeferredExpressionState::InStringAnnotation(node_key), ); let result = matches!( @@ -4737,10 +4923,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return None; } - let previous_deferred_state = std::mem::replace( - &mut self.deferred_state, - DeferredExpressionState::InStringAnnotation(node_key), - ); + let previous_deferred_state = self + .replace_deferred_state(DeferredExpressionState::InStringAnnotation(node_key)); let result = self.infer_concatenate_tail(parsed.expr()); self.deferred_state = previous_deferred_state; @@ -4756,11 +4940,34 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } } - /// Checks if the inferred type is an unbound type variable and reports a diagnostic if so. + /// Check whether a type variable can be used in the current type expression. /// - /// Returns `Unknown` as a fallback if the type variable is unbound, otherwise returns the - /// original type unchanged. - fn check_for_unbound_type_variable(&self, expression: &ast::Expr, ty: Type<'db>) -> Type<'db> { + /// Unbound variables fall back to `Unknown`. Bound variables retain their type so that an + /// invalid scope does not also make `Callable[P, R]` or `tuple[*Ts]` appear malformed. + fn check_type_variable_scope(&self, expression: &ast::Expr, ty: Type<'db>) -> Type<'db> { + let db = self.db(); + // Legacy aliases introduce independent type parameters. PEP 695 aliases can instead + // capture their enclosing class's parameters. + if let Type::TypeVar(typevar) = ty + && self + .inference_flags() + .contains(InferenceFlags::IN_TYPE_ALIAS) + && self.typevar_binding_context.is_some_and(|definition| { + matches!(definition.kind(db), DefinitionKind::AnnotatedAssignment(_)) + }) + && let Some(owner) = typevar.binding_context(db).definition() + && matches!(owner.kind(db), DefinitionKind::Class(_)) + { + self.report_invalid_type_expression( + expression, + format_args!( + "Type alias cannot capture class-scoped type variable `{}`", + typevar.name(db) + ), + ); + return ty; + } + if !self .inference_flags() .contains(InferenceFlags::CHECK_UNBOUND_TYPEVARS) diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs index 88dfa725aa..355b0d85c1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs @@ -28,7 +28,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .iter() .any(|element| matches!(element.resolve_type_alias(db), Type::TypeForm(_))) => { - Some(target.filter_union(db, |element| { + Some(target.filter_union(db, env, |element| { !matches!(element.resolve_type_alias(db), Type::TypeForm(_)) })) } diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index fdadf7c8ee..3f343ae83c 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -11,7 +11,6 @@ use crate::types::diagnostic::{ INVALID_ARGUMENT_TYPE, INVALID_TYPE_FORM, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT, report_mismatched_type_name, }; -use crate::types::infer::builder::DeferredExpressionState; use crate::types::special_form::TypeQualifier; use crate::types::typed_dict::{ TypedDictOpenness, TypedDictSchema, collect_guaranteed_keyword_keys, @@ -19,8 +18,8 @@ use crate::types::typed_dict::{ validate_typed_dict_constructor, validate_typed_dict_dict_literal, }; use crate::types::{ - ClassType, IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext, TypedDictModule, - TypedDictType, any_over_type, + ClassType, IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext, TypedDictType, + TypingModule, any_over_type, }; use crate::{Db, ProgramEnvironment, TypeQualifiers}; use ty_python_core::definition::Definition; @@ -99,7 +98,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { &mut self, call_expr: &ast::ExprCall, definition: Option>, - typed_dict_module: TypedDictModule, + typed_dict_module: TypingModule, ) -> Type<'db> { let env = self.program_environment(); let db = self.db(); @@ -173,7 +172,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut closed = false; let mut extra_items = None; let supports_pep_728 = self.in_stub() - || typed_dict_module == TypedDictModule::TypingExtensions + || typed_dict_module == TypingModule::TypingExtensions || self.program_environment().python_version(db) >= PythonVersion::PY315; for kw in keywords { @@ -331,19 +330,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let anchor = match definition { Some(definition) => DynamicTypedDictAnchor::Definition(definition), None => { - let call_node_index = call_expr.node_index.load(); - let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("scope anchor should not be NodeIndex::NONE"); - let call_u32 = call_node_index - .as_u32() - .expect("call node should not be NodeIndex::NONE"); let schema = self.infer_dangling_typeddict_spec(fields_arg, total); DynamicTypedDictAnchor::ScopeOffset { scope, - offset: call_u32 - anchor_u32, + offset: self.dynamic_class_scope_offset(call_expr), schema, openness: extra_items.unwrap_or(if closed { TypedDictOpenness::Closed @@ -817,12 +808,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } pub(super) fn infer_extra_items_kwarg(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> { - let state = if self.in_stub() { - DeferredExpressionState::Deferred - } else { - self.deferred_state - }; - let annotation = self.infer_annotation_expression(value, state); + let annotation = self.infer_annotation_expression(value, self.deferred_state); for qualifier in TypeQualifier::iter() { if qualifier != TypeQualifier::ReadOnly && annotation diff --git a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs index 067504305e..798e428d92 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs @@ -231,7 +231,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let is_by = self.is_basedpython_file(); let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, DeferredExpressionState::Deferred); + self.replace_deferred_state(DeferredExpressionState::Deferred); let bound_node = bound.as_deref(); let bound_or_constraints = if let Some(constraints) = constraint_set_nodes(node, is_by) { let constraint_tys: Box<[Type<'_>]> = constraints @@ -808,7 +808,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_reified: _, } = node; let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, DeferredExpressionState::Deferred); + self.replace_deferred_state(DeferredExpressionState::Deferred); // basedpython: `**Kwargs: int` / `**Kwargs: **{"a": int}` — evaluated here so that // `lazy_bound` can read it back if let Some(bound) = bound.as_deref() { @@ -1013,7 +1013,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_reified: _, } = node; let previous_deferred_state = - std::mem::replace(&mut self.deferred_state, DeferredExpressionState::Deferred); + self.replace_deferred_state(DeferredExpressionState::Deferred); // basedpython: `*Ts: int` / `*Ts: *(int, str)` — evaluated here so that `lazy_bound` // can read it back if let Some(bound) = bound.as_deref() { diff --git a/crates/ty_python_semantic/src/types/infer/tests.rs b/crates/ty_python_semantic/src/types/infer/tests.rs index f85ca23ecf..44254adbf8 100644 --- a/crates/ty_python_semantic/src/types/infer/tests.rs +++ b/crates/ty_python_semantic/src/types/infer/tests.rs @@ -1,3 +1,6 @@ +use std::assert_matches; +use std::fmt::Write; + use super::builder::TypeInferenceBuilder; use crate::db::tests::{TestDb, TestDbBuilder, setup_db}; use crate::place::symbol; @@ -122,7 +125,7 @@ fn same_file_at_different_python_versions() -> anyhow::Result<()> { file, Program::from_settings( &db, - ProgramSettings { + &ProgramSettings { python_version: PythonVersionWithSource { version: PythonVersion::PY311, source: PythonVersionSource::Default, @@ -137,7 +140,7 @@ fn same_file_at_different_python_versions() -> anyhow::Result<()> { file, Program::from_settings( &db, - ProgramSettings { + &ProgramSettings { python_version: PythonVersionWithSource { version: PythonVersion::PY312, source: PythonVersionSource::Default, @@ -201,7 +204,7 @@ fn program_file_changes_with_python_version() -> anyhow::Result<()> { let equivalent_program = Program::from_settings( &db, - ProgramSettings { + &ProgramSettings { python_version: db.program_settings().python_version.clone(), python_platform: program.python_platform(&db).clone(), search_paths: program.search_paths(&db).clone(), @@ -215,7 +218,7 @@ fn program_file_changes_with_python_version() -> anyhow::Result<()> { let py312_program = Program::from_settings( &db, - ProgramSettings { + &ProgramSettings { python_version: PythonVersionWithSource { version: PythonVersion::PY312, source: PythonVersionSource::Default, @@ -300,14 +303,14 @@ fn compact_definition_types_omit_owner() -> anyhow::Result<()> { let owner_type = Type::unknown(); let owner = DefinitionTypes::from_parts(first, vec![(first, owner_type)], vec![]); - assert!(matches!(owner, DefinitionTypes::Binding(ty) if ty == owner_type)); + assert_matches!(owner, DefinitionTypes::Binding(ty) if ty == owner_type); assert_eq!( owner.bindings(first).collect::>(), [(first, owner_type)] ); let non_owner = DefinitionTypes::from_parts(first, vec![(second, owner_type)], vec![]); - assert!(matches!(non_owner, DefinitionTypes::Other(_))); + assert_matches!(non_owner, DefinitionTypes::Other(_)); assert_eq!( non_owner.bindings(first).collect::>(), [(second, owner_type)] @@ -595,6 +598,72 @@ fn simple_assignment_does_not_enter_salsa_cycle() { assert_eq!(cycles, Vec::::new()); } +/// Checks widening when a comparison truthiness override is present in only one iteration. +/// +/// A missing override falls back to the expression type's truthiness. Widening must compare the +/// effective truthiness from both iterations, including this fallback. Discarding an override from +/// the previous iteration could otherwise make a previously ambiguous condition definite again. +/// +/// We construct inference results directly because mdtests cannot prescribe intermediate Salsa +/// results. A Python cycle can converge before widening starts, or drop an override without +/// changing any final types or diagnostics. No known Python example exposes the failures checked +/// here, so this is defensive coverage of the widening invariant. +#[test] +fn comparison_truthiness_widens_across_sparse_cycle_results() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented("src/comparison.py", "0 < 1 < 2")?; + let file = program_file(&db, system_path_to_file(&db, "src/comparison.py")?); + let module = parsed_module(&db, file.python_file(&db)).load(&db); + let Some(ast::Stmt::Expr(statement)) = module.syntax().body.first() else { + anyhow::bail!("expected a comparison expression statement"); + }; + let expression = ExpressionNodeKey::from(statement.value.as_ref()); + let scope = global_scope(&db, file); + let env = ProgramEnvironment::from_scope(scope); + let inference = |ty, truthiness: Option| ExpressionInference { + expressions: [(expression, ty)].into_iter().collect(), + extra: truthiness.map(|truthiness| { + Box::new(ExpressionInferenceExtra { + comparison_truthiness: [(expression, truthiness)].into_iter().collect(), + ..ExpressionInferenceExtra::default() + }) + }), + #[cfg(debug_assertions)] + scope, + }; + + // A previously widened condition stays ambiguous even when the new result omits its + // override and has a definite value-type fallback. + let previous = inference(Type::bool_literal(false), Some(Truthiness::Ambiguous)); + let mut current = inference(Type::bool_literal(false), None); + current.widen_comparison_truthiness(&db, &env, &previous); + assert_eq!( + current.comparison_truthiness(expression), + Some(Truthiness::Ambiguous) + ); + + // A new override is compared with the previous result's value-type fallback. + let previous = inference(Type::bool_literal(true), None); + let mut current = inference(Type::unknown(), Some(Truthiness::AlwaysFalse)); + current.widen_comparison_truthiness(&db, &env, &previous); + assert_eq!( + current.comparison_truthiness(expression), + Some(Truthiness::Ambiguous) + ); + + // Matching effective truthiness stays precise. Keep the override even though it agrees with + // the current type: subsequent type widening can make that fallback ambiguous again. + let previous = inference(Type::unknown(), Some(Truthiness::AlwaysFalse)); + let mut current = inference(Type::bool_literal(false), None); + current.widen_comparison_truthiness(&db, &env, &previous); + assert_eq!( + current.comparison_truthiness(expression), + Some(Truthiness::AlwaysFalse) + ); + + Ok(()) +} + /// Test that a symbol known to be unbound in a scope does not still trigger cycle-causing /// reachability-constraint checks in that scope. #[test] @@ -664,7 +733,8 @@ class Ui: ); for index in 0..MANY_WIDGETS { - ui.push_str(&format!( + write!( + ui, concat!( " self.widget_{index} = Widget()\n", " self.widget_{index}.configure()\n", @@ -672,7 +742,7 @@ class Ui: " self.widget_{index}.configure()\n", ), index = index, - )); + )?; } ui.push_str(" self.target = Widget()\n"); @@ -721,7 +791,8 @@ class Inner: "#, ); for index in 0..MANY_WIDGETS { - inner.push_str(&format!( + write!( + inner, concat!( " self.widget_{index} = Widget()\n", " self.widget_{index}.configure()\n", @@ -729,7 +800,7 @@ class Inner: " self.widget_{index}.configure()\n", ), index = index, - )); + )?; } inner.push_str(" self.target = Widget()\n"); @@ -826,6 +897,43 @@ class Form(Ui): Ok(()) } +#[test] +fn nested_binding_remains_precise_after_many_module_calls() -> anyhow::Result<()> { + let mut db = setup_db(); + let calls = "noop()\n".repeat(MANY_NON_TERMINAL_CALLS); + let source = format!( + r#"def noop(): ... +{calls}value = 1 +values = [(value := 'abc') for _ in range(2)] +value.bit_count() +"# + ); + db.write_file("/src/main.py", &source)?; + + assert_file_diagnostics( + &db, + "/src/main.py", + &["Object of type `str` has no attribute `bit_count`"], + ); + + Ok(()) +} + +#[test] +fn redundant_cast_without_closing_parenthesis() -> anyhow::Result<()> { + let mut db = setup_db(); + + // A final newline changes the recovered argument range, so these files deliberately omit it. + for suffix in ["", " # comment"] { + let source = + format!("from typing import cast\n\ndef f(x: int):\n return cast(int, x{suffix}"); + db.write_file("/src/main.py", &source)?; + assert_file_diagnostics(&db, "/src/main.py", &["Value is already of type `int`"]); + } + + Ok(()) +} + // Incremental inference tests #[track_caller] fn first_public_binding<'db>(db: &'db TestDb, file: File, name: &str) -> Definition<'db> { @@ -868,6 +976,189 @@ fn dependency_public_symbol_type_change() -> anyhow::Result<()> { Ok(()) } +#[test] +fn function_inference_regions_are_disjoint() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/main.py", + r#" + def f(x: int = 1) -> int: return x + def annotated(x: int) -> int: return x + def defaulted(x=1): return x + "#, + )?; + let file = system_path_to_file(&db, "/src/main.py")?; + db.clear_salsa_events(); + assert_file_diagnostics(&db, "/src/main.py", &[]); + let events = db.take_salsa_events(); + assert_function_query_was_run( + &db, + infer_function_default_types, + first_public_binding(&db, file, "f"), + &events, + ); + assert_function_query_was_not_run( + &db, + infer_function_default_types, + first_public_binding(&db, file, "annotated"), + &events, + ); + assert_function_query_was_not_run( + &db, + infer_deferred_types, + first_public_binding(&db, file, "defaulted"), + &events, + ); + + let definition = first_public_binding(&db, file, "f"); + let module = parsed_module(&db, program_file(&db, file).python_file(&db)).load(&db); + let DefinitionKind::Function(function) = definition.kind(&db) else { + anyhow::bail!("expected a function definition"); + }; + let Some(parameter) = function.node(&module).parameters.find("x") else { + anyhow::bail!("expected parameter x"); + }; + let (Some(annotation), Some(default)) = (parameter.annotation(), parameter.default()) else { + anyhow::bail!("expected an annotated parameter with a default"); + }; + + let annotations = infer_deferred_types(&db, definition); + assert!(annotations.try_expression_type(annotation).is_some()); + assert!(annotations.try_expression_type(default).is_none()); + let defaults = infer_function_default_types(&db, definition); + assert!(defaults.try_expression_type(default).is_some()); + assert!(defaults.try_expression_type(annotation).is_none()); + assert_eq!( + crate::types::definition_expression_type(&db, definition, default), + defaults.expression_type(default) + ); + Ok(()) +} + +#[test] +fn lazy_parameter_defaults() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_files([ + ("/src/defaults.py", "def f(x: int = 1) -> int: return x"), + ("/src/main.py", "from defaults import f\nresult = f()"), + ])?; + let source = system_path_to_file(&db, "/src/defaults.py")?; + let main = system_path_to_file(&db, "/src/main.py")?; + db.clear_salsa_events(); + let result = global_symbol(&db, main, "result").place.expect_type(); + assert_eq!( + result.display(&db, &db.program_environment()).to_string(), + "int" + ); + let events = db.take_salsa_events(); + assert_function_query_was_not_run( + &db, + infer_function_default_types, + first_public_binding(&db, source, "f"), + &events, + ); + + // Display needs the actual default, unlike call checking. + let function = global_symbol(&db, source, "f").place.expect_type(); + assert_eq!( + function.display(&db, &db.program_environment()).to_string(), + "def f(x: int = 1) -> int" + ); + let events = db.take_salsa_events(); + assert_function_query_was_run( + &db, + infer_function_default_types, + first_public_binding(&db, source, "f"), + &events, + ); + + db.write_file("/src/defaults.py", "def f(x: int = 2) -> int: return x")?; + db.clear_salsa_events(); + let result = global_symbol(&db, main, "result").place.expect_type(); + assert_eq!( + result.display(&db, &db.program_environment()).to_string(), + "int" + ); + let events = db.take_salsa_events(); + assert_function_query_was_not_run( + &db, + infer_definition_types, + first_public_binding(&db, main, "result"), + &events, + ); + let function = global_symbol(&db, source, "f").place.expect_type(); + assert_eq!( + function.display(&db, &db.program_environment()).to_string(), + "def f(x: int = 2) -> int" + ); + Ok(()) +} + +#[test] +fn parameter_default_presence_invalidates_caller() -> anyhow::Result<()> { + let mut db = setup_db(); + let with_default = "def f(x: int = 1) -> int: return x"; + db.write_files([ + ("/src/defaults.py", with_default), + // the result is bound so `unused-return-value` does not report it: this test is + // about invalidation, not about the call + ("/src/main.py", "from defaults import f\nresult = f()"), + ])?; + assert_file_diagnostics(&db, "/src/main.py", &[]); + + db.write_file("/src/defaults.py", "def f(x: int) -> int: return x")?; + assert_file_diagnostics( + &db, + "/src/main.py", + &["No argument provided for required parameter `x` of function `f`"], + ); + + db.write_file("/src/defaults.py", with_default)?; + assert_file_diagnostics(&db, "/src/main.py", &[]); + Ok(()) +} + +#[test] +fn field_specifier_default_value_invalidates_caller() -> anyhow::Result<()> { + let mut db = setup_db(); + let field_source = r#"from typing import Any + +def field(*, init: bool = False) -> Any: ... +"#; + db.write_files([ + ("/src/fields.py", field_source), + ( + "/src/model.py", + r#"from typing_extensions import dataclass_transform +from fields import field + +@dataclass_transform(field_specifiers=(field,)) +class ModelBase: ... + +class Model(ModelBase): + value: int = field() +"#, + ), + ("/src/main.py", "from model import Model\nmodel = Model()"), + ])?; + assert_file_diagnostics(&db, "/src/main.py", &[]); + + // This changes a default's value, not the field specifier's callable signature. + db.write_file( + "/src/fields.py", + field_source.replace("init: bool = False", "init: bool = True"), + )?; + assert_file_diagnostics( + &db, + "/src/main.py", + &["No argument provided for required parameter `value` of class `Model`"], + ); + + db.write_file("/src/fields.py", field_source)?; + assert_file_diagnostics(&db, "/src/main.py", &[]); + Ok(()) +} + #[test] fn dependency_internal_symbol_change() -> anyhow::Result<()> { let mut db = setup_db(); diff --git a/crates/ty_python_semantic/src/types/inferred_signature.rs b/crates/ty_python_semantic/src/types/inferred_signature.rs index 41a3880efa..c62025c49e 100644 --- a/crates/ty_python_semantic/src/types/inferred_signature.rs +++ b/crates/ty_python_semantic/src/types/inferred_signature.rs @@ -46,7 +46,7 @@ use crate::types::typevar::{ TypeVarInstance, TypeVarKind, }; use crate::types::{ - IntersectionBuilder, KnownClass, Type, TypeContext, UnionType, infer_deferred_types, + IntersectionBuilder, KnownClass, Type, TypeContext, UnionType, infer_function_default_types, infer_scope_types, }; @@ -76,7 +76,7 @@ pub(crate) fn inferred_return_type<'db>( ) -> Type<'db> { let env = &ProgramEnvironment::from_file(overload.program_file(db)); let file = overload.file(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let module = parsed_module(db, overload.python_file(db)).load(db); let node = overload.node(db, file, &module); let body_scope = overload.body_scope(db); let index = semantic_index(db, db.program_file(file)); @@ -199,7 +199,7 @@ pub(crate) fn return_type_from_body<'db>( /// builds the same one. Both its bound and its default are lazy, which is what /// lets the bound be read out of a body that is itself typed in terms of this /// hole. -pub(crate) fn inferred_parameter_typevar<'db>( +fn inferred_parameter_typevar<'db>( db: &'db dyn Db, name: &Name, parameter: Definition<'db>, @@ -255,7 +255,7 @@ pub(crate) fn inferred_parameter_type<'db>( } /// The definition of the function `parameter` belongs to. -pub(crate) fn parameter_function_definition<'db>( +fn parameter_function_definition<'db>( db: &'db dyn Db, parameter: Definition<'db>, ) -> Option> { @@ -288,10 +288,10 @@ pub(crate) fn inferred_parameter_default<'db>( let default = node.node(&module).default.as_deref()?; let function = parameter_function_definition(db, parameter)?; - // defaults are always deferred, so this goes straight to the deferred inference the - // same way the rest of the signature does + // a default is inferred in a region of its own, separate from the rest of the signature, + // so that changing one does not invalidate everything that reads the signature Some( - infer_deferred_types(db, function) + infer_function_default_types(db, function) .expression_type(default) .replace_parameter_defaults(db, env), ) @@ -496,15 +496,18 @@ pub(crate) fn body_parameter_constraints<'db>( ) -> ParameterConstraints<'db> { let env = &ProgramEnvironment::from_definition(function); let file = function.file(db); - let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); - let index = semantic_index(db, db.program_file(file)); + // the definition's own program, not the one `db.program_file(file)` answers for the + // file: those part company for a vendored stub reached from a pep 723 script + let program_file = function.program_file(db); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); let DefinitionKind::Function(function_kind) = function.kind(db) else { return ParameterConstraints::default(); }; let node = function_kind.node(&module); let Some(body_scope) = index .try_node_scope(NodeWithScopeRef::Function(node)) - .map(|scope| scope.to_scope_id(db, db.program_file(file))) + .map(|scope| scope.to_scope_id(db, program_file)) else { return ParameterConstraints::default(); }; diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index e2b346832b..d9cdd1d9bd 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -3,21 +3,25 @@ use crate::ProgramEnvironment; use std::borrow::Cow; use std::cell::Cell; +use std::debug_assert_matches; use std::marker::PhantomData; use ruff_python_ast::name::Name; use ty_module_resolver::{ModuleName, file_to_module}; -use super::protocol_class::{InlineProtocolMember, ProtocolInterface, ProtocolInterfaceView}; +use super::protocol_class::{ + InlineProtocolMember, ProtocolInterface, ProtocolInterfaceView, StructuralMemberPriority, +}; use super::{ BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, KnownClass, - MaterializationKind, SubclassOfType, Type, TypeAliasType, TypeVarVariance, + MaterializationKind, SubclassOfType, Type, TypeAliasType, }; use crate::place::PlaceAndQualifiers; use crate::types::class::{DynamicNamedTupleAnchor, GenericAlias, StaticClassLiteral}; use crate::types::constraints::{ ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, OwnedConstraintSet, }; +use crate::types::cyclic::{ActiveRecursionDetector, TypeIdentity}; use crate::types::enums::is_single_member_enum; use crate::types::generics::{Specialization, walk_specialization}; use crate::types::protocol_class::{ @@ -32,10 +36,14 @@ use crate::types::relation::{ use crate::types::signatures::SignatureRelationVisitor; use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; use crate::types::typevar::TypeVarSet; -use crate::types::visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}; +use crate::types::visitor::{ + TypeCollector, TypeVisitor, any_over_type_expanding_aliases, materialization_is_noop, + walk_type_with_recursion_guard, +}; use crate::types::{ ApplyTypeMappingVisitor, CallableType, ClassBase, ClassLiteral, ErrorContext, FindLegacyTypeVarsVisitor, LiteralValueTypeKind, TypeContext, TypeMapping, VarianceInferable, + VarianceTerm, }; use crate::{Db, FxOrderSet}; pub(super) use synthesized_protocol::SynthesizedProtocolType; @@ -107,21 +115,14 @@ impl<'db> Type<'db> { /// This is a refinement of `Type::instance(db, class)`: it behaves as that /// instance everywhere, and only the `re` members that depend on the group /// shape consult the extra payload. - pub(crate) fn regex_instance( - db: &'db dyn Db, - class: ClassType<'db>, - groups: RegexGroups<'db>, - ) -> Self { + fn regex_instance(db: &'db dyn Db, class: ClassType<'db>, groups: RegexGroups<'db>) -> Self { Type::NominalInstance(NominalInstanceType(NominalInstanceInner::Regex( RegexInstanceClass::new(db, class, groups), ))) } - pub(crate) fn tuple(tuple: Option>) -> Self { - let Some(tuple) = tuple else { - return Type::Never; - }; - Type::tuple_instance(tuple) + pub(crate) fn tuple(tuple: TupleType<'db>) -> Self { + Type::NominalInstance(NominalInstanceType(NominalInstanceInner::ExactTuple(tuple))) } pub fn homogeneous_tuple( @@ -129,7 +130,7 @@ impl<'db> Type<'db> { env: &ProgramEnvironment<'db>, element: Type<'db>, ) -> Self { - Type::tuple_instance(TupleType::homogeneous(db, env, element)) + Type::tuple(TupleType::homogeneous(db, env, element)) } pub(crate) fn heterogeneous_tuple( @@ -149,12 +150,7 @@ impl<'db> Type<'db> { } pub(crate) fn empty_tuple(db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Self { - Type::tuple_instance(TupleType::empty(db, env)) - } - - /// **Private** helper function to create a `Type::NominalInstance` from a tuple. - fn tuple_instance(tuple: TupleType<'db>) -> Self { - Type::NominalInstance(NominalInstanceType(NominalInstanceInner::ExactTuple(tuple))) + Type::tuple(TupleType::empty(db, env)) } pub(crate) const fn sys_version_info() -> Self { @@ -328,7 +324,7 @@ impl<'db> NominalInstanceType<'db> { /// As of 2026-02-16, this method is not used in any crates in the Ruff /// repo, but is exposed as a public API for external users of /// `ty_python_semantic`. - pub fn class_name(&self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> &'db Name { + pub(crate) fn class_name(&self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> &'db Name { self.class(db, env).name(db) } @@ -342,7 +338,7 @@ impl<'db> NominalInstanceType<'db> { /// As of 2026-02-16, this method is not used in any crates in the Ruff /// repo, but is exposed as a public API for external users of /// `ty_python_semantic`. - pub fn class_module_name( + pub(crate) fn class_module_name( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -732,9 +728,8 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ty: Type<'db>, protocol: ProtocolInstanceType<'db>, ) -> ConstraintSet<'db, 'c> { - // Explicit protocol inheritance is nominal, but materializing a protocol can change - // the requirements represented by that same class. The nominal shortcut is therefore - // valid only when materialization leaves the target's members unchanged. + // Explicit protocol inheritance establishes subtyping even when a subclass overrides + // members incompatibly. let mut result = self.never(); let source_protocol = ty.as_protocol_instance(); @@ -787,47 +782,63 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } let env = self.env; - // A nominal relation that cannot succeed cannot bypass any materialized requirement. + // `result` combines nominal and structural ways to satisfy the protocol. Including the + // nominal constraints directly is safe when the target's requirements are unchanged or + // weakened by top materialization, and the source's requirements are unchanged. It is + // also safe when the nominal relation has no solutions to add to `result`. + // // Check that inexpensive case first: comparing every requirement of an unrelated // recursive protocol can expand its interface before structural member ordering gets // a chance to reject an incompatible finite member. - let nominal_is_safe = nominally_satisfied.is_never_satisfied(db, env) - || (!protocol.materialization_changes_requirements(db, env, protocol) + let can_use_nominal_result_directly = nominally_satisfied.is_never_satisfied(db, env) + || ((protocol.materialization_kind(db) == Some(MaterializationKind::Top) + || !protocol.materialization_changes_requirements(db, env, protocol)) && !source_protocol.is_some_and(|source| { source.materialization_changes_requirements(db, env, protocol) })); - if nominal_is_safe { - if result + if can_use_nominal_result_directly + && result .union(db, self.constraints, nominally_satisfied) .is_trivially_always_satisfied() - { - return result; - } + { + return result; + } - if let Some(structurally_satisfied) = self.try_check_non_recursive_protocol_members( + // For union simplification, failing the nominal relation between two + // specializations of the same protocol class is enough to keep both union elements. + // Falling back to the structural relation can recursively compare every protocol + // member even though a failed redundancy check only means that we preserve a + // potentially redundant union arm. + let can_use_nominal_redundancy = can_use_nominal_result_directly + && matches!(self.relation, TypeRelation::Redundancy { pure: false }) + && source_protocol_as_nominal.is_some_and(|source_instance| { + source_instance.class(db, env).class_literal(db) + == nominal_instance.class(db, env).class_literal(db) + }); + + // Even when the nominal result cannot be accepted on its own, it can help prove + // that recursive requirements add no constraints. For materialized protocols, the + // helper first checks the actual non-recursive requirements, then checks that their + // constraints are enough to establish the nominal relation. + // + // Eager finite checks can only reject. Lazy comparisons can also contribute + // structural solutions, so try them before using the nominal fallback. + if (self.typevar_evaluation == TypeVarEvaluation::Lazy || !can_use_nominal_redundancy) + && let Some(structurally_satisfied) = self.try_check_non_recursive_protocol_members( db, ty, protocol, source_protocol_as_nominal, nominal_instance, - ) { - return result.or(db, self.constraints, || structurally_satisfied); - } + nominally_satisfied, + ) + { + return result.or(db, self.constraints, || structurally_satisfied); + } - // For union simplification, failing the nominal relation between two - // specializations of the same protocol class is enough to keep both union elements. - // Falling back to the structural relation can recursively compare every protocol - // member even though a failed redundancy check only means that we preserve a - // potentially redundant union arm. - if matches!(self.relation, TypeRelation::Redundancy { pure: false }) - && source_protocol_as_nominal.is_some_and(|source_instance| { - source_instance.class(db, env).class_literal(db) - == nominal_instance.class(db, env).class_literal(db) - }) - { - return nominally_satisfied; - } + if can_use_nominal_redundancy { + return nominally_satisfied; } } @@ -848,6 +859,10 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { source_protocol.interface(db), protocol.interface(db), ) + } else if let Some(structurally_satisfied) = + self.try_check_nominal_recursive_protocol_members(db, ty, protocol, result) + { + structurally_satisfied } else { protocol .interface(db) @@ -867,24 +882,182 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { result.or(db, self.constraints, || structurally_satisfied) } - /// Tries to relate the finite members of two specializations of the same protocol. + /// Try a nominal proof when a materialized recursive protocol changes specialization. /// - /// This retains structural solutions such as `T | int`, while recursive members are the - /// coinductive edge currently being proved. Returns `None` when the shortcut is inapplicable. - fn try_check_non_recursive_protocol_members( + /// A recursive child can stabilize at a specialization that relates nominally even when its + /// parent only relates structurally. Keep the child's constraints without retrying the + /// structural comparison that reached the recursion guard. + pub(super) fn try_check_nominal_protocol_cycle( + &self, + db: &'db dyn Db, + source: Type<'db>, + target: Type<'db>, + ) -> Option> { + let source = source.as_protocol_instance()?; + let target = target.as_protocol_instance()?; + if source.materialization_kind(db).is_none() && target.materialization_kind(db).is_none() { + return None; + } + let source_origin = source.class_origin(db)?; + let target_origin = target.class_origin(db)?; + if source_origin.class_literal(db) != target_origin.class_literal(db) { + return None; + } + + // Nominal arguments alone do not describe materialized requirements such as a fixed + // `Any` member. Only use the nominal proof when the pending wrappers are harmless. + for protocol in [source, target] { + if let Some(origin) = protocol.materialized_origin(db) + && !materialization_is_noop( + db, + self.env, + Type::ProtocolInstance(ProtocolInstanceType::from_class(origin)), + ) + { + return None; + } + } + + Some(self.check_type_pair( + db, + Type::NominalInstance(source.nominal_origin_instance(db)?), + Type::NominalInstance(target.nominal_origin_instance(db)?), + )) + } + + /// Avoid recursive requirements that cannot add solutions beyond explicit inheritance. + fn try_check_nominal_recursive_protocol_members( &self, db: &'db dyn Db, ty: Type<'db>, protocol: ProtocolInstanceType<'db>, - source_protocol_as_nominal: Option>, - nominal_instance: NominalInstanceType<'db>, + nominally_satisfied: ConstraintSet<'db, 'c>, ) -> Option> { if self.typevar_evaluation != TypeVarEvaluation::Lazy || self.is_context_collection_enabled() + || nominally_satisfied.is_trivially_never_satisfied() { return None; } + let env = self.env; + let source = ty.as_nominal_instance()?; + let source_class = source.class(db, env); + let source_alias = source_class.into_generic_alias()?; + + let source_arguments = source_alias.specialization(db).types(db); + // Nested variables, such as `T` in `Concrete[T | Iterable[T]]`, can also be + // constrained by the nominal relation. Only variables absent from that relation + // require structural inference that the nominal proof cannot account for. + if source_arguments.iter().any(|argument| { + any_over_type_expanding_aliases(db, env, *argument, |nested| { + matches!(nested, Type::TypeVar(typevar) + if !nominally_satisfied.mentions_typevar(typevar)) + }) + }) { + return None; + } + + let interface = protocol.interface(db); + + // A concrete source normally contributes useful structural inference beyond its nominal + // specialization; for example, `()` infers `Iterable[Never]`. Recursive receiver + // binding is the exception: same-origin protocol sources and protocols with explicitly + // constrained receivers can otherwise repeatedly expand the same interface. + if !source_arguments + .iter() + .any(|argument| argument.is_type_var()) + && !protocol.class_origin(db).is_some_and(|target_origin| { + source_class.class_literal(db) == target_origin.class_literal(db) + }) + && !interface + .members(db) + .any(|member| member.has_explicit_receiver_annotation(db)) + { + return None; + } + + let mut members: Vec<_> = interface + .members(db) + .map(|member| (member.structural_member_priority(db, env), member)) + .collect(); + members.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let first_recursive = members.partition_point(|(priority, _)| { + !matches!(priority, StructuralMemberPriority::Recursive) + }); + let (finite_members, recursive_members) = members.split_at(first_recursive); + if recursive_members.is_empty() { + return None; + } + + let mut structurally_satisfied = + finite_members + .iter() + .when_all(db, self.constraints, |(_, member)| { + self.type_satisfies_protocol_member(db, ty, member) + }); + for (_, member) in recursive_members { + if structurally_satisfied + .implies(db, self.constraints, || nominally_satisfied) + .is_always_satisfied(db, env) + { + break; + } + structurally_satisfied = structurally_satisfied.and(db, self.constraints, || { + self.type_satisfies_protocol_member(db, ty, member) + }); + } + + Some(structurally_satisfied) + } + + /// Tries to relate specializations of the same protocol using only non-recursive members. + /// + /// In this example, `value` can be checked without comparing another `Chain`, while checking + /// `child` leads to another protocol comparison: + /// + /// ```python + /// class Chain[T](Protocol): + /// def value(self) -> T: ... + /// def child(self) -> Chain[tuple[T]]: ... + /// ``` + /// + /// Expanding `child` while comparing `Chain[S]` with `Chain[T]` produces a comparison of + /// `Chain[tuple[S]]` with `Chain[tuple[T]]`, then another with doubly nested tuples, and so on. + /// Each pair is different, so checking for an already-visited pair does not stop the expansion. + /// Comparing `value` instead relates `S` to `T` directly. In this example, that also establishes + /// the relationship between their tuples, without expanding `child` at all. + /// + /// For materialized protocols, we need more than a successful check of the remaining members. + /// Their constraints must mention every type variable in both sets of type arguments and imply + /// the nominal relation: every solution they allow must also satisfy the comparison of the + /// type arguments, according to the protocol's variance. Together with the materialization + /// checks below, this establishes that the recursive members cannot add further restrictions. + /// + /// We still return the structural constraints, not the nominal result. In particular, the + /// unmaterialized path retains structural solutions from members such as `value() -> T | int` + /// that comparing type arguments alone would miss. + /// + /// Eager comparisons can only reject: matching the finite requirements does not prove that + /// the omitted recursive members are compatible. Materialized protocols use this shortcut only + /// during lazy evaluation. + /// + /// Returning `None` means that we cannot use this shortcut, not that the relation fails. The + /// caller continues with its usual checks, including the full recursive comparison when needed. + fn try_check_non_recursive_protocol_members( + &self, + db: &'db dyn Db, + ty: Type<'db>, + protocol: ProtocolInstanceType<'db>, + source_protocol_as_nominal: Option>, + nominal_instance: NominalInstanceType<'db>, + nominally_satisfied: ConstraintSet<'db, 'c>, + ) -> Option> { + if self.is_context_collection_enabled() { + return None; + } + let Type::ProtocolInstance(source_protocol) = ty else { return None; }; @@ -899,6 +1072,37 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { if source_alias.origin(db) != target_alias.origin(db) { return None; } + + // Assignability chooses `Bottom` for an unmaterialized source and `Top` for an + // unmaterialized target. An explicit `Top -> Bottom` comparison is different: + // materialization can make a recursive requirement incompatible even when the type + // arguments are compatible. + // + // For example, consider: + // + // class P[T](Protocol): + // def value(self) -> T: ... + // def consume(self, other: P[Any]) -> Any: ... + // + // Comparing `Top[P[str]]` with `Bottom[P[object]]` accepts `value`, since `str` is a + // subtype of `object`. But `consume` returns `object` in the source and must return + // `Never` in the target. This fixed `Any` changes independently of `T`, so neither the + // finite member nor the nominal comparison detects the mismatch. Leave that direction + // to the full structural check. + let is_materialized = match ( + source_protocol.materialization_kind(db), + protocol.materialization_kind(db), + ) { + (None, None) => false, + (Some(MaterializationKind::Top), Some(MaterializationKind::Bottom)) => return None, + _ if self.typevar_evaluation == TypeVarEvaluation::Lazy + && self.relation.is_assignability() => + { + true + } + _ => return None, + }; + let identity_protocol = target_alias .origin(db) .identity_specialization(db) @@ -906,8 +1110,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let source_interface = source_protocol.interface(db); let target_interface = protocol.interface(db); - let source_non_recursive = - non_recursive_protocol_interface(db, source_interface.base(), identity_protocol, ty); let target_non_recursive = non_recursive_protocol_interface( db, target_interface.base(), @@ -915,24 +1117,80 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { Type::ProtocolInstance(protocol), ); - if source_non_recursive == source_interface.base() - && target_non_recursive == target_interface.base() - { + if target_non_recursive == target_interface.base() { return None; } - Some(self.check_protocol_interface_pair( + // Remove recursive requirements only from the target, and keep the complete source as + // evidence that the remaining requirements are satisfied. For example, when comparing + // `Chain[Chain[int]]` with `Chain[object]`, the target's `value() -> object` is + // non-recursive, but the source's `value() -> Chain[int]` refers to `Chain`. Filtering both + // interfaces would remove the source member we need to establish that valid return-type + // comparison. + let structurally_satisfied = self.check_protocol_interface_pair( db, ty, - ProtocolInterfaceView::new( - source_non_recursive, - source_interface.materialization_kind(), - ), + source_interface, ProtocolInterfaceView::new( target_non_recursive, target_interface.materialization_kind(), ), - )) + ); + + // A skipped member can be the only source of information about a type variable. In this + // example, `marker: Any` ensures materialization changes the interface for static arguments: + // + // class Pair[First, Second](Protocol): + // marker: Any + // @property + // def first(self) -> First: ... + // def recursive_second(self, child: Pair[Any, Any]) -> Second: ... + // + // For `Top[Pair[int, str]] -> Top[Pair[int, Second]]`, checking `first` tells us nothing + // about `Second`; only `recursive_second` supplies `str <: Second`. Check variables in both + // source and target arguments, since contravariant callable parameters can reverse the + // comparison. Also look through aliases: given `type Identity[T] = T`, the argument + // `Identity[Second]` still needs evidence for `Second`. + // + // Merely mentioning a variable is not enough: a skipped member may add its other bound. + // For example: + // + // class Invariant[T](Protocol): + // marker: Any + // @property + // def value(self) -> T: ... + // def consume(self, other: Invariant[T]) -> None: ... + // + // Comparing `Top[Invariant[str]]` with `Top[Invariant[T]]`, `value` supplies `str <: T`, + // but `consume` also requires `T <: str`. The nominal comparison requires both bounds + // because `T` is invariant. Requiring the finite constraints to imply that comparison + // catches the missing bound: allowing every supertype of `str` is not enough to prove + // `T` must equal `str`. + if is_materialized + && (target_alias + .specialization(db) + .types(db) + .iter() + .chain(source_alias.specialization(db).types(db)) + .any(|argument| { + any_over_type_expanding_aliases(db, env, *argument, |nested| { + matches!(nested, Type::TypeVar(typevar) + if !structurally_satisfied.mentions_typevar(typevar)) + }) + }) + || !structurally_satisfied + .implies(db, self.constraints, || nominally_satisfied) + .is_always_satisfied(db, env)) + { + return None; + } + + // We run the eager comparison to reject incompatible finite requirements before + // expanding recursive members. If it cannot reject, the caller checks the full + // interface instead. + (self.typevar_evaluation == TypeVarEvaluation::Lazy + || structurally_satisfied.is_never_satisfied(db, env)) + .then_some(structurally_satisfied) } /// Return whether a class-object type inhabits `type[protocol]`. @@ -951,10 +1209,10 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { protocol: ProtocolInstanceType<'db>, ) -> ConstraintSet<'db, 'c> { let env = self.env; - debug_assert!(matches!( + debug_assert_matches!( meta_ty, Type::ClassLiteral(_) | Type::SubclassOf(_) | Type::GenericAlias(_) - )); + ); let constructed_ty = meta_ty.bindings(db, env).return_type(db, env); self.check_type_pair(db, constructed_ty, Type::ProtocolInstance(protocol)) @@ -1073,6 +1331,7 @@ fn non_recursive_protocol_interface<'db>( origin: ClassLiteral<'db>, found: Cell, recursion_guard: TypeCollector<'db>, + active_aliases: ActiveRecursionDetector>, } impl<'db> TypeVisitor<'db> for ProtocolReferenceFinder<'_, 'db> { @@ -1085,7 +1344,11 @@ fn non_recursive_protocol_interface<'db>( } fn visit_type_alias_type(&self, db: &'db dyn Db, type_alias: TypeAliasType<'db>) { - self.visit_type(db, type_alias.value_type(db)); + self.active_aliases.visit( + &Type::TypeAlias(type_alias).to_type_identity(db), + || self.found.set(true), + || self.visit_type(db, type_alias.value_type(db)), + ); } fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { @@ -1115,6 +1378,7 @@ fn non_recursive_protocol_interface<'db>( origin: protocol.class_literal(db), found: Cell::new(false), recursion_guard: TypeCollector::default(), + active_aliases: ActiveRecursionDetector::default(), }; walk_protocol_instance_member(db, member, receiver_ty, &visitor); !visitor.found.get() @@ -1330,7 +1594,7 @@ impl<'db> VarianceInferable<'db> for NominalInstanceType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.class(db, env).variance_of(db, env, typevar) } } @@ -1357,6 +1621,7 @@ pub(super) fn walk_protocol_instance_type<'db, V: super::visitor::TypeVisitor<'d } else { match protocol.inner { Protocol::FromClass(_) | Protocol::Materialized(_) => { + visitor.notify_skipped_lazy_type_attributes(); if let Some((_, Some(specialization))) = protocol .class_origin(db) .and_then(|class| class.static_class_literal(db)) @@ -1744,7 +2009,7 @@ impl<'db> VarianceInferable<'db> for ProtocolInstanceType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.inner.variance_of(db, env, typevar) } } @@ -1817,7 +2082,7 @@ impl<'db> VarianceInferable<'db> for Protocol<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { match self { Protocol::FromClass(class_type) => class_type.variance_of(db, env, typevar), Protocol::Synthesized(synthesized_protocol_type) => { @@ -1835,8 +2100,7 @@ mod synthesized_protocol { use crate::types::protocol_class::ProtocolInterface; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, - FindLegacyTypeVarsVisitor, Type, TypeContext, TypeMapping, TypeVarVariance, - VarianceInferable, + FindLegacyTypeVarsVisitor, Type, TypeContext, TypeMapping, VarianceInferable, VarianceTerm, }; use crate::{Db, FxOrderSet, ProgramEnvironment}; use ty_python_core::definition::Definition; @@ -1929,7 +2193,7 @@ mod synthesized_protocol { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.interface(db).variance_of(db, env, typevar) } } diff --git a/crates/ty_python_semantic/src/types/iteration.rs b/crates/ty_python_semantic/src/types/iteration.rs index 9670b8eb0f..bb17318a03 100644 --- a/crates/ty_python_semantic/src/types/iteration.rs +++ b/crates/ty_python_semantic/src/types/iteration.rs @@ -293,6 +293,7 @@ impl<'db> Type<'db> { | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::BoundSuper(_) diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index 86a44602d6..398ef4cb3c 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -8,7 +8,7 @@ use crate::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, CallableType, ClassType, GenericContext, InferenceFlags, InvalidTypeExpressionError, KnownClass, PromotionKind, PromotionMode, StringLiteralType, Type, TypeAliasType, TypeContext, - TypeMapping, TypeVarNonce, TypeVarVariance, UnionBuilder, + TypeMapping, TypeVarNonce, UnionBuilder, VarianceTerm, class::NamedTupleSpec, constraints::{OwnedConstraintSet, TypeVarSolution}, dedicated::pydantic::ConfigBoolean, @@ -247,12 +247,12 @@ impl<'db> VarianceInferable<'db> for KnownInstanceType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { match self { KnownInstanceType::TypeAliasType(type_alias) => { type_alias.raw_value_type(db).variance_of(db, env, typevar) } - _ => TypeVarVariance::Bivariant, + _ => VarianceTerm::BIVARIANT, } } } @@ -334,7 +334,7 @@ impl<'db> KnownInstanceType<'db> { Self::TypeAliasType(alias) if alias.specialization(db).is_some() => { KnownClass::GenericAlias } - Self::TypeAliasType(_) => KnownClass::TypeAliasType, + Self::TypeAliasType(alias) => alias.known_class(db), Self::Deprecated(_) => KnownClass::Deprecated, Self::Field(_) => KnownClass::Field, Self::ConstraintSet(_) => KnownClass::ConstraintSet, diff --git a/crates/ty_python_semantic/src/types/list_members.rs b/crates/ty_python_semantic/src/types/list_members.rs index 6112ed4fb6..90a140887a 100644 --- a/crates/ty_python_semantic/src/types/list_members.rs +++ b/crates/ty_python_semantic/src/types/list_members.rs @@ -16,10 +16,11 @@ use crate::{ DefinedPlace, Place, PlaceWithDefinition, imported_symbol, place_from_bindings, place_from_declarations, }, + reachability::ReachabilityConstraintsExtension, types::{ ClassBase, ClassLiteral, KnownClass, ProgramEnvironment, StaticClassLiteral, SubclassOfInner, Type, TypeVarBoundOrConstraints, class::CodeGeneratorKind, - exists_at_runtime, + function::FunctionType, infer_definition_types, may_exist_at_runtime, }, }; use ty_python_core::{ @@ -335,7 +336,7 @@ impl<'db> AllMembers<'db> { db, env, ty, - class_literal.metaclass(db), + class_type.inferred_metaclass(db).for_inheritance(db, env), ); } } @@ -403,6 +404,7 @@ impl<'db> AllMembers<'db> { Type::LiteralValue(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::FunctionLiteral(_) | Type::BoundMethod(_) | Type::KnownBoundMethod(_) @@ -496,7 +498,7 @@ impl<'db> AllMembers<'db> { is_type_check_only: defined .provenance .definition() - .is_some_and(|definition| !exists_at_runtime(db, definition)), + .is_some_and(|definition| !may_exist_at_runtime(db, definition)), }); } @@ -549,6 +551,8 @@ impl<'db> AllMembers<'db> { .filter_map(ClassBase::into_class) .filter_map(|class| class.static_class_literal(db).map(|(lit, _)| lit)) { + self.extend_with_slot_members(db, env, ty, parent); + let parent_scope = parent.body_scope(db); for memberdef in all_end_of_scope_members(db, parent_scope) { let result = ty.member(db, env, memberdef.member.name.as_str()); @@ -564,6 +568,38 @@ impl<'db> AllMembers<'db> { } } + /// Includes slot descriptors that are generated outside the class-body symbol table. + /// + /// ```python + /// class Example: + /// __slots__ = ("value",) + /// ``` + /// + /// Both `Example` and `Example()` expose `value` even without an explicit attribute binding. + fn extend_with_slot_members( + &mut self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + class_literal: StaticClassLiteral<'db>, + ) { + let Some(slots) = class_literal.slot_names(db) else { + return; + }; + + for name in slots { + let result = ty.member(db, env, name); + let Some(ty) = result.place.ignore_possibly_undefined() else { + continue; + }; + self.members.insert(Member { + name: name.clone(), + ty, + is_type_check_only: false, + }); + } + } + /// Extend a class object's members with members set by its metaclass. /// /// A static metaclass can also assign attributes onto the class objects that it creates, @@ -596,6 +632,9 @@ impl<'db> AllMembers<'db> { let class_body_scope = class_literal.body_scope(db); let program_file = class_body_scope.program_file(db); let index = semantic_index(db, program_file); + + self.extend_with_slot_members(db, env, ty, class_literal); + for function_scope_id in attribute_scopes(db, class_body_scope) { for place_expr in index.place_table(function_scope_id).members() { let Some(name) = place_expr.as_instance_attribute() else { @@ -754,6 +793,83 @@ pub struct Member<'db> { pub is_type_check_only: bool, } +impl<'db> Member<'db> { + /// Recover local functions retained in the exposed type, including property accessors. + /// Unlike [`Self::local_functions`], this does not recover definitions replaced by decorators. + fn local_functions_from_type( + &self, + db: &'db dyn Db, + scope: ScopeId<'db>, + ) -> smallvec::SmallVec<[FunctionType<'db>; 1]> { + let mut functions = smallvec::SmallVec::<[FunctionType<'db>; 1]>::new(); + let mut types: smallvec::SmallVec<[Type<'db>; 1]> = smallvec::smallvec![self.ty]; + let mut index = 0; + + while let Some(ty) = types.get(index).copied() { + index += 1; + match ty { + Type::PropertyInstance(property) => { + for accessor in [ + property.getter(db), + property.setter(db), + property.deleter(db), + ] + .into_iter() + .flatten() + { + functions.extend(extract_underlying_functions(db, accessor)); + } + } + Type::Union(union) => { + types.extend(union.elements(db).iter().copied()); + } + _ => functions.extend(extract_underlying_functions(db, ty)), + } + } + + functions + .into_iter() + .filter(|function| is_local_member_function(db, *function, &self.name, scope)) + .collect() + } + + /// Recover source methods for a class member, including retained property accessors. + /// + /// The exposed type and the source functions serve different purposes: decorators can replace + /// a function's type while its definition still carries exclusions or diagnostic locations. + /// Functions recovered from the type must belong to this member, so aliases and replacements + /// from another class are not treated as local method definitions. + pub(super) fn local_functions( + &self, + db: &'db dyn Db, + scope: ScopeId<'db>, + ) -> smallvec::SmallVec<[FunctionType<'db>; 1]> { + let member_functions = self.local_functions_from_type(db, scope); + let mut functions = smallvec::SmallVec::<[FunctionType<'db>; 1]>::new(); + for definition in end_of_scope_function_definitions(db, scope, &self.name) { + let function = member_functions + .iter() + .copied() + .find(|function| function.contains_definition(db, definition)) + .or_else(|| infer_definition_types(db, definition).function_type(definition)); + + if let Some(function) = function + && !functions.contains(&function) + { + functions.push(function); + } + } + + // A property can retain a getter even though only its setter is an end-of-scope binding. + let additional_functions: smallvec::SmallVec<[_; 1]> = member_functions + .into_iter() + .filter(|function| !functions.contains(function)) + .collect(); + functions.extend(additional_functions); + functions + } +} + impl std::hash::Hash for Member<'_> { fn hash(&self, state: &mut H) { self.name.hash(state); @@ -780,6 +896,70 @@ impl<'db> PartialOrd for Member<'db> { } } +/// Return reachable function definitions that bind `member_name` at the end of `subclass_scope`. +fn end_of_scope_function_definitions<'db>( + db: &'db dyn Db, + subclass_scope: ScopeId<'db>, + member_name: &Name, +) -> smallvec::SmallVec<[Definition<'db>; 1]> { + let table = place_table(db, subclass_scope); + let Some(symbol_id) = table.symbol_id(member_name) else { + return smallvec::smallvec![]; + }; + + let use_def = use_def_map(db, subclass_scope); + let predicates = use_def.predicates(); + let reachability_constraints = use_def.reachability_constraints(); + use_def + .end_of_scope_symbol_bindings(symbol_id) + .filter_map(|binding| { + let definition = binding.binding.definition()?; + let reachability = + reachability_constraints.evaluate(db, predicates, binding.reachability_constraint); + if reachability.is_always_false() || !definition.kind(db).is_function_def() { + return None; + } + + Some(definition) + }) + .collect() +} + +fn is_local_member_function<'db>( + db: &'db dyn Db, + function: FunctionType<'db>, + member_name: &Name, + member_scope: ScopeId<'db>, +) -> bool { + function.python_file(db) == member_scope.python_file(db) + && function.definition(db).scope(db) == member_scope + && function.name(db) == member_name +} + +/// Extract callable functions represented by a type. +/// These may be defined in files other than the one being checked. +pub(super) fn extract_underlying_functions<'db>( + db: &'db dyn Db, + ty: Type<'db>, +) -> smallvec::SmallVec<[FunctionType<'db>; 1]> { + match ty { + Type::FunctionLiteral(function) => smallvec::smallvec_inline![function], + Type::BoundMethod(method) => smallvec::smallvec_inline![method.function(db)], + Type::PropertyInstance(property) => property.getter(db).map_or_else( + || smallvec::smallvec![], + |getter| extract_underlying_functions(db, getter), + ), + Type::Union(union) => { + let mut functions = smallvec::smallvec![]; + for member in union.elements(db) { + functions.extend(extract_underlying_functions(db, *member)); + } + functions + } + _ => smallvec::smallvec![], + } +} + /// List all members of a given type: anything that would be valid when accessed /// as an attribute on an object of the given type. pub fn all_members<'db>( diff --git a/crates/ty_python_semantic/src/types/match_pattern.rs b/crates/ty_python_semantic/src/types/match_pattern.rs index 65d720b75d..2bf2496f88 100644 --- a/crates/ty_python_semantic/src/types/match_pattern.rs +++ b/crates/ty_python_semantic/src/types/match_pattern.rs @@ -9,7 +9,7 @@ use ty_python_core::predicate::{ }; use crate::place::{DefinedPlace, Place}; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; +use crate::types::callable::CallableTypeKind; use crate::types::context_sensitive::case_name_pattern_type; use crate::types::equality::{ ComparisonSoundnessPolicy, evaluate_type_equality, is_same_enum_domain, @@ -170,7 +170,6 @@ fn sequence_pattern_getitem_method<'db>( db, CallableSignature::from_overloads(overloads.chain(fallback_overload)), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::None, ) } @@ -1146,7 +1145,9 @@ fn subject_independent_definite_match_pattern_type<'db>( PatternPredicateKind::Class(kind) => { match infer_same_file_expression_type(db, kind.class, TypeContext::default()) { Type::ClassLiteral(class) if kind.is_empty() => { - let class_instance_ty = Type::instance(db, env, class.top_materialization(db)); + let class_instance_ty = + Type::instance(db, env, class.unknown_specialization(db)) + .top_materialization(db, env); let typed_dict_adds_runtime_matches = typed_dict_matches_class_pattern(db, env, class) && !Type::object().is_subtype_of(db, env, class_instance_ty); @@ -1212,7 +1213,8 @@ pub(crate) fn definite_match_pattern_type<'db>( PatternPredicateKind::Class(kind) => { match infer_same_file_expression_type(db, kind.class, TypeContext::default()) { Type::ClassLiteral(class) if kind.is_empty() => { - Type::instance(db, env, class.top_materialization(db)) + Type::instance(db, env, class.unknown_specialization(db)) + .top_materialization(db, env) } Type::SpecialForm(SpecialFormType::CollectionsAbcCallable) if kind.is_empty() => { callable_pattern_type(db, env) diff --git a/crates/ty_python_semantic/src/types/match_type.rs b/crates/ty_python_semantic/src/types/match_type.rs index 06bbb57830..b341af9031 100644 --- a/crates/ty_python_semantic/src/types/match_type.rs +++ b/crates/ty_python_semantic/src/types/match_type.rs @@ -251,11 +251,11 @@ fn subject_type<'db>( return Some(unpacked); } let bound_typevar = unpacked.as_typevar()?; - Some(Type::tuple(Some(TupleType::unpacked_typevartuple( + Some(Type::tuple(TupleType::unpacked_typevartuple( db, env, bound_typevar, - )))) + ))) } /// The outcome of matching one pattern against one subject type. diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 3e1454889c..4793358ce2 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -7,13 +7,9 @@ use crate::{ types::{ CallableType, KnownClass, LiteralValueType, LiteralValueTypeKind, Parameter, Parameters, PropertyInstanceType, Signature, StringLiteralType, Type, TypeFormType, UnionType, - callable::{CallableFunctionProvenance, CallableTypeKind}, - constraints::ConstraintSet, - function::FunctionType, - known_instance::InternedConstraintSet, - relation::TypeRelationChecker, - signatures::CallableSignature, - visitor, + callable::CallableTypeKind, constraints::ConstraintSet, function::FunctionType, + known_instance::InternedConstraintSet, relation::TypeRelationChecker, + signatures::CallableSignature, visitor, }, }; @@ -31,6 +27,15 @@ pub struct BoundMethodType<'db> { /// attribute on a bound method object #[returns(copy)] pub(super) self_instance: Type<'db>, + + /// The receiver type used to validate and specialize the function signature. + /// + /// This normally equals [`self_instance`][Self::self_instance]. They differ when member lookup + /// distributes over the declared constraints of a typevar: This field contains the particular + /// declared constraint that this bound method belongs to, while `self_instance` is the typevar + /// itself. + #[returns(copy)] + pub(super) signature_receiver: Type<'db>, } // The Salsa heap is tracked separately. @@ -43,6 +48,7 @@ pub(super) fn walk_bound_method_type<'db, V: visitor::TypeVisitor<'db> + ?Sized> ) { visitor.visit_function_type(db, method.function(db)); visitor.visit_type(db, method.self_instance(db)); + visitor.visit_type(db, method.signature_receiver(db)); } #[salsa::tracked] @@ -66,9 +72,23 @@ impl<'db> BoundMethodType<'db> { pub(crate) fn map_self_type( self, db: &'db dyn Db, - f: impl FnOnce(Type<'db>) -> Type<'db>, + mut f: impl FnMut(Type<'db>) -> Type<'db>, ) -> Self { - Self::new(db, self.function(db), f(self.self_instance(db))) + Self::new( + db, + self.function(db), + f(self.self_instance(db)), + f(self.signature_receiver(db)), + ) + } + + pub(crate) fn with_signature_receiver( + self, + db: &'db dyn Db, + self_instance: Type<'db>, + signature_receiver: Type<'db>, + ) -> Self { + Self::new(db, self.function(db), self_instance, signature_receiver) } #[salsa::tracked( @@ -77,14 +97,10 @@ impl<'db> BoundMethodType<'db> { heap_size=ruff_memory_usage::heap_size )] pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> CallableType<'db> { - let function = self.function(db); CallableType::new( db, self.bound_signatures(db), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::from_function_return_annotation( - function.has_explicit_return_annotation(db), - ), ) } @@ -96,15 +112,10 @@ impl<'db> BoundMethodType<'db> { receiver_type: Type<'db>, typing_self_type: Type<'db>, ) -> CallableType<'db> { - let function = self.function(db); - CallableType::new( db, self.bound_signatures_with_receiver(db, env, receiver_type, typing_self_type), CallableTypeKind::FunctionLike, - CallableFunctionProvenance::from_function_return_annotation( - function.has_explicit_return_annotation(db), - ), ) } @@ -114,7 +125,7 @@ impl<'db> BoundMethodType<'db> { let env = ProgramEnvironment::from_scope(function.literal(db).last_definition.body_scope(db)); let typing_self_type = self.typing_self_type(db); - let receiver_type = self.self_instance(db); + let receiver_type = self.signature_receiver(db); self.bound_signatures_with_receiver(db, &env, receiver_type, typing_self_type) } @@ -147,18 +158,25 @@ impl<'db> BoundMethodType<'db> { } return CallableSignature::from_overloads( - function_signature.overloads.iter().filter_map(|signature| { - signature.bind_self_if_compatible(db, env, receiver_type, typing_self_type) - }), + function_signature + .overloads + .iter() + .filter_map(|signature| { + signature.bind_self_if_compatible(db, env, receiver_type, typing_self_type) + }) + .flat_map(|signature| signature.overloads), ); }; - CallableSignature::single(signature.bind_self_with_receiver( - db, - env, - Some(receiver_type), - Some(typing_self_type), - )) + let specialized = if signature.has_receiver_determined_method_typevar(db, env) { + signature.specialize_for_bound_receiver(db, env, receiver_type, typing_self_type) + } else { + None + }; + + specialized + .unwrap_or_else(|| CallableSignature::single(signature.clone())) + .bind_self_with_receiver(db, env, Some(receiver_type), Some(typing_self_type)) } pub(super) fn recursive_type_normalized_impl( @@ -174,6 +192,8 @@ impl<'db> BoundMethodType<'db> { .recursive_type_normalized_impl(db, env, div, nested)?, self.self_instance(db) .recursive_type_normalized_impl(db, env, div, true)?, + self.signature_receiver(db) + .recursive_type_normalized_impl(db, env, div, true)?, )) } } @@ -185,13 +205,19 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { source: BoundMethodType<'db>, target: BoundMethodType<'db>, ) -> ConstraintSet<'db, 'c> { - // A bound method is a typically a subtype of itself. However, we must explicitly verify - // the subtyping of the underlying function signatures (since they might be specialized - // differently), and of the bound self parameter (taking care that parameters, including a - // bound self parameter, are contravariant.) + // The receiver exposed by `__self__` is an already-captured value, so it is covariant. + // However, `Self` can also appear in the remaining parameters, where binding the + // receiver must still preserve ordinary callable contravariance. self.check_function_pair(db, source.function(db), target.function(db)) .and(db, self.constraints, || { - self.check_type_pair(db, target.self_instance(db), source.self_instance(db)) + self.check_type_pair(db, source.self_instance(db), target.self_instance(db)) + }) + .and(db, self.constraints, || { + self.check_callable_signature_pair( + db, + source.bound_signatures(db), + target.bound_signatures(db), + ) }) } } @@ -230,7 +256,6 @@ pub enum KnownBoundMethodType<'db> { ConstraintSetSatisfies(InternedConstraintSet<'db>), ConstraintSetExists(InternedConstraintSet<'db>), ConstraintSetForAll(InternedConstraintSet<'db>), - ConstraintSetSatisfiedByAllTypeVars(InternedConstraintSet<'db>), ConstraintSetSolutionsFor(InternedConstraintSet<'db>), ConstraintSetSolutions(InternedConstraintSet<'db>), ConstraintSetWithDetailedDisplay(InternedConstraintSet<'db>), @@ -273,7 +298,6 @@ pub(super) fn walk_method_wrapper_type<'db, V: visitor::TypeVisitor<'db> + ?Size | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => {} @@ -325,7 +349,6 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => Some(self), @@ -351,7 +374,6 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => { @@ -572,23 +594,6 @@ impl<'db> KnownBoundMethodType<'db> { ))) } - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) => { - Either::Right(std::iter::once(Signature::new( - Parameters::standard([Parameter::keyword_only(Name::new_static("inferable")) - .with_annotated_type(UnionType::from_two_elements( - db, - env, - TypeFormType::from_type_expression( - db, - Type::homogeneous_tuple(db, env, Type::object()), - ), - Type::none(db, env), - )) - .with_default_type(Type::none(db, env))]), - KnownClass::Bool.to_instance(db, env), - ))) - } - KnownBoundMethodType::ConstraintSetSolutionsFor(_) => { Either::Right(std::iter::once(Signature::new( Parameters::standard([ @@ -719,10 +724,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { KnownBoundMethodType::ConstraintSetForAll(_), KnownBoundMethodType::ConstraintSetForAll(_), ) - | ( - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), - KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), - ) | ( KnownBoundMethodType::ConstraintSetSolutionsFor(_), KnownBoundMethodType::ConstraintSetSolutionsFor(_), @@ -753,7 +754,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), @@ -773,7 +773,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 725b2e18fc..450d32c1fd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1,7 +1,11 @@ use std::borrow::Cow; +use std::cmp::Ordering; use std::collections::{BTreeMap, btree_map::Entry as BTreeEntry, hash_map::Entry}; -use crate::reachability::{narrow_type_by_constraint, type_narrowed_by_previous_patterns}; +use crate::place::loop_header_reachability; +use crate::reachability::{ + binding_reachability, narrow_type_by_constraint, type_narrowed_by_previous_patterns, +}; use crate::subscript::PyIndex; use crate::types::callable::CallableTypes; use crate::types::context_sensitive::case_name_pattern_type; @@ -10,19 +14,22 @@ use crate::types::infer::{ExpressionInference, infer_same_file_expression_type}; use crate::types::narrowing_guards::{GuardRoot, guard_root, narrowed_place, narrowed_scope_place}; use crate::types::signatures::NarrowingGuardKind; use crate::types::special_form::TypeQualifier; -use crate::types::tuple::{TupleLength, TupleSpec, TupleSpecBuilder, TupleType, TupleUnpacker}; +use crate::types::tuple::{TupleElement, TupleLength, TupleSpec, TupleSpecBuilder, TupleType}; use crate::types::typed_dict::{TypedDictFieldBuilder, TypedDictSchema, TypedDictType}; +use crate::types::unpacker::collected_list_type; use crate::types::{ CallableType, ClassBase, ClassLiteral, ClassPatternPositionalSource, ClassType, IntersectionBuilder, IntersectionType, KnownClass, KnownInstanceType, LiteralValueTypeKind, Parameter, Parameters, Signature, SpecialFormType, SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext, TypeVarBoundOrConstraints, UnionBuilder, basedpython_is_keeps_identity, - callable_pattern_type, class_pattern_positional_sources, + binding_type, callable_pattern_type, class_pattern_positional_sources, definite_match_pattern_type_for_subject, exact_sequence_pattern_type, infer_expression_types, mapping_pattern_type, pattern_binding_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, starred_sequence_pattern_type, typed_dict_matches_class_pattern, }; use crate::{Db, ProgramEnvironment}; +use ty_python_core::ast_ids::HasScopedUseId; +use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::expression::Expression; use ty_python_core::frozen::FrozenMap; use ty_python_core::place::{PlaceExpr, PlaceTable, ScopedPlaceId}; @@ -32,6 +39,7 @@ use ty_python_core::predicate::{ SubjectElementPatternPredicate, }; use ty_python_core::scope::ScopeId; +use ty_python_core::symbol::Symbol; use ty_python_core::{ExpressionNodeKey, NarrowingEvaluator, place_table, semantic_index}; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; @@ -40,13 +48,12 @@ use ruff_python_stdlib::identifiers::is_identifier; use super::UnionType; use super::call::CallArguments; -use super::constraints::{ConstraintSetBuilder, PathBounds, Solutions}; +use super::constraints::{ConstraintSetBuilder, Solutions}; use super::equality::{ ComparisonSoundnessPolicy, equality_exclusion_constraint, equality_truthiness, evaluate_type_equality, evaluate_type_inequality, }; use super::match_pattern::is_typed_dict_runtime_domain; -use super::variance::TypeVarVariance; use itertools::Itertools; use ruff_python_ast as ast; use ruff_python_ast::{BoolOp, ExprBoolOp}; @@ -83,7 +90,9 @@ pub(crate) fn infer_narrowing_constraints<'db>( Option>, ) { let constraints = match predicate.node { - PredicateNode::Expression(expression) => { + PredicateNode::Expression(expression) + | PredicateNode::Condition(expression) + | PredicateNode::ChainedComparisonCondition(expression) => { let constraints = all_narrowing_constraints_for_expression(db, expression); ( constraints.get(place, true).cloned(), @@ -129,7 +138,9 @@ pub(crate) fn infer_narrowing_constraints<'db>( None => (None, None), } } - PredicateNode::IsNonTerminalCall(_) + PredicateNode::ContextManagerSuppresses { .. } + | PredicateNode::FinallyNormalPathImpossible { .. } + | PredicateNode::IsNonTerminalCall(_) | PredicateNode::IsNonEmptyIterable(_) | PredicateNode::OrPatternAlternative(_) | PredicateNode::StarImportPlaceholder(_) @@ -143,6 +154,23 @@ pub(crate) fn infer_narrowing_constraints<'db>( } } +#[salsa::tracked( + returns(as_ref), + cycle_initial=|_, _, _| None, + heap_size=ruff_memory_usage::heap_size, +)] +fn all_narrowing_constraints_for_pattern<'db>( + db: &'db dyn Db, + pattern: PatternPredicate<'db>, +) -> Option> { + let program_file = pattern.program_file(db); + let python_file = program_file.python_file(db); + let env = ProgramEnvironment::from_file(program_file); + let module = parsed_module(db, python_file).load(db); + NarrowingConstraintsBuilder::new(db, &env, &module, PredicateNode::Pattern(pattern), true) + .finish() +} + /// basedpython: the places a statement-level call asserts, and what it narrows each to. /// /// `def f(x) -> asserts x` narrows the argument the call passes for `x`, `-> asserts self.d` @@ -269,19 +297,6 @@ fn asserted_argument<'ast>( positional.last() } -#[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] -fn all_narrowing_constraints_for_pattern<'db>( - db: &'db dyn Db, - pattern: PatternPredicate<'db>, -) -> Option> { - let program_file = pattern.program_file(db); - let python_file = program_file.python_file(db); - let env = ProgramEnvironment::from_file(program_file); - let module = parsed_module(db, python_file).load(db); - NarrowingConstraintsBuilder::new(db, &env, &module, PredicateNode::Pattern(pattern), true) - .finish() -} - #[salsa::tracked( returns(ref), cycle_initial=|_, _, _| ExpressionNarrowingConstraints::default(), @@ -302,7 +317,11 @@ fn all_narrowing_constraints_for_expression<'db>( } } -#[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked( + returns(as_ref), + cycle_initial=|_, _, _| None, + heap_size=ruff_memory_usage::heap_size, +)] fn all_negative_narrowing_constraints_for_pattern<'db>( db: &'db dyn Db, pattern: PatternPredicate<'db>, @@ -315,7 +334,11 @@ fn all_negative_narrowing_constraints_for_pattern<'db>( .finish() } -#[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked( + returns(as_ref), + cycle_initial=|_, _, _, _| None, + heap_size=ruff_memory_usage::heap_size, +)] fn all_narrowing_constraints_for_subject_element_pattern<'db>( db: &'db dyn Db, pattern: PatternPredicate<'db>, @@ -636,15 +659,21 @@ impl ClassInfoConstraintFunction { let specialization = if use_generic_filtering { class.unknown_specialization(db) } else { - // A negative result excludes every specialization of the class. class.top_materialization(db) }; - - match self { + let constraint = match self { ClassInfoConstraintFunction::IsInstance => Type::instance(db, env, specialization), ClassInfoConstraintFunction::IsSubclass => { SubclassOfType::from(db, env, specialization) } + }; + + if is_positive { + constraint + } else { + // A negative result excludes every specialization of the class. Materialize the + // whole type so that this also covers gradual protocol members. + constraint.top_materialization(db, env) } }; @@ -882,6 +911,7 @@ impl ClassInfoConstraintFunction { | Type::FunctionLiteral(_) | Type::ProtocolInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::KnownInstance(_) | Type::TypeIs(_) | Type::TypeGuard(_) @@ -1066,12 +1096,9 @@ fn specialize_narrowing_target_from_intersection<'db>( combined_constraints.intersect(db, &constraints, base_constraint); } - let solutions = combined_constraints.solutions( - db, - env, - &constraints, - generic_context.inferable_typevars(db), - ); + let solutions = combined_constraints + .solutions(db, env, generic_context.inferable_typevars(db)) + .ok()?; let specialized_class = specialize_generic_class_from_solutions(db, env, target_class, solutions)?; Some(Type::instance(db, env, specialized_class)) @@ -1678,6 +1705,63 @@ enum NominalAttributeComparison { Identity, } +/// A comparison with an integer length, expressed as a required or excluded ordering. +/// For example, `<=` excludes `Ordering::Greater`. +#[derive(Clone, Copy)] +struct LengthComparison { + ordering: Ordering, + is_positive: bool, +} + +impl LengthComparison { + fn from_op(op: ast::CmpOp, is_positive: bool) -> Option { + let (ordering, matches_ordering) = match op { + ast::CmpOp::Eq => (Ordering::Equal, true), + ast::CmpOp::NotEq => (Ordering::Equal, false), + ast::CmpOp::Lt => (Ordering::Less, true), + ast::CmpOp::LtE => (Ordering::Greater, false), + ast::CmpOp::Gt => (Ordering::Greater, true), + ast::CmpOp::GtE => (Ordering::Less, false), + _ => return None, + }; + Some(Self { + ordering, + is_positive: matches_ordering == is_positive, + }) + } + + fn reflected(self) -> Self { + Self { + ordering: self.ordering.reverse(), + ..self + } + } + + fn is_equality(self) -> bool { + self.ordering == Ordering::Equal && self.is_positive + } + + fn matches(self, actual: i128, expected: i64) -> bool { + (actual.cmp(&i128::from(expected)) == self.ordering) == self.is_positive + } + + fn matches_tuple_length(self, actual: TupleLength, expected: i64) -> bool { + match actual { + TupleLength::Fixed(actual) => self.matches(actual as i128, expected), + TupleLength::Variable(..) => { + let minimum = actual.minimum() as i128; + let expected = i128::from(expected); + match (self.ordering, self.is_positive) { + (Ordering::Equal, true) | (Ordering::Greater, false) => minimum <= expected, + (Ordering::Less, true) => minimum < expected, + // An unbounded tuple can exceed any upper bound or differ from any exact length. + _ => true, + } + } + } + } +} + struct NarrowingConstraintsBuilder<'db, 'ast> { db: &'db dyn Db, env: ProgramEnvironment<'db>, @@ -1705,7 +1789,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { fn finish(mut self) -> Option> { let constraints: Option> = match self.predicate { - PredicateNode::Expression(expression) => { + PredicateNode::Expression(expression) + | PredicateNode::Condition(expression) + | PredicateNode::ChainedComparisonCondition(expression) => { self.evaluate_expression_predicate(expression, self.is_positive) } PredicateNode::Pattern(pattern) => { @@ -1714,7 +1800,10 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { PredicateNode::SubjectElementPattern(subject_element) => { self.evaluate_subject_element_pattern(subject_element) } - PredicateNode::AssertsCall(_) | PredicateNode::IsNonTerminalCall(_) => return None, + PredicateNode::AssertsCall(_) + | PredicateNode::ContextManagerSuppresses { .. } + | PredicateNode::FinallyNormalPathImpossible { .. } + | PredicateNode::IsNonTerminalCall(_) => return None, PredicateNode::IsNonEmptyIterable(_) => return None, PredicateNode::OrPatternAlternative(_) => return None, PredicateNode::StarImportPlaceholder(_) => return None, @@ -1742,10 +1831,17 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) -> Option> { let db = self.db; match expression_node { - ast::Expr::Name(_) => { + ast::Expr::Name(name) => { let index = semantic_index(db, expression.program_file(db)); let constraints = self.evaluate_simple_expr(expression_node, is_positive); - if let Some(alias_predicate) = index.narrowing_alias_predicate(expression_node) { + if let Some(alias_predicate) = index.narrowing_alias_predicate(expression_node) + && self.is_valid_alias( + name, + expression, + alias_predicate.expression, + is_positive, + ) + { let aliased_constraints = self.evaluate_expression_predicate(alias_predicate.expression, is_positive); // For example, suppose we have an alias `is_none = x is None`. @@ -1807,6 +1903,80 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } + /// Check that every binding that can produce this outcome evaluates the recorded alias. + /// Reachability is not yet known when aliases are recorded in the semantic index. + fn is_valid_alias( + &self, + name: &ast::ExprName, + expression: Expression<'db>, + alias: Expression<'db>, + is_positive: bool, + ) -> bool { + let db = self.db; + let scope = expression.scope(db); + let index = semantic_index(db, expression.program_file(db)); + let use_def = index.use_def_map(scope.file_scope_id(db)); + let alias_key = ExpressionNodeKey::from(alias.node_ref(db).node(self.module)); + + // An unbound local raises before the condition is evaluated. Other scopes can fall back + // to a different binding, such as a global with the same name in a class body. + let unbound_is_terminal = scope.node(db).scope_kind().is_function_like() + && index + .place_table(scope.file_scope_id(db)) + .symbol_by_name(name.id.as_str()) + .is_some_and(Symbol::is_local); + + use_def + .bindings_at_use(name.scoped_use_id(db, expression.program_file(db))) + .all(|binding| { + let Some(definition) = binding.binding.definition() else { + return unbound_is_terminal + || binding_reachability(db, use_def, &binding).is_always_false(); + }; + if self.binding_assigns_alias(definition, alias_key, unbound_is_terminal) + || binding_reachability(db, use_def, &binding).is_always_false() + { + return true; + } + if matches!(definition.kind(db), DefinitionKind::LoopHeader(_)) { + // Inferring a mixed loop header could depend on this predicate itself. + return false; + } + + // A different binding need not prevent narrowing if it cannot produce this + // outcome: `if not check: check = x is None` still narrows `x` when `check` is false. + let ty = binding.narrowing_constraint.narrow( + db, + &self.env, + binding_type(db, definition), + definition.place(db), + ); + // `Never` cannot produce either outcome, even though its truthiness is ambiguous. + ty.is_never() || ty.bool(db, &self.env) == Truthiness::from(!is_positive) + }) + } + + fn binding_assigns_alias( + &self, + definition: Definition<'db>, + alias: ExpressionNodeKey, + unbound_is_terminal: bool, + ) -> bool { + let db = self.db; + match definition.kind(db) { + DefinitionKind::LoopHeader(_) => { + let loop_header = loop_header_reachability(db, definition); + (unbound_is_terminal || loop_header.deleted_reachability.is_always_false()) + && loop_header.reachable_bindings.iter().all(|binding| { + self.binding_assigns_alias(binding.definition, alias, unbound_is_terminal) + }) + } + kind => kind + .value(self.module) + .is_some_and(|value| ExpressionNodeKey::from(value) == alias), + } + } + fn merge_optional_constraints_and( left: Option>, right: Option>, @@ -2484,18 +2654,20 @@ impl<'db> PatternSuccessAnalyzer<'db> { kind: &ClassPatternPredicateKind<'db>, context: &ClassPatternContext<'db>, original_subject_ty: Type<'db>, - filtering_subject_ty: Type<'db>, subject_ty: Type<'db>, ) -> Option>> { let db = self.db; let subject_is_final = subject_ty .nominal_class(db, &self.env) .is_some_and(|class| class.is_final(db)); - let specialized_pattern_class = if context.positional_sources.is_empty() - && kind.keywords.is_empty() + let use_generic_filtering = self.use_generic_filtering(); + // Strict narrowing already includes the pattern's constraints in the subject type. + // Its full member type must not be replaced by one specialization from an intersection. + let specialized_pattern_class = if !use_generic_filtering + || (context.positional_sources.is_empty() && kind.keywords.is_empty()) { None - } else if self.use_generic_filtering() { + } else { context .class .filter(|pattern_class| pattern_class.generic_context(db).is_some()) @@ -2515,13 +2687,6 @@ impl<'db> PatternSuccessAnalyzer<'db> { } }) }) - } else { - context - .class - .zip(filtering_subject_ty.nominal_class(db, &self.env)) - .and_then(|(pattern_class, subject_class)| { - self.specialize_pattern_class_for_subject(pattern_class, subject_class) - }) }; let member_type = |name: &Name| { let original_member_ty = original_subject_ty @@ -2540,7 +2705,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { && !specialized_member_ty.is_unknown() { member_ty = Some(specialized_member_ty); - } else if let Some(pattern_class) = context.class + } else if use_generic_filtering + && let Some(pattern_class) = context.class && pattern_class .generic_context(db) .and_then(|generic_context| { @@ -2626,84 +2792,6 @@ impl<'db> PatternSuccessAnalyzer<'db> { .collect() } - /// Infer an exact specialization of a generic pattern subclass from a specialized base-class - /// subject. - /// - /// This intentionally handles only the case where every pattern-class type variable has one - /// exact solution. Variant base classes and pattern classes with unconstrained parameters keep - /// the existing conservative member type. - /// - /// ```python - /// class Base[T]: - /// value: T - /// - /// class Child[T](Base[T]): - /// item: T - /// - /// def f(value: Base[int]) -> None: - /// match value: - /// case Child(item=item): - /// reveal_type(item) # int - /// ``` - fn specialize_pattern_class_for_subject( - &self, - pattern_class: ClassLiteral<'db>, - subject_class: ClassType<'db>, - ) -> Option> { - let db = self.db; - let generic_context = pattern_class.generic_context(db)?; - let pattern_base = pattern_class - .identity_specialization(db) - .iter_mro(db) - .filter_map(ClassBase::into_class) - .find(|base| base.class_literal(db) == subject_class.class_literal(db))?; - - let constraints = ConstraintSetBuilder::new(); - let solutions = Type::instance(db, &self.env, pattern_base) - .assignable_solutions_with_inferable( - db, - &self.env, - Type::instance(db, &self.env, subject_class), - generic_context.inferable_typevars(db), - ) - .solve_with(|variance, path_bound| { - let Some(lower) = path_bound.lower else { - return Ok(None); - }; - if variance != TypeVarVariance::Invariant - || path_bound.upper.materialize_exact(db, &self.env) != lower - { - return Ok(None); - } - PathBounds::default_solve(db, &self.env, &constraints, path_bound) - }); - let Solutions::Constrained(solutions) = solutions else { - return None; - }; - let [solution] = solutions.as_slice() else { - return None; - }; - - let typevars = generic_context.variables(db); - let types = typevars - .clone() - .map(|typevar| { - solution - .iter() - .find(|binding| binding.bound_typevar == typevar) - .map(|binding| binding.solution) - }) - .collect::>>()?; - if types.iter().any(|ty| { - typevars.clone().any(|typevar| { - ty.references_typevar(db, &self.env, typevar.typevar(db).identity(db)) - }) - }) { - return None; - } - Some(pattern_class.apply_specialization(db, |_| generic_context.specialize(db, types))) - } - fn class_pattern_contexts( &self, kind: &ClassPatternPredicateKind<'db>, @@ -2759,7 +2847,6 @@ impl<'db> PatternSuccessAnalyzer<'db> { kind, context, original_subject_ty, - subject_ty, narrowed_subject_ty, )?; Some((narrowed_subject_ty, arguments)) @@ -3290,9 +3377,32 @@ impl<'db> PatternSuccessAnalyzer<'db> { }, )) }); - let mut unpacker = TupleUnpacker::new(db, &self.env, target_len); - unpacker.unpack_tuple(tuple.as_ref()).ok()?; - Some((narrowed_subject_ty, unpacker.into_types().collect())) + let unpacked = tuple + .unpack( + target_len, + |segment| vec![segment.element_type(db)], + |elements| { + UnionType::from_elements_leave_aliases(db, &self.env, elements.iter().copied()) + }, + ) + .ok()?; + let element_types = unpacked + .into_all_elements_with_kind() + .map(|element| match element { + // `case [1, *rest]:` still needs the precise first element to select matching + // tuple union members. Only the fresh list for `rest` undergoes promotion. + TupleElement::Variable(elements) => { + collected_list_type(db, &self.env, elements.into_iter().map(|ty| (ty, None))) + } + TupleElement::Fixed(ty) | TupleElement::Prefix(ty) | TupleElement::Suffix(ty) => { + UnionBuilder::new(db, &self.env) + .add(ty) + .try_build() + .unwrap_or_else(Type::unknown) + } + }) + .collect(); + Some((narrowed_subject_ty, element_types)) } fn analyze_matched_subject_arms( @@ -3503,8 +3613,12 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { fn scope(&self) -> ScopeId<'db> { let db = self.db; match self.predicate { - PredicateNode::Expression(expression) => expression.scope(db), + PredicateNode::Expression(expression) + | PredicateNode::Condition(expression) + | PredicateNode::ChainedComparisonCondition(expression) + | PredicateNode::ContextManagerSuppresses { expression, .. } => expression.scope(db), PredicateNode::Pattern(pattern) => pattern.scope(db), + PredicateNode::FinallyNormalPathImpossible { scope, .. } => scope, PredicateNode::OrPatternAlternative(scope) => scope, PredicateNode::SubjectElementPattern(subject_element) => { subject_element.pattern.scope(db) @@ -3639,26 +3753,26 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } } - /// Filter a type based on an equality or inequality comparison against an exact length. + /// Filter a type based on a comparison against an integer length. /// - /// Exact tuple types are specialized to the observed length. Other types that encode their - /// possible lengths are filtered. Unknown-length types are left unchanged because persisting - /// an observed length would become stale after mutation. - fn narrow_type_by_exact_len( + /// Equality comparisons specialize exact tuple types to the observed length. Other comparisons + /// filter types that encode their possible lengths. Unknown-length types are left unchanged + /// because persisting an observed length would become stale after mutation. + fn narrow_type_by_len_comparison( db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>, - length: usize, - is_equality: bool, + length: i64, + comparison: LengthComparison, ) -> Type<'db> { let resolved = ty.resolve_type_alias(db); let narrowed = match resolved { Type::Union(union) => union.map(db, env, |element| { - Self::narrow_type_by_exact_len(db, env, *element, length, is_equality) + Self::narrow_type_by_len_comparison(db, env, *element, length, comparison) }), Type::Intersection(intersection) => intersection.map_positive(db, env, |element| { - Self::narrow_type_by_exact_len(db, env, *element, length, is_equality) + Self::narrow_type_by_len_comparison(db, env, *element, length, comparison) }), Type::TypeVar(typevar) => { let Some(bound_or_constraints) = typevar.typevar(db).bound_or_constraints(db, env) @@ -3669,19 +3783,19 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let upper_bound = bound_or_constraints.as_type(db, env); let narrowed_upper_bound = match bound_or_constraints { TypeVarBoundOrConstraints::UpperBound(bound) => { - Self::narrow_type_by_exact_len(db, env, bound, length, is_equality) + Self::narrow_type_by_len_comparison(db, env, bound, length, comparison) } TypeVarBoundOrConstraints::Constraints(constraints) => { UnionType::from_elements( db, env, constraints.elements(db).iter().map(|constraint| { - Self::narrow_type_by_exact_len( + Self::narrow_type_by_len_comparison( db, env, *constraint, length, - is_equality, + comparison, ) }), ) @@ -3695,9 +3809,23 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } } _ => { - if is_equality && let Some(tuple) = resolved.exact_tuple_instance_spec(db) { + if comparison.is_equality() + && let Ok(length) = usize::try_from(length) + && let Some(tuple) = resolved.exact_tuple_instance_spec(db) + { match tuple.resize(db, env, TupleLength::Fixed(length)) { - Ok(tuple) => Type::tuple(TupleType::new(db, env, &tuple)), + Ok(resized) => { + let narrowed = Type::tuple(TupleType::new(db, env, &resized)); + if let TupleSpec::Variable(variable) = tuple.as_ref() + && variable.variable().typevartuple().is_some() + { + // Resizing forgets which TypeVarTuple these elements came from. + // Retain that identity alongside the observed length and elements. + IntersectionType::from_two_elements(db, env, resolved, narrowed) + } else { + narrowed + } + } Err(_) => Type::Never, } } else { @@ -3705,8 +3833,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let satisfies_comparison = |length_type: Type<'db>| { length_type .as_int_literal() - .and_then(|actual| usize::try_from(actual).ok()) - .is_some_and(|actual| (actual == length) == is_equality) + .is_some_and(|actual| comparison.matches(i128::from(actual), length)) }; let comparison_possible = resolved .len(db, env) @@ -3719,19 +3846,13 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { }) .or_else(|| { tuple_length - .and_then(TupleLength::into_fixed_length) - .map(|actual| (actual == length) == is_equality) + .map(|actual| comparison.matches_tuple_length(actual, length)) }); - match comparison_possible { - Some(false) => Type::Never, - None if is_equality - && tuple_length - .is_some_and(|tuple_length| length < tuple_length.minimum()) => - { - Type::Never - } - _ => resolved, + if comparison_possible == Some(false) { + Type::Never + } else { + resolved } } } @@ -4172,6 +4293,60 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } } + if let [op] = &**ops + && let Some(comparison) = LengthComparison::from_op(*op, is_positive) + { + let mut narrow_len_call = + |call: &ast::ExprCall, length_type: Type<'db>, comparison: LengthComparison| { + let Type::FunctionLiteral(function_type) = + inference.expression_type(&*call.func) + else { + return; + }; + if function_type.known(db) != Some(KnownFunction::Len) + || !call.arguments.keywords.is_empty() + { + return; + } + let [arg] = &*call.arguments.args else { + return; + }; + let Some(length) = length_type.resolve_type_alias(db).as_int_like_literal() + else { + return; + }; + let Some(target) = PlaceExpr::try_from_expr(arg) else { + return; + }; + + let arg_type = inference.expression_type(arg); + let narrowed = Self::narrow_type_by_len_comparison( + db, &self.env, arg_type, length, comparison, + ); + if narrowed != arg_type { + insert_narrowing_constraint( + &mut constraints, + self.expect_place(&target), + NarrowingConstraint::replacement(narrowed), + ); + } + }; + + // E.g., `len(items) == 2` + if let ast::Expr::Call(call) = left.expression_value() { + narrow_len_call(call, inference.expression_type(&comparators[0]), comparison); + } + + // E.g., `2 == len(items)` + if let ast::Expr::Call(call) = comparators[0].expression_value() { + narrow_len_call( + call, + inference.expression_type(&**left), + comparison.reflected(), + ); + } + } + // Narrow tagged unions of `TypedDict`s with `Literal` keys, for example: // // class Foo(TypedDict): @@ -4189,52 +4364,6 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { // For `!=`, we use equality semantics on the `else` branch (is_positive=false). let is_equality = is_positive == (ops[0] == ast::CmpOp::Eq); - let mut narrow_len_call = |call: &ast::ExprCall, length_type: Type<'db>| { - let Type::FunctionLiteral(function_type) = inference.expression_type(&*call.func) - else { - return; - }; - if function_type.known(db) != Some(KnownFunction::Len) - || !call.arguments.keywords.is_empty() - { - return; - } - let [arg] = &*call.arguments.args else { - return; - }; - let Some(length_literal) = length_type.resolve_type_alias(db).as_int_like_literal() - else { - return; - }; - let Ok(length) = usize::try_from(length_literal) else { - return; - }; - let Some(target) = PlaceExpr::try_from_expr(arg) else { - return; - }; - - let arg_type = inference.expression_type(arg); - let narrowed = - Self::narrow_type_by_exact_len(db, &self.env, arg_type, length, is_equality); - if narrowed != arg_type { - insert_narrowing_constraint( - &mut constraints, - self.expect_place(&target), - NarrowingConstraint::replacement(narrowed), - ); - } - }; - - // E.g., `len(items) == 2` - if let ast::Expr::Call(call) = left.expression_value() { - narrow_len_call(call, inference.expression_type(&comparators[0])); - } - - // E.g., `2 == len(items)` - if let ast::Expr::Call(call) = comparators[0].expression_value() { - narrow_len_call(call, inference.expression_type(&**left)); - } - let mut narrow_subscript = |subscript: &ast::ExprSubscript, other_type: Type<'db>| { let value_type = inference.expression_type(&*subscript.value); let slice_type = inference.expression_type(&*subscript.slice); @@ -4400,6 +4529,18 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } } + // Expression-inference cycles can replace every subexpression's type, including literals, + // with a cycle placeholder. This can prevent comparisons against `None` from narrowing + // recursively inferred attributes. Other literals can encounter the same issue, but a + // general solution would require broader changes to cycle recovery. For now, intentionally + // preserve only `None`, whose type can be recovered directly. + let expression_type = |expr: &ast::Expr, env: &ProgramEnvironment<'db>| { + if expr.is_none_literal_expr() { + Type::none(db, env) + } else { + inference.expression_type(expr) + } + }; let mut last_rhs_ty: Option = None; // basedpython: in `.by` files, the `is`/`is not` keyword form @@ -4415,8 +4556,8 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { .then(|| ruff_db::source::source_text(self.db, file)); for (op, (left, right)) in std::iter::zip(&**ops, comparator_tuples) { - let lhs_ty = last_rhs_ty.unwrap_or_else(|| inference.expression_type(left)); - let rhs_ty = inference.expression_type(right); + let lhs_ty = last_rhs_ty.unwrap_or_else(|| expression_type(left, &self.env)); + let rhs_ty = expression_type(right, &self.env); let lhs_narrowing_rhs_ty = if matches!(op, ast::CmpOp::In | ast::CmpOp::NotIn) { self.inline_membership_rhs_type(right, inference) .unwrap_or(rhs_ty) @@ -5419,7 +5560,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { .ignore_possibly_undefined() .is_none_or(|attribute_type| match (comparison, is_positive) { (NominalAttributeComparison::Equality, true) => { - !is_supported_tag_literal(attribute_type) + !is_supported_tag_literal_or_union(db, attribute_type) || !attribute_type.is_disjoint_from(db, &self.env, rhs_type) } (NominalAttributeComparison::Equality, false) => { @@ -5533,6 +5674,7 @@ fn is_or_contains_typeddict<'db>( | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::LiteralValue(_) @@ -5639,6 +5781,19 @@ fn is_supported_tag_literal(ty: Type) -> bool { ) } +/// Return true if the given type is a literal type with one or more supported literal values, +/// e.g. `Literal[1, "A", "B"]`. These types are represented as `Type::Union(_)`. +fn is_supported_tag_literal_or_union(db: &dyn Db, ty: Type) -> bool { + match ty { + Type::Union(union) => union + .elements(db) + .iter() + .copied() + .all(is_supported_tag_literal), + _ => is_supported_tag_literal(ty), + } +} + // Return true if the given type is a `TypedDict` whose `field_name` field has a supported tag literal // type, or a union in which all elements that are `TypedDict`s have a supported tag literal type // for that field, or an intersection in which all positive elements that are `TypedDict`s have a @@ -5654,7 +5809,7 @@ fn all_matching_typeddict_fields_have_literal_types<'db>( typeddict .items(db) .get(field_name) - .is_none_or(|field| is_supported_tag_literal(field.declared_ty)) + .is_none_or(|field| is_supported_tag_literal_or_union(db, field.declared_ty)) }; match ty { @@ -5733,6 +5888,7 @@ fn all_matching_typeddict_fields_have_literal_types<'db>( | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::LiteralValue(_) @@ -5781,7 +5937,7 @@ fn all_matching_tuple_elements_have_literal_types<'db>( union.elements(db).iter().all(|elem| { elem.tuple_instance_spec(db, env) .and_then(|spec| spec.py_index(db, env, index).ok()) - .is_none_or(is_supported_tag_literal) + .is_none_or(|ty| is_supported_tag_literal_or_union(db, ty)) }) } @@ -5803,14 +5959,6 @@ impl<'db> NarrowingEvaluatorExtension<'db> for NarrowingEvaluator<'_, 'db> { base_type: Type<'db>, place: ScopedPlaceId, ) -> Type<'db> { - narrow_type_by_constraint( - db, - env, - self.narrowing_constraints(), - self.predicates(), - self.constraint(), - base_type, - place, - ) + narrow_type_by_constraint(db, env, self, base_type, place) } } diff --git a/crates/ty_python_semantic/src/types/newtype.rs b/crates/ty_python_semantic/src/types/newtype.rs index 40e7703fc3..59267786cd 100644 --- a/crates/ty_python_semantic/src/types/newtype.rs +++ b/crates/ty_python_semantic/src/types/newtype.rs @@ -263,7 +263,11 @@ pub(crate) fn walk_newtype_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Si let base = if visitor.should_visit_lazy_type_attributes() { Some(newtype.base(db)) } else { - newtype.eager_base(db) + let base = newtype.eager_base(db); + if base.is_none() { + visitor.notify_skipped_lazy_type_attributes(); + } + base }; if let Some(base) = base { visitor.visit_type(db, base.instance_type(db, visitor.program_environment())); diff --git a/crates/ty_python_semantic/src/types/overlapping.rs b/crates/ty_python_semantic/src/types/overlapping.rs index 227f8d3633..b54da9a08d 100644 --- a/crates/ty_python_semantic/src/types/overlapping.rs +++ b/crates/ty_python_semantic/src/types/overlapping.rs @@ -18,8 +18,8 @@ //! `SafeVariance`: they share the two-faced structure and differ only in the //! call-site relation (overlap vs. subtype) -use super::variance::VarianceInferable; -use super::{BoundTypeVarIdentity, Type, TypeVarVariance, visitor}; +use super::variance::{VarianceInferable, VarianceTerm}; +use super::{BoundTypeVarIdentity, Type, visitor}; use crate::Db; use crate::types::ProgramEnvironment; @@ -88,7 +88,7 @@ impl<'db> VarianceInferable<'db> for OverlappingType<'db> { _db: &'db dyn Db, _env: &ProgramEnvironment<'db>, _typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - TypeVarVariance::Bivariant + ) -> VarianceTerm<'db> { + VarianceTerm::BIVARIANT } } diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 78f74a9f65..b06406ecf0 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -17,7 +17,6 @@ use crate::{ Db, ProgramEnvironment, lint::LintId, place::{DefinedPlace, Place, PlaceAndQualifiers, TypeOrigin}, - reachability::ReachabilityConstraintsExtension, types::{ CallableType, ClassBase, ClassLiteral, ClassType, IntersectionType, KnownClass, Parameter, Parameters, Signature, StaticClassLiteral, Type, TypeContext, TypeQualifiers, @@ -35,8 +34,9 @@ use crate::{ }, enums::{EnumMetadata, enum_metadata, is_enum_class_by_inheritance}, function::{FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral}, - infer::infer_definition_types, - list_members::{Member, MemberWithDefinition, all_end_of_scope_members}, + list_members::{ + Member, MemberWithDefinition, all_end_of_scope_members, extract_underlying_functions, + }, tuple::Tuple, }, }; @@ -1076,7 +1076,7 @@ fn method_override_types<'db>( let (subclass_type, superclass_type) = match (subclass_type, superclass_type) { (Type::BoundMethod(subclass_method), Type::BoundMethod(superclass_method)) => { let superclass_signature = superclass_method.function(db).signature(db); - let receiver = match superclass_signature.overloads.as_slice() { + let explicit_receiver = match superclass_signature.overloads.as_slice() { [signature] => signature .parameters() .get(0) @@ -1088,29 +1088,33 @@ fn method_override_types<'db>( _ => None, }; - receiver.map_or((subclass_type, superclass_type), |receiver| { - let typing_self_type = subclass_method.typing_self_type(db); - let receiver = receiver.bind_self_typevars(db, env, typing_self_type); - let receiver = IntersectionType::from_elements( - db, - env, - [subclass_method.self_instance(db), receiver], - ); - ( - Type::Callable(subclass_method.into_callable_type_with_receiver( + let typing_self_type = subclass_method.typing_self_type(db); + let receiver = + explicit_receiver.map_or(subclass_method.self_instance(db), |receiver| { + let receiver = receiver.bind_self_typevars(db, env, typing_self_type); + IntersectionType::from_elements( db, env, - receiver, - typing_self_type, - )), - Type::Callable(superclass_method.into_callable_type_with_receiver( - db, - env, - receiver, - typing_self_type, - )), - ) - }) + [subclass_method.self_instance(db), receiver], + ) + }); + + // Both signatures describe calls on the subclass. In particular, inherited `Self` + // annotations refer to the subclass even when the receiver is implicitly annotated. + ( + Type::Callable(subclass_method.into_callable_type_with_receiver( + db, + env, + receiver, + typing_self_type, + )), + Type::Callable(superclass_method.into_callable_type_with_receiver( + db, + env, + receiver, + typing_self_type, + )), + ) } _ => (subclass_type, superclass_type), }; @@ -1729,143 +1733,25 @@ fn missing_override_definition<'db>( .find(|definition| !definition.focus_definition_has_override_decorator) } -/// Extract function definitions that can carry an `@override` decorator for the class member -/// currently being checked. -/// -/// Use functions recovered from the member type when possible, because this preserves overload and -/// property accessor handling. If decorators replaced some subclass definitions with functions from -/// another class or file, recover the local function type from the binding definition so overload -/// metadata is still preserved. fn extract_local_override_definitions<'db>( context: &InferContext<'db, '_>, member: &Member<'db>, subclass_scope: ScopeId<'db>, ) -> smallvec::SmallVec<[LocalOverrideDefinition; 1]> { - let db = context.db(); - let in_stub = context.in_stub(); - let module = context.module(); - let member_functions = - extract_member_functions_from_type(db, member.ty, &member.name, subclass_scope); - let mut candidates = smallvec::smallvec![]; - let mut seen_function_types = smallvec::SmallVec::<[FunctionType<'db>; 1]>::new(); - for definition in end_of_scope_function_definitions(db, subclass_scope, &member.name) { - let function = member_functions - .iter() - .copied() - .find(|function| function.contains_definition(db, definition)) - .or_else(|| infer_definition_types(db, definition).function_type(definition)); - - let Some(function) = function else { - continue; - }; - - if seen_function_types.contains(&function) { - continue; - } - candidates.push(LocalOverrideDefinition::from_function( - db, function, in_stub, module, - )); - seen_function_types.push(function); - } - - // A property with a setter can keep the getter in the member type even though the setter is the - // end-of-scope binding. Preserve any type-derived functions that the syntactic pass did not see. - for function in member_functions { - if !seen_function_types.contains(&function) { - candidates.push(LocalOverrideDefinition::from_function( - db, function, in_stub, module, - )); - } - } - - candidates -} - -/// Return reachable function definitions that bind `member_name` at the end of `subclass_scope`. -fn end_of_scope_function_definitions<'db>( - db: &'db dyn Db, - subclass_scope: ScopeId<'db>, - member_name: &Name, -) -> smallvec::SmallVec<[Definition<'db>; 1]> { - let table = place_table(db, subclass_scope); - let Some(symbol_id) = table.symbol_id(member_name) else { - return smallvec::smallvec![]; - }; - - let use_def = use_def_map(db, subclass_scope); - let predicates = use_def.predicates(); - let reachability_constraints = use_def.reachability_constraints(); - use_def - .end_of_scope_symbol_bindings(symbol_id) - .filter_map(|binding| { - let definition = binding.binding.definition()?; - let reachability = - reachability_constraints.evaluate(db, predicates, binding.reachability_constraint); - if reachability.is_always_false() || !definition.kind(db).is_function_def() { - return None; - } - - Some(definition) - }) - .collect() -} - -/// Extract functions represented by a member type that belong to the member currently being -/// checked. Decorators can replace a function with a function from another class or file, so -/// callers must not use unfiltered functions as diagnostic anchors. -/// -/// The same is true for property accessors: a setter or deleter can reuse a getter defined by -/// another class, but override diagnostics should only point at accessors defined by the subclass -/// member under analysis. -fn extract_member_functions_from_type<'db>( - db: &'db dyn Db, - ty: Type<'db>, - member_name: &Name, - member_scope: ScopeId<'db>, -) -> smallvec::SmallVec<[FunctionType<'db>; 1]> { - let mut functions = smallvec::SmallVec::<[FunctionType<'db>; 1]>::new(); - let mut types: smallvec::SmallVec<[Type<'db>; 1]> = smallvec::smallvec![ty]; - let mut index = 0; - - while let Some(ty) = types.get(index).copied() { - index += 1; - match ty { - Type::PropertyInstance(property) => { - for accessor in [ - property.getter(db), - property.setter(db), - property.deleter(db), - ] - .into_iter() - .flatten() - { - functions.extend(extract_underlying_functions(db, accessor)); - } - } - Type::Union(union) => { - types.extend(union.elements(db).iter().copied()); - } - _ => functions.extend(extract_underlying_functions(db, ty)), - } - } - - functions + member + .local_functions(context.db(), subclass_scope) .into_iter() - .filter(|function| is_local_member_function(db, *function, member_name, member_scope)) + .map(|function| { + LocalOverrideDefinition::from_function( + context.db(), + function, + context.in_stub(), + context.module(), + ) + }) .collect() } -fn is_local_member_function<'db>( - db: &'db dyn Db, - function: FunctionType<'db>, - member_name: &Name, - member_scope: ScopeId<'db>, -) -> bool { - function.python_file(db) == member_scope.python_file(db) - && function.definition(db).scope(db) == member_scope - && function.name(db) == member_name -} - fn overriding_definition<'db>( db: &'db dyn Db, function: FunctionType<'db>, @@ -1879,30 +1765,6 @@ fn overriding_definition<'db>( } } -/// Extract callable functions represented by a type. -/// These may be defined in files other than the one being checked. -fn extract_underlying_functions<'db>( - db: &'db dyn Db, - ty: Type<'db>, -) -> smallvec::SmallVec<[FunctionType<'db>; 1]> { - match ty { - Type::FunctionLiteral(function) => smallvec::smallvec_inline![function], - Type::BoundMethod(method) => smallvec::smallvec_inline![method.function(db)], - Type::PropertyInstance(property) => property.getter(db).map_or_else( - || smallvec::smallvec![], - |getter| extract_underlying_functions(db, getter), - ), - Type::Union(union) => { - let mut functions = smallvec::smallvec![]; - for member in union.elements(db) { - functions.extend(extract_underlying_functions(db, *member)); - } - functions - } - _ => smallvec::smallvec![], - } -} - fn check_post_init_signature<'db>( context: &InferContext<'db, '_>, configuration: OverrideRulesConfig, diff --git a/crates/ty_python_semantic/src/types/property_tests.rs b/crates/ty_python_semantic/src/types/property_tests.rs index 04e9a744f0..5c235fa80f 100644 --- a/crates/ty_python_semantic/src/types/property_tests.rs +++ b/crates/ty_python_semantic/src/types/property_tests.rs @@ -39,10 +39,10 @@ use type_generation::{intersection, union}; /// where `t1`, `t2`, ..., `tn` are identifiers that represent arbitrary types, and `` /// is an expression using these identifiers. macro_rules! type_property_test { - ($test_name:ident, $db:ident, $env:ident, forall types $($types:ident),+ . $property:expr) => { + (@impl $test_name:ident, $db:ident, $env:ident, $input_type:ty, $($types:ident),+ . $property:expr) => { #[quickcheck_macros::quickcheck] #[ignore] - fn $test_name($($types: Ty),+) -> bool { + fn $test_name($($types: $input_type),+) -> bool { let $db = &get_cached_db(); let $env = &$db.program_environment(); $(let $types = $types.into_type($db, $env);)+ @@ -57,22 +57,12 @@ macro_rules! type_property_test { } }; - ($test_name:ident, $db:ident, $env:ident, forall fully_static_types $($types:ident),+ . $property:expr) => { - #[quickcheck_macros::quickcheck] - #[ignore] - fn $test_name($($types: FullyStaticTy),+) -> bool { - let $db = &get_cached_db(); - let $env = &$db.program_environment(); - $(let $types = $types.into_type($db, $env);)+ - let result = $property; - - if !result { - println!("\nFailing types were:"); - $(println!("{}", $types.display($db, $env));)+ - } + ($test_name:ident, $db:ident, $env:ident, forall types $($types:ident),+ . $property:expr) => { + type_property_test!(@impl $test_name, $db, $env, Ty, $($types),+ . $property); + }; - result - } + ($test_name:ident, $db:ident, $env:ident, forall fully_static_types $($types:ident),+ . $property:expr) => { + type_property_test!(@impl $test_name, $db, $env, FullyStaticTy, $($types),+ . $property); }; // A property test with a logical implication. diff --git a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs index 720710a510..52ca01c115 100644 --- a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs +++ b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs @@ -1,3 +1,5 @@ +use std::debug_assert_matches; + use crate::Db; use crate::place::{DefinedPlace, Place, builtins_symbol, global_symbol, known_module_symbol}; use crate::types::enums::is_single_member_enum; @@ -54,6 +56,7 @@ pub(crate) enum Ty { neg: Vec, }, FixedLengthTuple(Vec), + #[expect(dead_code, reason = "Tuple generation is temporarily disabled")] VariableLengthTuple(Vec, Box, Vec), SubclassOfAny, SubclassOfBuiltinClass(&'static str), @@ -219,10 +222,12 @@ fn create_bound_method<'db>( builtins_class: Type<'db>, ) -> Type<'db> { let env = ProgramEnvironment::from_program(program); + let self_instance = builtins_class.to_instance_approximation(db, &env).unwrap(); Type::BoundMethod(BoundMethodType::new( db, function.expect_function_literal(), - builtins_class.to_instance_approximation(db, &env).unwrap(), + self_instance, + self_instance, )) } @@ -258,8 +263,10 @@ impl Ty { let ty = known_module_symbol(db, env, KnownModule::Dataclasses, "MISSING") .place .expect_type(); - debug_assert!( - matches!(ty, Type::NominalInstance(instance) if is_single_member_enum(db, instance.class_literal(db, env))) + debug_assert_matches!( + ty, + Type::NominalInstance(instance) + if is_single_member_enum(db, instance.class_literal(db, env)) ); ty } @@ -388,6 +395,28 @@ fn newtype_instance<'db>(db: &'db dyn Db, env: &ProgramEnvironment<'db>, name: & } } +/// A `QuickCheck` input generated without dynamic components, including in nested unions, tuples, +/// and callables. +/// +/// Some type properties, such as reflexivity of subtyping, only hold for fully static types. It is +/// tempting to generate an arbitrary [`Ty`] and express such a property as an implication: +/// +/// ```text +/// t.is_fully_static(db, env) => t.is_subtype_of(db, env, t) +/// ``` +/// +/// However, the property-test macro implements implications as `!premise || conclusion`. Every +/// non-static input therefore counts as a successful `QuickCheck` iteration even though the property +/// itself was never checked. If `QUICKCHECK_TESTS=100000`, the test can report 100,000 successful +/// iterations while checking reflexivity for far fewer types. Properties with two fully static +/// inputs lose even more coverage because both inputs must satisfy the premise. +/// +/// Filtering also disproportionately removes nested unions, tuples, and callables: each additional +/// component gives the generated type another opportunity to contain a dynamic type. Generating +/// fully static components directly ensures that every `QuickCheck` iteration checks the property +/// and that complex types remain represented alongside simple ones. +/// +/// See for the discussion of this coverage problem. #[derive(Debug, Clone, PartialEq)] pub(crate) struct FullyStaticTy(Ty); @@ -397,94 +426,116 @@ impl FullyStaticTy { db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Type<'db> { - self.0.into_type(db, env) + let ty = self.0.into_type(db, env); + assert!( + ty.is_fully_static(db, env), + "FullyStaticTy generated a non-static type: {}", + ty.display(db, env), + ); + ty } } +// A single draw across both groups keeps unrestricted candidates equally likely without +// allocating a combined list or maintaining a positional boundary between the groups. +macro_rules! choose_core_type { + ( + $generator:expr, + $fully_static:expr, + dynamic_types: [$($dynamic:expr),+ $(,)?], + fully_static_types: [$($static:expr),+ $(,)?] $(,)? + ) => {{ + if $fully_static { + $generator.choose(&[$($static),+]).unwrap().clone() + } else { + $generator + .choose(&[$($dynamic),+, $($static),+]) + .unwrap() + .clone() + } + }}; +} + fn arbitrary_core_type(g: &mut Gen, fully_static: bool) -> Ty { // We could select a random integer here, but this would make it much less // likely to explore interesting edge cases: let int_lit = Ty::IntLiteral(*g.choose(&[-2, -1, 0, 1, 2]).unwrap()); let bool_lit = Ty::BooleanLiteral(bool::arbitrary(g)); - // Update this if new non-fully-static types are added below. - let fully_static_index = 8; - let types = &[ - Ty::Any, - Ty::Unknown, - Ty::Divergent, - Ty::TopDivergent, - Ty::BottomDivergent, - Ty::SubclassOfAny, - Ty::UnittestMockLiteral, - Ty::UnittestMockInstance, - // Add fully static types below, dynamic types above. - // Update `fully_static_index` above if adding new dynamic types! - Ty::Never, - Ty::None, - int_lit, - bool_lit, - Ty::StringLiteral(""), - Ty::StringLiteral("a"), - Ty::LiteralString, - Ty::BytesLiteral(""), - Ty::BytesLiteral("\x00"), - Ty::EnumLiteral("safe"), - Ty::EnumLiteral("unsafe"), - Ty::EnumLiteral("unknown"), - Ty::SingleMemberEnumLiteral, - Ty::KnownClassInstance(KnownClass::Object), - Ty::KnownClassInstance(KnownClass::Str), - Ty::KnownClassInstance(KnownClass::Int), - Ty::KnownClassInstance(KnownClass::Float), - Ty::KnownClassInstance(KnownClass::Complex), - Ty::KnownClassInstance(KnownClass::Bool), - Ty::KnownClassInstance(KnownClass::FunctionType), - Ty::KnownClassInstance(KnownClass::SpecialForm), - Ty::KnownClassInstance(KnownClass::TypeVar), - Ty::KnownClassInstance(KnownClass::TypeAliasType), - Ty::KnownClassInstance(KnownClass::NoDefaultType), - Ty::TypingLiteral, - Ty::BuiltinClassLiteral("str"), - Ty::BuiltinClassLiteral("int"), - Ty::BuiltinClassLiteral("bool"), - Ty::BuiltinClassLiteral("object"), - Ty::BuiltinInstance("type"), - Ty::AbcInstance("ABC"), - Ty::AbcInstance("ABCMeta"), - Ty::SubclassOfBuiltinClass("object"), - Ty::SubclassOfBuiltinClass("str"), - Ty::SubclassOfBuiltinClass("type"), - Ty::AbcClassLiteral("ABC"), - Ty::AbcClassLiteral("ABCMeta"), - Ty::SubclassOfAbcClass("ABC"), - Ty::SubclassOfAbcClass("ABCMeta"), - Ty::AlwaysTruthy, - Ty::AlwaysFalsy, - Ty::BuiltinsFunction("chr"), - Ty::BuiltinsFunction("ascii"), - Ty::BuiltinsBoundMethod { - class: "str", - method: "isascii", - }, - Ty::BuiltinsBoundMethod { - class: "int", - method: "bit_length", - }, - Ty::IntNewtypeInstance, - Ty::StrNewtypeInstance, - Ty::FloatNewtypeInstance, - Ty::ComplexNewtypeInstance, - Ty::SubNewTypeOfIntInstance, - Ty::SubSubNewTypeOfIntInstance, - Ty::SubNewTypeOfFloatInstance, - ]; - let types = if fully_static { - &types[fully_static_index..] - } else { - types - }; - g.choose(types).unwrap().clone() + choose_core_type!( + g, + fully_static, + dynamic_types: [ + Ty::Any, + Ty::Unknown, + Ty::Divergent, + Ty::SubclassOfAny, + Ty::UnittestMockInstance, + ], + fully_static_types: [ + Ty::Never, + Ty::TopDivergent, + Ty::BottomDivergent, + Ty::None, + int_lit, + bool_lit, + Ty::StringLiteral(""), + Ty::StringLiteral("a"), + Ty::LiteralString, + Ty::BytesLiteral(""), + Ty::BytesLiteral("\x00"), + Ty::EnumLiteral("safe"), + Ty::EnumLiteral("unsafe"), + Ty::EnumLiteral("unknown"), + Ty::SingleMemberEnumLiteral, + Ty::KnownClassInstance(KnownClass::Object), + Ty::KnownClassInstance(KnownClass::Str), + Ty::KnownClassInstance(KnownClass::Int), + Ty::KnownClassInstance(KnownClass::Float), + Ty::KnownClassInstance(KnownClass::Complex), + Ty::KnownClassInstance(KnownClass::Bool), + Ty::KnownClassInstance(KnownClass::FunctionType), + Ty::KnownClassInstance(KnownClass::SpecialForm), + Ty::KnownClassInstance(KnownClass::TypeVar), + Ty::KnownClassInstance(KnownClass::ExtensionsTypeAliasType), + Ty::KnownClassInstance(KnownClass::NoDefaultType), + Ty::TypingLiteral, + Ty::UnittestMockLiteral, + Ty::BuiltinClassLiteral("str"), + Ty::BuiltinClassLiteral("int"), + Ty::BuiltinClassLiteral("bool"), + Ty::BuiltinClassLiteral("object"), + Ty::BuiltinInstance("type"), + Ty::AbcInstance("ABC"), + Ty::AbcInstance("ABCMeta"), + Ty::SubclassOfBuiltinClass("object"), + Ty::SubclassOfBuiltinClass("str"), + Ty::SubclassOfBuiltinClass("type"), + Ty::AbcClassLiteral("ABC"), + Ty::AbcClassLiteral("ABCMeta"), + Ty::SubclassOfAbcClass("ABC"), + Ty::SubclassOfAbcClass("ABCMeta"), + Ty::AlwaysTruthy, + Ty::AlwaysFalsy, + Ty::BuiltinsFunction("chr"), + Ty::BuiltinsFunction("ascii"), + Ty::BuiltinsBoundMethod { + class: "str", + method: "isascii", + }, + Ty::BuiltinsBoundMethod { + class: "int", + method: "bit_length", + }, + Ty::IntNewtypeInstance, + Ty::StrNewtypeInstance, + Ty::FloatNewtypeInstance, + Ty::ComplexNewtypeInstance, + Ty::SubNewTypeOfIntInstance, + Ty::SubSubNewTypeOfIntInstance, + Ty::SubNewTypeOfFloatInstance, + ], + ) } /// Constructs an arbitrary type. @@ -498,28 +549,14 @@ fn arbitrary_type(g: &mut Gen, size: u32, fully_static: bool) -> Ty { if size == 0 { arbitrary_core_type(g, fully_static) } else { - match u32::arbitrary(g) % 6 { + match u32::arbitrary(g) % 4 { 0 => arbitrary_core_type(g, fully_static), 1 => Ty::Union( (0..*g.choose(&[2, 3]).unwrap()) .map(|_| arbitrary_type(g, size - 1, fully_static)) .collect(), ), - 2 => Ty::FixedLengthTuple( - (0..*g.choose(&[0, 1, 2]).unwrap()) - .map(|_| arbitrary_type(g, size - 1, fully_static)) - .collect(), - ), - 3 => Ty::VariableLengthTuple( - (0..*g.choose(&[0, 1, 2]).unwrap()) - .map(|_| arbitrary_type(g, size - 1, fully_static)) - .collect(), - Box::new(arbitrary_type(g, size - 1, fully_static)), - (0..*g.choose(&[0, 1, 2]).unwrap()) - .map(|_| arbitrary_type(g, size - 1, fully_static)) - .collect(), - ), - 4 => Ty::Intersection { + 2 => Ty::Intersection { pos: (0..*g.choose(&[0, 1, 2]).unwrap()) .map(|_| arbitrary_type(g, size - 1, fully_static)) .collect(), @@ -527,13 +564,29 @@ fn arbitrary_type(g: &mut Gen, size: u32, fully_static: bool) -> Ty { .map(|_| arbitrary_type(g, size - 1, fully_static)) .collect(), }, - 5 => Ty::Callable { + 3 => Ty::Callable { params: match u32::arbitrary(g) % 2 { 0 if !fully_static => CallableParams::GradualForm, _ => CallableParams::List(arbitrary_parameter_list(g, size, fully_static)), }, returns: Box::new(arbitrary_type(g, size - 1, fully_static)), }, + // TODO: Re-enable tuple types once they are fixed, and change the modulus back to 6. + // See https://github.com/astral-sh/ty/issues/4263. + // 4 => Ty::FixedLengthTuple( + // (0..*g.choose(&[0, 1, 2]).unwrap()) + // .map(|_| arbitrary_type(g, size - 1, fully_static)) + // .collect(), + // ), + // 5 => Ty::VariableLengthTuple( + // (0..*g.choose(&[0, 1, 2]).unwrap()) + // .map(|_| arbitrary_type(g, size - 1, fully_static)) + // .collect(), + // Box::new(arbitrary_type(g, size - 1, fully_static)), + // (0..*g.choose(&[0, 1, 2]).unwrap()) + // .map(|_| arbitrary_type(g, size - 1, fully_static)) + // .collect(), + // ), _ => unreachable!(), } } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 16a14f2b9f..bcf02e9098 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -8,16 +8,17 @@ use ruff_python_ast::name::Name; use rustc_hash::{FxHashMap, FxHashSet}; use crate::types::attribute_write::{ - AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, - FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, - ProtocolMemberWriteRequirement, attribute_write_requirement, + AttributeWriteRequirement, ClassAttributeWriteMember, DescriptorSetterDomain, + ExplicitAttributeWriteRequirement, FallbackAttributeWriteRequirement, + InstanceAttributeWriteMember, ProtocolMemberWriteRequirement, attribute_write_requirement, + descriptor_setter_domain, }; use crate::types::call::{CallArguments, CallDunderError}; use crate::types::deferred::is_symbolic_operand; use crate::types::instance::Protocol; use crate::types::overrides::{VariableKind, effective_superclass_variable_kind}; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; -use crate::types::visitor::any_over_type; +use crate::types::visitor::any_over_type_expanding_aliases; use crate::types::{TypeContext, UpcastPolicy}; use crate::{ Db, FxOrderSet, @@ -29,15 +30,16 @@ use crate::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, CallableType, ClassBase, ClassType, DeferredOperation, DeferredType, ErrorContext, FindLegacyTypeVarsVisitor, GenericAlias, GenericContext, - InstanceFallbackShadowsNonDataDescriptor, IntersectionType, KnownFunction, + InstanceFallbackShadowsNonDataDescriptor, KnownFunction, KnownInstanceType, MaterializationKind, MemberLookupKey, MemberLookupPolicy, Parameter, PropertyInstanceType, ProtocolInstanceType, SelfBinding, Signature, StaticClassLiteral, Type, TypeMapping, - TypeQualifiers, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, VarianceInferable, + TypeQualifiers, TypeVarVariance, UnionType, VarianceInferable, VarianceTerm, constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, - diagnostic::report_undeclared_protocol_member, + diagnostic::{INVALID_PROTOCOL, report_undeclared_protocol_member}, generics::Specialization, signatures::walk_signature, + variance::infer_protocol_variance, }, }; use ty_python_core::{definition::Definition, place::ScopedPlaceId, place_table, use_def_map}; @@ -81,6 +83,16 @@ impl<'db> ProtocolClass<'db> { cached_protocol_interface(db, *self) } + /// Structural variance inference currently excludes recursive type aliases and descriptor + /// writes whose accepted values cannot be represented by a single type, leaving no write + /// domain to use contravariantly. + /// + /// TODO: Support recursive type aliases and descriptor writes with unrepresentable domains. + pub(super) fn supports_variance_inference(self, db: &'db dyn Db) -> bool { + self.static_class_literal(db) + .is_some_and(|(class, _)| supports_protocol_variance_inference(db, class)) + } + /// Returns the interface before an invariant specialization is materialized. /// /// A materialized generic origin retains its specialization for nominal identity and display. @@ -289,6 +301,71 @@ impl<'db> ProtocolClass<'db> { } } + /// Validate explicitly declared type-variable variance against this protocol's interface. + pub(super) fn validate_type_parameter_variance(self, context: &InferContext) { + if !context.is_lint_enabled(&INVALID_PROTOCOL) { + return; + } + + let db = context.db(); + let Some((class, _)) = self.static_class_literal(db) else { + return; + }; + // TODO: Validate protocols with inherited members too. This single-base pattern skips + // subclasses such as `class Child(Base[T], Protocol[T])`, even when their declared + // variance disagrees with the inherited interface. + let [Type::KnownInstance(KnownInstanceType::SubscriptedProtocol(generic_context))] = + class.explicit_bases(db) + else { + return; + }; + if class.has_pep_695_type_params(db) || class.try_mro(db, None).is_err() { + return; + } + let env = ProgramEnvironment::from_scope(class.body_scope(db)); + if generic_context.variables(db).any(|typevar| { + typevar.is_typevartuple(db) || typevar.typevar(db).default_type(db, &env).is_some() + }) { + return; + } + let Some(protocol) = class.identity_specialization(db).into_protocol_class(db) else { + return; + }; + if !protocol.supports_variance_inference(db) { + return; + } + + for typevar in generic_context.variables(db) { + if typevar.is_paramspec(db) { + continue; + } + + let Some(declared_variance) = typevar.typevar(db).explicit_variance(db) else { + continue; + }; + + let inferred_variance = + match infer_protocol_variance(db, class, typevar.identity(db), declared_variance) { + TypeVarVariance::Bivariant => TypeVarVariance::Covariant, + variance => variance, + }; + + if inferred_variance == declared_variance { + continue; + } + + if let Some(builder) = context.report_lint(&INVALID_PROTOCOL, class.header_range(db)) { + builder.into_diagnostic(format_args!( + "Type variable `{}` in protocol `{}` should be {}, but is {}", + typevar.typevar(db).name(db), + self.name(db), + inferred_variance.as_str(), + declared_variance.as_str(), + )); + } + } + } + pub(super) fn apply_type_mapping_impl<'a>( self, db: &'db dyn Db, @@ -494,7 +571,11 @@ impl<'db> ProtocolInterfaceView<'db> { }) } - fn member_by_name<'a>(self, db: &'db dyn Db, name: &'a str) -> Option> { + pub(super) fn member_by_name<'a>( + self, + db: &'db dyn Db, + name: &'a str, + ) -> Option> { self.interface .inner(db) .get(name) @@ -616,31 +697,6 @@ impl<'db> ProtocolInterfaceView<'db> { }) } - /// Returns the callable signature exposed by instance access to a protocol's `__call__` - /// method. - /// - /// The callable is already in its instance-bound form, so callers must not bind it again. - pub(super) fn call_method( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ) -> Option> { - self.member_by_name(db, "__call__").and_then(|member| { - if !member.is_method() { - return None; - } - match member - .access(db, env, ProtocolMemberAccessMode::Instance) - .read - .and_then(|read| read.resolve(db, env)) - .map(ProtocolMemberType::ty) - { - Some(Type::Callable(callable)) => Some(callable), - _ => None, - } - }) - } - pub(super) fn instance_member( self, db: &'db dyn Db, @@ -933,12 +989,32 @@ impl<'db> ProtocolInterface<'db> { /// basedpython: whether `name` is an ordinary method member, the one kind whose access /// binds a receiver away. - pub(super) fn is_instance_method_member(self, db: &'db dyn Db, name: &str) -> bool { + fn is_instance_method_member(self, db: &'db dyn Db, name: &str) -> bool { ProtocolInterfaceView::new(self, None) .member_by_name(db, name) .is_some_and(|member| member.is_instance_method()) } + /// The exposed read and write types, with their positions in variance inference. + fn variance_types<'a>( + self, + db: &'db dyn Db, + env: &'a ProgramEnvironment<'db>, + ) -> impl Iterator, TypeVarVariance)> + 'a { + self.members(db).flat_map(move |member| { + let capabilities = member.capabilities(db, env); + // Instance methods are checked only through their bound instance signature. + let class_access = if member.is_instance_method() { + ProtocolMemberAccess::NONE + } else { + capabilities.class + }; + [capabilities.instance, class_access] + .into_iter() + .flat_map(|access| access.variances(db, env)) + }) + } + /// Returns whether `name` has an instance-write requirement of `type[T]`, where `T` belongs /// to `generic_context`. pub(super) fn includes_generic_writable_instance_member( @@ -1062,31 +1138,16 @@ impl<'db> ProtocolInterface<'db> { db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, ) -> impl std::fmt::Display + 'env { - struct ProtocolInterfaceDisplay<'env, 'db> { - db: &'db dyn Db, - env: &'env ProgramEnvironment<'db>, - interface: ProtocolInterface<'db>, - } - - impl std::fmt::Display for ProtocolInterfaceDisplay<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - f.write_char('{')?; - for (i, (name, data)) in self.interface.inner(db).iter().enumerate() { - write!(f, "\"{name}\": {data}", data = data.display(db, self.env))?; - if i < self.interface.inner(db).len() - 1 { - f.write_str(", ")?; - } + std::fmt::from_fn(move |f| { + f.write_char('{')?; + for (i, (name, data)) in self.inner(db).iter().enumerate() { + write!(f, "\"{name}\": {data}", data = data.display(db, env))?; + if i < self.inner(db).len() - 1 { + f.write_str(", ")?; } - f.write_char('}') } - } - - ProtocolInterfaceDisplay { - db, - env, - interface: self, - } + f.write_char('}') + }) } } @@ -1323,16 +1384,12 @@ impl<'db> VarianceInferable<'db> for ProtocolInterface<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - self.members(db) - .flat_map(|member| { - let capabilities = member.capabilities(db, env); - [capabilities.instance, capabilities.class] - .into_iter() - .flat_map(|access| access.variances(db, env)) - }) - .map(|(ty, variance)| ty.with_polarity(variance).variance_of(db, env, typevar)) - .collect() + ) -> VarianceTerm<'db> { + VarianceTerm::join( + db, + self.variance_types(db, env) + .map(|(ty, variance)| ty.with_polarity(variance).variance_of(db, env, typevar)), + ) } } @@ -1796,60 +1853,37 @@ impl<'db> ProtocolMemberData<'db> { } } - fn display<'env>( - &self, + fn display<'a, 'env>( + &'a self, db: &'db dyn Db, env: &'env ProgramEnvironment<'db>, - ) -> impl std::fmt::Display + 'env { - struct ProtocolMemberDataDisplay<'env, 'db> { - db: &'db dyn Db, - env: &'env ProgramEnvironment<'db>, - kind: ProtocolMemberKind<'db>, - qualifiers: TypeQualifiers, - } - - impl std::fmt::Display for ProtocolMemberDataDisplay<'_, '_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let db = self.db; - match self.kind { - ProtocolMemberKind::Method(member, _) => { - write!(f, "MethodMember(`{}`)", member.ty().display(db, self.env)) - } - ProtocolMemberKind::Property { read, write } => { - let env = self.env; - let mut d = f.debug_struct("PropertyMember"); - if let Some(read) = read.and_then(|read| read.resolve(db, env)) { - d.field( - "read", - &format_args!("`{}`", read.ty().display(db, self.env)), - ); - } - if let Some(write) = write.and_then(|write| write.display_type(db, env)) { - d.field( - "write", - &format_args!("`{}`", write.ty().display(db, self.env)), - ); - } - d.finish() - } - ProtocolMemberKind::Attribute(attribute) => { - f.write_str("AttributeMember(")?; - write!(f, "`{}`", attribute.ty().display(db, self.env))?; - if self.qualifiers.contains(TypeQualifiers::CLASS_VAR) { - f.write_str("; ClassVar")?; - } - f.write_char(')') - } + ) -> impl std::fmt::Display + 'a + where + 'env: 'a, + { + std::fmt::from_fn(move |f| match self.kind { + ProtocolMemberKind::Method(member, _) => { + write!(f, "MethodMember(`{}`)", member.ty().display(db, env)) + } + ProtocolMemberKind::Property { read, write } => { + let mut d = f.debug_struct("PropertyMember"); + if let Some(read) = read.and_then(|read| read.resolve(db, env)) { + d.field("read", &format_args!("`{}`", read.ty().display(db, env))); } + if let Some(write) = write.and_then(|write| write.display_type(db, env)) { + d.field("write", &format_args!("`{}`", write.ty().display(db, env))); + } + d.finish() } - } - - ProtocolMemberDataDisplay { - db, - env, - kind: self.kind, - qualifiers: self.qualifiers, - } + ProtocolMemberKind::Attribute(attribute) => { + f.write_str("AttributeMember(")?; + write!(f, "`{}`", attribute.ty().display(db, env))?; + if self.qualifiers.contains(TypeQualifiers::CLASS_VAR) { + f.write_str("; ClassVar")?; + } + f.write_char(')') + } + }) } } @@ -1907,12 +1941,9 @@ impl<'db> ProtocolMemberKind<'db> { cycle, ); Self::Method( - current.with_ty(Type::Callable(CallableType::new( - db, - signatures, - current_callable.kind(db), - current_callable.provenance(db), - ))), + current.with_ty(Type::Callable( + current_callable.with_signatures(db, signatures), + )), kind, ) } @@ -2018,12 +2049,12 @@ pub(super) struct ProtocolMember<'a, 'db> { /// The declaration order is significant because the derived ordering is used when comparing /// protocol interfaces. #[derive(Eq, Ord, PartialEq, PartialOrd)] -enum StructuralMemberPriority { +pub(super) enum StructuralMemberPriority { /// A non-recursive member with at most one callable signature. Simple, /// A non-recursive callable member with multiple overloads. FiniteOverload, - /// A member that may recurse through a protocol or type alias, or whose finiteness is unknown. + /// A member that contains a protocol or recursive alias, or whose finiteness is unknown. Recursive, } @@ -2150,18 +2181,33 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { } } + /// Returns whether an instance method has an explicit positional receiver annotation. + pub(super) fn has_explicit_receiver_annotation(&self, db: &'db dyn Db) -> bool { + match self.data.kind { + ProtocolMemberKind::Method(member, ProtocolMethodKind::Instance) + if let Type::Callable(callable) = member.ty() => + { + callable + .signatures(db) + .iter() + .any(Signature::has_explicit_positional_receiver_annotation) + } + _ => false, + } + } + /// Returns the priority for structurally comparing this member. /// - /// Simple finite members are cheapest, followed by finite overloads. Recursive and - /// alias-containing members are compared last because they can expand the same interface again. - fn structural_member_priority( + /// Simple finite members are cheapest, followed by finite overloads. Recursive members and + /// aliases that contain a protocol or are themselves recursive are compared last. + pub(super) fn structural_member_priority( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> StructuralMemberPriority { let is_recursive_type = |ty| { - any_over_type(db, env, ty, false, |nested| { - matches!(nested, Type::ProtocolInstance(_) | Type::TypeAlias(_)) + any_over_type_expanding_aliases(db, env, ty, |nested| { + matches!(nested, Type::ProtocolInstance(_)) }) }; @@ -2578,182 +2624,6 @@ fn descriptor_decorated_protocol_member<'db>( Some(ProtocolMemberData::property(read, write, definition)) } -#[derive(Copy, Clone)] -enum DescriptorSetterDomain<'db> { - Missing, - Known(Type<'db>), - Deferred, -} - -/// Derive the values accepted by every possible descriptor setter when they fit in [`Type`]. -fn descriptor_setter_domain<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - descriptor_ty: Type<'db>, - receiver_ty: Type<'db>, -) -> DescriptorSetterDomain<'db> { - match descriptor_ty { - Type::Union(union) => { - let mut write_types = Vec::with_capacity(union.elements(db).len()); - for descriptor_ty in union.elements(db) { - match single_descriptor_setter_domain(db, env, *descriptor_ty, receiver_ty) { - DescriptorSetterDomain::Missing => return DescriptorSetterDomain::Missing, - DescriptorSetterDomain::Known(write_ty) => write_types.push(write_ty), - DescriptorSetterDomain::Deferred => return DescriptorSetterDomain::Deferred, - } - } - IntersectionType::bounded_from_elements(db, env, write_types).map_or( - DescriptorSetterDomain::Deferred, - DescriptorSetterDomain::Known, - ) - } - _ => single_descriptor_setter_domain(db, env, descriptor_ty, receiver_ty), - } -} - -/// Derive the values accepted by one possible runtime descriptor. -fn single_descriptor_setter_domain<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - descriptor_ty: Type<'db>, - receiver_ty: Type<'db>, -) -> DescriptorSetterDomain<'db> { - let Place::Defined(DefinedPlace { - ty: setter_ty, - definedness: Definedness::AlwaysDefined, - .. - }) = descriptor_ty - .member_lookup_with_policy( - db, - env, - "__set__", - MemberLookupPolicy::REQUIRE_CONCRETE | MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) - .place - else { - return DescriptorSetterDomain::Missing; - }; - - let Some(callables) = setter_ty.try_upcast_to_callable(db, env) else { - return DescriptorSetterDomain::Deferred; - }; - let mut callable_domains = Vec::with_capacity(callables.iter().len()); - for callable in &callables { - let mut write_types = Vec::new(); - for signature in callable.signatures(db) { - match descriptor_setter_signature_domain(db, env, signature, descriptor_ty, receiver_ty) - { - DescriptorSetterSignatureDomain::Inapplicable => {} - DescriptorSetterSignatureDomain::Known(write_ty) => write_types.push(write_ty), - DescriptorSetterSignatureDomain::Deferred => { - return DescriptorSetterDomain::Deferred; - } - } - } - callable_domains.push(UnionType::from_elements(db, env, write_types)); - } - IntersectionType::bounded_from_elements(db, env, callable_domains).map_or( - DescriptorSetterDomain::Deferred, - DescriptorSetterDomain::Known, - ) -} - -enum DescriptorSetterSignatureDomain<'db> { - Inapplicable, - Known(Type<'db>), - Deferred, -} - -/// Derive the values accepted by one `__set__` overload when they fit in [`Type`]. -fn descriptor_setter_signature_domain<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - signature: &Signature<'db>, - descriptor_ty: Type<'db>, - receiver_ty: Type<'db>, -) -> DescriptorSetterSignatureDomain<'db> { - let parameters = signature.parameters(); - let missing_required_parameter = || { - if parameters.is_gradual() || parameters.as_slice().iter().any(Parameter::is_variadic) { - DescriptorSetterSignatureDomain::Deferred - } else { - DescriptorSetterSignatureDomain::Inapplicable - } - }; - let Some(trailing_parameters) = parameters.as_slice().get(2..) else { - return missing_required_parameter(); - }; - if !trailing_parameters.iter().all(|parameter| { - parameter.default_type().is_some() - || ((parameters.is_standard() || parameters.is_gradual()) - && (parameter.is_variadic() || parameter.is_keyword_variadic())) - }) { - return DescriptorSetterSignatureDomain::Inapplicable; - } - - let Some(receiver_parameter) = parameters.get_positional(0) else { - return missing_required_parameter(); - }; - let receiver_parameter = - receiver_parameter - .annotated_type() - .bind_self_typevars(db, env, descriptor_ty); - if contains_signature_typevar(db, env, signature, receiver_parameter) { - return DescriptorSetterSignatureDomain::Deferred; - } - if !receiver_ty.is_assignable_to(db, env, receiver_parameter) { - return DescriptorSetterSignatureDomain::Inapplicable; - } - - let Some(write_parameter) = parameters.get_positional(1) else { - return missing_required_parameter(); - }; - let write_ty = write_parameter - .annotated_type() - .bind_self_typevars(db, env, descriptor_ty); - if !contains_signature_typevar(db, env, signature, write_ty) { - return DescriptorSetterSignatureDomain::Known(write_ty); - } - - let Type::TypeVar(typevar) = write_ty else { - return DescriptorSetterSignatureDomain::Deferred; - }; - let Some(generic_context) = signature.generic_context else { - return DescriptorSetterSignatureDomain::Deferred; - }; - if !generic_context.contains(db, typevar.identity(db)) - || !typevar - .binding_context(db) - .definition() - .is_some_and(|definition| definition.kind(db).is_function_def()) - { - return DescriptorSetterSignatureDomain::Deferred; - } - - match typevar.typevar(db).bound_or_constraints(db, env) { - None => DescriptorSetterSignatureDomain::Known(Type::object()), - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - DescriptorSetterSignatureDomain::Known(bound.bind_self_typevars(db, env, descriptor_ty)) - } - Some(TypeVarBoundOrConstraints::Constraints(_)) => { - DescriptorSetterSignatureDomain::Deferred - } - } -} - -fn contains_signature_typevar<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - signature: &Signature<'db>, - ty: Type<'db>, -) -> bool { - signature.generic_context.is_some_and(|generic_context| { - super::visitor::any_over_type(db, env, ty, true, |ty| { - matches!(ty, Type::TypeVar(typevar) if generic_context.contains(db, typevar.identity(db))) - }) - }) -} - fn property_set_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -2811,6 +2681,7 @@ fn protocol_member_read_type<'db>( InstanceFallbackShadowsNonDataDescriptor::No, ) .unwrap_or_else(|error| error.fallback_member(db)) + .member(db) .place } else { receiver_ty.member(db, env, member.name).place @@ -3160,6 +3031,24 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { access: ProtocolMemberAccessMode, ) -> ConstraintSet<'db, 'c> { let env = self.env; + // Reading a member as `object` imposes no constraint on its value type. A class + // attribute establishes presence without inferring a shadowing instance assignment. + if !member.is_method() + && required_ty + .resolve(db, env) + .is_some_and(|required| required.ty().resolve_type_alias(db) == Type::object()) + && receiver_ty + .member_lookup_with_policy( + db, + env, + member.name, + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + .is_definitely_bound() + { + return self.always(); + } let Some(attribute_type) = protocol_member_read_type(db, env, ty, receiver_ty, member, access) else { @@ -3704,7 +3593,11 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { return self.never(); }; - ConstraintSet::from_bool(self.constraints, actual_property.setter(db).is_none()) + let missing = actual_property.setter(db).is_none(); + if missing && let Some(context) = self.report_context() { + context.push(ErrorContext::ProtocolMemberNotWritable); + } + ConstraintSet::from_bool(self.constraints, missing) } /// Checks whether `ty` is disjoint from the readable type required by `member`. @@ -3719,12 +3612,21 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { ) -> ConstraintSet<'db, 'c> { let env = self.env; let access = member.access(db, env, ProtocolMemberAccessMode::Instance); - if !member.is_method() { + let result = if !member.is_method() { access.read.when_some_and(db, self.constraints, |read_ty| { read_ty .resolve(db, env) .when_some_and(db, self.constraints, |read_ty| { - self.check_type_pair(db, ty, read_ty.ty()) + let result = self.check_type_pair(db, ty, read_ty.ty()); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, env) + { + context.push(ErrorContext::DisjointTypes { + left: ty, + right: read_ty.ty(), + }); + } + result }) }) } else { @@ -3761,16 +3663,31 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { db, self.constraints, |callable_signature| { - self.check_type_pair( + let result = self.check_type_pair( db, method_signature.return_ty, callable_signature.return_ty, - ) + ); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, env) + { + context.push(ErrorContext::DisjointReturnTypes { + left: method_signature.return_ty, + right: callable_signature.return_ty, + }); + } + result }, ) }) }) + }; + if let Some(context) = self.report_context() + && !result.is_always_satisfied(db, env) + { + context.take(); } + result } } @@ -3909,6 +3826,61 @@ fn non_object_protocol_member_count<'db>( interface.member_count(db) - inherited_member_count } +/// Check variance dependencies by definition, so expanding specializations such as `P[list[T]]` +/// do not produce an unbounded number of queries. A recursive dependency is supported unless +/// some member in the cycle has an unsupported type; variance itself is inferred by a separate +/// fixed-point computation starting from bivariance. +#[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _| true, + heap_size=ruff_memory_usage::heap_size, +)] +fn supports_protocol_variance_inference<'db>( + db: &'db dyn Db, + class: StaticClassLiteral<'db>, +) -> bool { + let Some(protocol) = class.identity_specialization(db).into_protocol_class(db) else { + return false; + }; + let interface = protocol.interface(db); + if interface.members(db).any(|member| { + matches!( + member.data.kind, + ProtocolMemberKind::Property { + write: Some(ProtocolMemberWrite::Descriptor { domain: None, .. }), + .. + } + ) + }) { + return false; + } + + let env = ProgramEnvironment::from_scope(class.body_scope(db)); + let supports_type = |ty| { + !any_over_type_expanding_aliases(db, &env, ty, |nested| { + matches!(nested, Type::ProtocolInstance(protocol) if protocol + .class_origin(db) + .is_none_or(|class| !class.supports_variance_inference(db))) + }) + }; + interface.variance_types(db, &env).all(|(ty, _)| { + if let Type::Callable(callable) = ty { + // Bound receivers constrain when a method can be called, but they are not input + // or output positions in variance inference. Match `Signature::variance_of`. + callable.signatures(db).iter().all(|signature| { + signature + .parameters() + .iter() + .map(Parameter::annotated_type) + .chain(std::iter::once(signature.return_ty)) + .all(supports_type) + }) + } else { + supports_type(ty) + } + }) +} + /// Inner Salsa query for [`ProtocolClass::interface`]. #[salsa::tracked( returns(copy), @@ -3928,6 +3900,8 @@ fn cached_protocol_interface<'db>( return; } + let specialization = + specialization.map(|specialization| specialization.with_typevar_bounds(db)); let candidate = candidate.apply_specialization(db, specialization); let ProtocolMemberCandidate { ty, @@ -4076,13 +4050,18 @@ pub(super) fn has_all_protocol_members_defined<'db>( }) } _ => target_interface.members(db).all(|member| { - matches!( - ty.member(db, env, member.name()).place, - Place::Defined(DefinedPlace { - definedness: Definedness::AlwaysDefined, - .. - }) + ty.member_lookup_with_policy( + db, + env, + member.name(), + MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) + .place + .is_definitely_bound() + || ty + .member(db, env, member.name()) + .place + .is_definitely_bound() }), } } diff --git a/crates/ty_python_semantic/src/types/regex.rs b/crates/ty_python_semantic/src/types/regex.rs index f50c328003..cf53204d26 100644 --- a/crates/ty_python_semantic/src/types/regex.rs +++ b/crates/ty_python_semantic/src/types/regex.rs @@ -32,9 +32,9 @@ pub(crate) use parse::{PatternAnalysis, analyze}; #[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct RegexGroup { /// the `(?P…)` name, if the group has one - pub(crate) name: Option, + name: Option, /// whether the group must have participated in *every* successful match - pub(crate) definitely_set: bool, + definitely_set: bool, } /// the capture groups of a statically-known pattern, in group-number order diff --git a/crates/ty_python_semantic/src/types/reified_infer.rs b/crates/ty_python_semantic/src/types/reified_infer.rs index feb832adbd..0d83c135dd 100644 --- a/crates/ty_python_semantic/src/types/reified_infer.rs +++ b/crates/ty_python_semantic/src/types/reified_infer.rs @@ -71,7 +71,7 @@ pub(crate) fn inferred_call_type_arguments<'db>( .and_then(|callable| callable.matching_overloads().exactly_one().ok()) .ok_or(ReifiedInferenceError::NoBinding)? .1 - .specialization(db, env); + .merged_specialization(db, env); rendered_type_arguments(db, env, file, function, specialization) } @@ -279,7 +279,9 @@ impl ParameterKind { Self::Variadic => { let spellings = variadic_elements(db, env, ty)? .into_iter() - .map(|element| runtime_spelling(db, env, file, element.promote(db, env))) + .map(|element| { + runtime_spelling(db, env, file, element.promote_in(db, env, file)) + }) .collect::>>()?; return Some(spellings.join(", ")); } @@ -290,7 +292,7 @@ impl ParameterKind { .map(|(name, field)| { Some(format!( "{name}={}", - runtime_spelling(db, env, file, field.promote(db, env))? + runtime_spelling(db, env, file, field.promote_in(db, env, file))? )) }) .collect::>>()?; @@ -531,7 +533,7 @@ fn spell_specialization_arguments<'db>( let elements = fixed .elements_slice() .iter() - .map(|element| runtime_spelling(db, env, file, element.promote(db, env))) + .map(|element| runtime_spelling(db, env, file, element.promote_in(db, env, file))) .collect::>>() .ok_or(ReifiedInferenceError::NoBinding)?; return Ok(if elements.is_empty() { @@ -839,12 +841,12 @@ pub(crate) fn erased_union<'db>( let arms = rows .iter() - .map(|row| runtime_spelling(db, env, file, row[position].promote(db, env))) + .map(|row| runtime_spelling(db, env, file, row[position].promote_in(db, env, file))) .collect::>>()?; let fixed = (0..width) .filter(|index| *index != position) .map(|index| { - runtime_spelling(db, env, file, rows[0][index].promote(db, env)) + runtime_spelling(db, env, file, rows[0][index].promote_in(db, env, file)) .map(|text| (index, text)) }) .collect::>>()?; diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 8e9b5b4654..597382aecd 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -17,6 +17,7 @@ use crate::types::enums::is_single_member_enum; use crate::types::function::FunctionDecorators; use ruff_python_ast::helpers::TypeModifier; +use crate::types::relation_error::ErrorRelation; use crate::types::restricted::{RestrictedType, restriction_admits}; use crate::types::set_theoretic::RecursivelyDefined; use crate::types::signatures::{ParametersKind, SignatureRelationVisitor}; @@ -281,7 +282,6 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetExists(_) | KnownBoundMethodType::ConstraintSetForAll(_) - | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetSolutionsFor(_) | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), @@ -319,6 +319,7 @@ impl<'db> Type<'db> { | KnownBoundMethodType::PropertyDunderDelete(_), ) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::BoundSuper(_) | Type::TypeIs(_) | Type::TypeGuard(_) @@ -449,7 +450,7 @@ impl<'db> Type<'db> { /// /// This is a separate method so that we can skip this expensive check when diagnostics /// are suppressed. - pub(crate) fn relation_error_context( + fn relation_error_context( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -903,6 +904,7 @@ impl<'db> Type<'db> { env, constraints, inferable, + context_tree: None, given: ConstraintSet::from_bool(constraints, false), perform_expensive_checks: true, disjointness_visitor: &disjointness_visitor, @@ -913,6 +915,31 @@ impl<'db> Type<'db> { checker.check_type_pair(db, self, other) } + /// Re-run a successful disjointness check with diagnostic context collection enabled. + pub(crate) fn disjointness_error_context( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + other: Type<'db>, + ) -> ErrorContextTree<'db> { + let constraints = ConstraintSetBuilder::new(); + let context = ErrorContextTree::new(ErrorRelation::Disjointness); + let checker = DisjointnessChecker { + env, + constraints: &constraints, + inferable: TypeVarSet::None, + context_tree: Some(context.clone()), + given: ConstraintSet::from_bool(&constraints, false), + perform_expensive_checks: true, + relation_visitor: &HasRelationToVisitor::default(&constraints), + disjointness_visitor: &IsDisjointVisitor::default(&constraints), + signature_relation_visitor: &SignatureRelationVisitor::default(), + materialization_visitor: &ApplyTypeMappingVisitor::new(env), + }; + checker.check_type_pair(db, self, other); + context + } + /// Checks whether `self` is disjoint from `other`, while being more accepting of false /// negatives. Use this when you want to _quickly_ check whether two types are _definitely_ /// disjoint, typically for engaging a fast path in some algorithm. @@ -932,6 +959,7 @@ impl<'db> Type<'db> { env, constraints, inferable, + context_tree: None, given: ConstraintSet::from_bool(constraints, false), perform_expensive_checks: false, disjointness_visitor: &disjointness_visitor, @@ -1149,6 +1177,260 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { && matches!(self.typevar_evaluation, TypeVarEvaluation::Eager) } + fn should_expand_intersection( + &self, + db: &'db dyn Db, + intersection: IntersectionType<'db>, + ) -> bool { + intersection + .positive(db) + .iter() + .any(|element| match element { + Type::TypeVar(tvar) => !tvar.is_inferable(db, self.inferable), + Type::NewTypeInstance(newtype) => newtype.concrete_base_type(db).is_union(), + _ => false, + }) + } + + fn check_source_union( + &self, + db: &'db dyn Db, + union: UnionType<'db>, + target: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if let Some(supertype) = union.common_literal_supertype(db, self.env) { + // Use the broader supertype only as a positive proof. If it has the requested + // relation to the target, then every literal in the union does too. Otherwise, + // check each literal individually. + let supertype_result = + self.without_context_collection(|| self.check_type_pair(db, supertype, target)); + if supertype_result.is_trivially_always_satisfied() { + return supertype_result; + } + } + + union + .elements(db) + .iter() + .when_all(db, self.constraints, |&element| { + let constraint_set = self.check_type_pair(db, element, target); + if let Some(context) = self.report_context() + && constraint_set.is_never_satisfied(db, self.env) + { + context.push(ErrorContext::NotAllUnionElementsAssignable { + element, + union: Type::Union(union), + target, + }); + } + constraint_set + }) + } + + fn check_target_union( + &self, + db: &'db dyn Db, + source: Type<'db>, + union: UnionType<'db>, + ) -> ConstraintSet<'db, 'c> { + let target = Type::Union(union); + if let Type::Intersection(intersection) = source + && let Some(alternatives) = intersection.finite_alternative_union(db, self.env) + { + return self.check_type_pair(db, alternatives, target); + } + + let is_new_type_of_union = || { + // Normally non-unions cannot directly contain unions in our model due to the fact that + // we enforce a DNF structure on our set-theoretic types. However, it *is* possible for + // there to be a newtype of a union, for an intersection to contain a newtype of a + // union, or for a non-inferable typevar (possibly inside an intersection) to widen to a + // bound or set of constraints that exposes a union; this requires special handling. + match source { + Type::Intersection(intersection) + if self.should_expand_intersection(db, intersection) => + { + self.check_type_pair( + db, + intersection.with_expanded_typevars_and_newtypes(db, self.env), + target, + ) + } + Type::NewTypeInstance(newtype) => { + let concrete_base = newtype.concrete_base_type(db); + if concrete_base.is_union() { + self.check_type_pair(db, concrete_base, target) + } else { + self.never() + } + } + _ => self.never(), + } + }; + + let mut elements_context = vec![]; + let context_tree = self.context_tree.as_ref().filter(|tree| tree.is_enabled()); + + let elements = union.elements(db); + let result = elements + .iter() + .when_any(db, self.constraints, |&element| { + let result = self.check_type_pair(db, source, element); + if let Some(context_tree) = context_tree { + let context = context_tree.take(); + if !context.is_empty() { + elements_context.push(context); + } + } + result + }) + .or(db, self.constraints, is_new_type_of_union); + + if context_tree.is_some() + && !elements_context.is_empty() + && result.is_never_satisfied(db, self.env) + { + let elements_without_context = elements.len() - elements_context.len(); + if elements_without_context > 0 && elements_without_context < elements.len() { + elements_context.push(ErrorContextTree::from_context( + ErrorContext::NotAssignableToNOtherUnionElements { + n: elements_without_context, + }, + self.relation, + )); + } + self.set_context( + ErrorContext::NotAssignableToAnyUnionElement { + source, + union: target, + }, + elements_context, + ); + } + + result + } + + fn check_target_intersection( + &self, + db: &'db dyn Db, + source: Type<'db>, + intersection: IntersectionType<'db>, + ) -> ConstraintSet<'db, 'c> { + intersection + .positive(db) + .iter() + .when_all(db, self.constraints, |&positive| { + let constraint_set = self.check_type_pair(db, source, positive); + if let Some(context) = self.report_context() + && constraint_set.is_never_satisfied(db, self.env) + { + context.push(ErrorContext::NotAssignableToIntersectionElement { + source, + element: positive, + intersection: Type::Intersection(intersection), + }); + } + constraint_set + }) + .and(db, self.constraints, || { + // For subtyping, we would want to check whether the *top materialization* of + // `source` is disjoint from the *top materialization* of `negative`. As an + // optimization, however, we can avoid this explicit transformation here, since + // our `Type::is_disjoint_from` implementation already only returns true for + // `T.is_disjoint_from(U)` if the *top materialization* of `T` is disjoint from the + // *top materialization* of `U`. + // + // Note that the implementation of redundancy here may be too strict from a + // theoretical perspective: under redundancy, `T <: ~U` if `Bottom[T]` is disjoint + // from `Top[U]` and `Bottom[U]` is disjoint from `Top[T]`. It's possible that this + // could be improved. For now, however, we err on the side of strictness for our + // redundancy implementation: a fully complete implementation of redundancy may + // lead to non-transitivity (highly undesirable); and pragmatically, a full + // implementation of redundancy may not generally lead to simpler types in many + // situations. + let source_ty = match self.relation { + TypeRelation::Subtyping + | TypeRelation::Redundancy { .. } + | TypeRelation::SubtypingAssuming => source, + TypeRelation::Assignability => source.bottom_materialization(db, self.env), + }; + intersection + .negative(db) + .iter() + .when_all(db, self.constraints, |&negative| { + let negative = match self.relation { + TypeRelation::Subtyping + | TypeRelation::Redundancy { .. } + | TypeRelation::SubtypingAssuming => negative, + TypeRelation::Assignability => { + negative.bottom_materialization(db, self.env) + } + }; + self.as_disjointness_checker() + .check_type_pair(db, source_ty, negative) + }) + }) + } + + fn check_source_intersection( + &self, + db: &'db dyn Db, + intersection: IntersectionType<'db>, + target: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if matches!(target, Type::LiteralValue(_)) + && let Some(alternatives) = intersection.finite_alternative_union(db, self.env) + { + return self.check_type_pair(db, alternatives, target); + } + + // An intersection type is a subtype of another type if at least one of its positive + // elements is a subtype of that type. If there are no positive elements, we treat `object` + // as the implicit positive element (e.g., `~str` is semantically `object & ~str`). + let mut elements_context = vec![]; + let context_tree = self.context_tree.as_ref().filter(|tree| tree.is_enabled()); + + let result = intersection + .positive_elements_or_object(db) + .when_any(db, self.constraints, |element| { + let result = self.check_type_pair(db, element, target); + if let Some(context_tree) = context_tree { + let context = context_tree.take(); + if !context.is_empty() { + elements_context.push(context); + } + } + result + }) + .or(db, self.constraints, || { + if self.should_expand_intersection(db, intersection) { + self.check_type_pair( + db, + intersection.with_expanded_typevars_and_newtypes(db, self.env), + target, + ) + } else { + self.never() + } + }); + + if context_tree.is_some() + && !elements_context.is_empty() + && result.is_never_satisfied(db, self.env) + { + self.set_context( + ErrorContext::NoIntersectionElementAssignableToTarget { + intersection: Type::Intersection(intersection), + target, + }, + elements_context, + ); + } + + result + } + /// Return the collected error context, or an empty tree if collection was disabled. pub(super) fn into_error_context(self) -> ErrorContextTree<'db> { self.context_tree @@ -1227,20 +1509,32 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { target: Type<'db>, work: impl FnOnce() -> ConstraintSet<'db, 'c>, ) -> ConstraintSet<'db, 'c> { + let collect_context = self.is_context_collection_enabled(); self.relation_visitor .try_visit( db, (source, target, self.relation, self.typevar_evaluation), + // Cached constraints do not retain explanations. When collecting context, + // recompute unsatisfiable comparisons while preserving the active recursion + // guards. Satisfiable constraints remain reusable, including those that + // constrain type variables. + |result| !collect_context || !result.is_never_satisfied(db, self.env), work, ) - .unwrap_or_else(|item| self.recursive_type_pair_fallback(item.0, item.1)) + .unwrap_or_else(|item| self.recursive_type_pair_fallback(db, item.0, item.1)) } fn recursive_type_pair_fallback( &self, - _source: Type<'db>, - _target: Type<'db>, + db: &'db dyn Db, + source: Type<'db>, + target: Type<'db>, ) -> ConstraintSet<'db, 'c> { + if let Some(nominally_satisfied) = self.try_check_nominal_protocol_cycle(db, source, target) + { + return nominally_satisfied; + } + // TODO: Recursively-specialized structural types can encode context-free languages, // whose inclusion and equivalence are undecidable. No complete fallback exists, but // more decidable cases can be recognized here before conservatively rejecting the pair. @@ -1476,17 +1770,6 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { } } - let should_expand_intersection = |intersection: IntersectionType<'db>| { - intersection - .positive(db) - .iter() - .any(|element| match element { - Type::TypeVar(tvar) => !tvar.is_inferable(db, self.inferable), - Type::NewTypeInstance(newtype) => newtype.concrete_base_type(db).is_union(), - _ => false, - }) - }; - match (source, target) { // Everything is a subtype of `object`. (_, Type::NominalInstance(target)) if target.is_object() => self.always(), @@ -1913,27 +2196,24 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { { self.check_type_pair( db, - Type::tuple(Some(TupleType::unpacked_typevartuple( - db, - env, - bound_typevar, - ))), + Type::tuple(TupleType::unpacked_typevartuple(db, env, bound_typevar)), target, ) } + // A fixed tuple cannot satisfy every specialization of a non-inferable TypeVarTuple. + // Let it reach the ordinary rejection below; expanding the target would repeat the + // same tuple comparison and cause the recursion guard to accept it. (source, Type::TypeVar(bound_typevar)) if !bound_typevar.is_inferable(db, self.inferable) && bound_typevar.is_typevartuple(db) - && source.exact_tuple_instance_spec(db).is_some() => + && source + .exact_tuple_instance_spec(db) + .is_some_and(|spec| spec.is_variadic()) => { self.check_type_pair( db, source, - Type::tuple(Some(TupleType::unpacked_typevartuple( - db, - env, - bound_typevar, - ))), + Type::tuple(TupleType::unpacked_typevartuple(db, env, bound_typevar)), ) } @@ -1951,14 +2231,19 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.always() } - // Any concrete specialization of a `ParamSpec` is a subtype of the top - // materialization of a `ParamSpec` value. + // Compare fixed `ParamSpec`s with the endpoints of the materialization range of `...`: + // its bottom is below every `ParamSpec`, and its top is above every `ParamSpec`. (Type::TypeVar(bound_typevar), Type::Callable(other)) + | (Type::Callable(other), Type::TypeVar(bound_typevar)) if !bound_typevar.is_inferable(db, self.inferable) && bound_typevar.is_parameter_pack(db) - && Self::is_top_paramspec_value(db, other) => + && other.kind(db) == CallableTypeKind::ParamSpecValue + && other.signatures(db).iter().all(|signature| { + signature.parameters().is_top() || signature.parameters().is_bottom() + }) => { - self.always() + let other_is_top = Self::is_top_paramspec_value(db, other); + ConstraintSet::from_bool(self.constraints, source.is_type_var() == other_is_top) } // basedpython: a hole nothing bounded is the gradual type it replaced, so anything is @@ -2061,223 +2346,18 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.check_newtype_pair(db, source_newtype, target_newtype) } - (Type::Union(union), _) => { - if let Some(supertype) = union.common_literal_supertype(db, env) { - // Use the broader supertype only as a positive proof. If it has the requested - // relation to the target, then every literal in the union does too. Otherwise, - // check each literal individually. - let supertype_result = self - .without_context_collection(|| self.check_type_pair(db, supertype, target)); - if supertype_result.is_trivially_always_satisfied() { - return supertype_result; - } - } - - union - .elements(db) - .iter() - .when_all(db, self.constraints, |&elem_ty| { - let constraint_set = self.check_type_pair(db, elem_ty, target); - if let Some(context) = self.report_context() - && constraint_set.is_never_satisfied(db, env) - { - context.push(ErrorContext::NotAllUnionElementsAssignable { - element: elem_ty, - union: source, - target, - }); - } - constraint_set - }) - } - - (_, Type::Union(union)) => { - if let Type::Intersection(intersection) = source - && let Some(alternatives) = intersection.finite_alternative_union(db, env) - { - return self.check_type_pair(db, alternatives, target); - } - - let is_new_type_of_union = || { - // Normally non-unions cannot directly contain unions in our model due to the fact that we - // enforce a DNF structure on our set-theoretic types. However, it *is* possible for there - // to be a newtype of a union, for an intersection to contain a newtype of a union, or for - // a non-inferable typevar (possibly inside an intersection) to widen to a bound or set of - // constraints that exposes a union; this requires special handling. - match source { - Type::Intersection(intersection) - if should_expand_intersection(intersection) => - { - self.check_type_pair( - db, - intersection.with_expanded_typevars_and_newtypes(db, env), - target, - ) - } - Type::NewTypeInstance(newtype) => { - let concrete_base = newtype.concrete_base_type(db); - if concrete_base.is_union() { - self.check_type_pair(db, concrete_base, target) - } else { - self.never() - } - } - _ => self.never(), - } - }; - - let mut elements_context = vec![]; - let context_tree = self.context_tree.as_ref().filter(|tree| tree.is_enabled()); - - let elements = union.elements(db); - let result = elements - .iter() - .when_any(db, self.constraints, |&elem_ty| { - let result = self.check_type_pair(db, source, elem_ty); - if let Some(context_tree) = context_tree { - let env = context_tree.take(); - if !env.is_empty() { - elements_context.push(env); - } - } - result - }) - .or(db, self.constraints, is_new_type_of_union); - - if context_tree.is_some() - && !elements_context.is_empty() - && result.is_never_satisfied(db, env) - { - let elements_without_context = elements.len() - elements_context.len(); - if elements_without_context > 0 && elements_without_context < elements.len() { - elements_context.push(ErrorContextTree::from_context( - ErrorContext::NotAssignableToNOtherUnionElements { - n: elements_without_context, - }, - self.relation, - )); - } - self.set_context( - ErrorContext::NotAssignableToAnyUnionElement { - source, - union: target, - }, - elements_context, - ); - } - - result - } + (Type::Union(union), _) => self.check_source_union(db, union, target), + (_, Type::Union(union)) => self.check_target_union(db, source, union), // If both sides are intersections we need to handle the right side first // (A & B & C) is a subtype of (A & B) because the left is a subtype of both A and B, // but none of A, B, or C is a subtype of (A & B). - (_, Type::Intersection(intersection)) => intersection - .positive(db) - .iter() - .when_all(db, self.constraints, |&pos_ty| { - let constraint_set = self.check_type_pair(db, source, pos_ty); - if let Some(context) = self.report_context() - && constraint_set.is_never_satisfied(db, env) - { - context.push(ErrorContext::NotAssignableToIntersectionElement { - source, - element: pos_ty, - intersection: target, - }); - } - constraint_set - }) - .and(db, self.constraints, || { - // For subtyping, we would want to check whether the *top materialization* of `source` - // is disjoint from the *top materialization* of `neg_ty`. As an optimization, however, - // we can avoid this explicit transformation here, since our `Type::is_disjoint_from` - // implementation already only returns true for `T.is_disjoint_from(U)` if the *top - // materialization* of `T` is disjoint from the *top materialization* of `U`. - // - // Note that the implementation of redundancy here may be too strict from a - // theoretical perspective: under redundancy, `T <: ~U` if `Bottom[T]` is disjoint - // from `Top[U]` and `Bottom[U]` is disjoint from `Top[T]`. It's possible that this - // could be improved. For now, however, we err on the side of strictness for our - // redundancy implementation: a fully complete implementation of redundancy may lead - // to non-transitivity (highly undesirable); and pragmatically, a full implementation - // of redundancy may not generally lead to simpler types in many situations. - let source_ty = match self.relation { - TypeRelation::Subtyping - | TypeRelation::Redundancy { .. } - | TypeRelation::SubtypingAssuming => source, - TypeRelation::Assignability => source.bottom_materialization(db, env), - }; - intersection - .negative(db) - .iter() - .when_all(db, self.constraints, |&neg_ty| { - let neg_ty = match self.relation { - TypeRelation::Subtyping - | TypeRelation::Redundancy { .. } - | TypeRelation::SubtypingAssuming => neg_ty, - TypeRelation::Assignability => { - neg_ty.bottom_materialization(db, env) - } - }; - self.as_disjointness_checker() - .check_type_pair(db, source_ty, neg_ty) - }) - }), + (_, Type::Intersection(intersection)) => { + self.check_target_intersection(db, source, intersection) + } (Type::Intersection(intersection), _) => { - if matches!(target, Type::LiteralValue(_)) - && let Some(alternatives) = intersection.finite_alternative_union(db, env) - { - return self.check_type_pair(db, alternatives, target); - } - - // An intersection type is a subtype of another type if at least one of its - // positive elements is a subtype of that type. If there are no positive elements, - // we treat `object` as the implicit positive element (e.g., `~str` is semantically - // `object & ~str`). - - let mut elements_context = vec![]; - let context_tree = self.context_tree.as_ref().filter(|tree| tree.is_enabled()); - - let result = intersection - .positive_elements_or_object(db) - .when_any(db, self.constraints, |elem_ty| { - let result = self.check_type_pair(db, elem_ty, target); - if let Some(context_tree) = context_tree { - let env = context_tree.take(); - if !env.is_empty() { - elements_context.push(env); - } - } - result - }) - .or(db, self.constraints, || { - if should_expand_intersection(intersection) { - self.check_type_pair( - db, - intersection.with_expanded_typevars_and_newtypes(db, env), - target, - ) - } else { - self.never() - } - }); - - if context_tree.is_some() - && !elements_context.is_empty() - && result.is_never_satisfied(db, env) - { - self.set_context( - ErrorContext::NoIntersectionElementAssignableToTarget { - intersection: source, - target, - }, - elements_context, - ); - } - - result + self.check_source_intersection(db, intersection, target) } // `Never` is the bottom type, the empty set. @@ -2513,7 +2593,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // if `type` is a subtype of that protocol. (Type::SubclassOf(source_subclass_ty), Type::ProtocolInstance(_)) if (source_subclass_ty.is_dynamic() || source_subclass_ty.is_type_var()) - && !self.is_eager_assignability() => + && !self.relation.is_assignability() => { self.check_type_pair(db, KnownClass::Type.to_instance(db, env), target) } @@ -2535,11 +2615,29 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { (Type::TypedDict(typed_dict), _) => { self.with_recursion_guard(db, source, target, || { - let dict_value_type = if self.relation.is_assignability() { - typed_dict.assignable_dict_value_type(db, env) - } else { - typed_dict.dict_value_type(db, env) - }; + let dict_value_type = + typed_dict.dict_value_type_if(db, |field_ty, extra_ty| { + let result = if self.relation.is_assignability() { + // Mutual assignability lets gradual field types satisfy the mutable + // dict contract. Check the schema without inferring type variables or + // contributing error context, but keep the active recursion guards. + let checker = Self { + inferable: TypeVarSet::None, + typevar_evaluation: TypeVarEvaluation::Eager, + context_tree: None, + ..self.clone() + }; + checker.check_type_pair(db, field_ty, extra_ty).and( + db, + self.constraints, + || checker.check_type_pair(db, extra_ty, field_ty), + ) + } else { + self.as_equivalence_checker() + .check_type_pair(db, field_ty, extra_ty) + }; + result.is_always_satisfied(db, env) + }); let fallback = if let Some(value_ty) = dict_value_type { KnownClass::Dict.to_specialized_instance( db, @@ -2902,15 +3000,9 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // Similarly `type[enum.Enum]` is a subtype of `enum.EnumMeta` because `enum.Enum` // is an instance of `enum.EnumMeta`. `type[Any]` and `type[Unknown]` do not participate in subtyping, // however, as they are not fully static types. - (Type::SubclassOf(subclass_of_ty), _) => self.check_type_pair( - db, - subclass_of_ty - .subclass_of() - .into_class(db, env) - .map(|source_class| source_class.metaclass_instance_type(db, env)) - .unwrap_or_else(|| KnownClass::Type.to_instance(db, env)), - target, - ), + (Type::SubclassOf(subclass_of_ty), _) => { + self.check_type_pair(db, subclass_of_ty.to_metaclass_instance(db, env), target) + } (Type::TypeForm(_), _) => self.check_type_pair(db, Type::object(), target), @@ -2959,6 +3051,16 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { (_, Type::PropertyInstance(property)) => { self.check_type_pair(db, source, property.instance_fallback(db, env)) } + (Type::SlotDescriptor(_), _) => self.check_type_pair( + db, + KnownClass::MemberDescriptorType.to_instance(db, env), + target, + ), + (_, Type::SlotDescriptor(_)) => self.check_type_pair( + db, + source, + KnownClass::MemberDescriptorType.to_instance(db, env), + ), // Other than the special cases enumerated above, nominal-instance types are never // subtypes of any other variants (Type::NominalInstance(_), _) => self.never(), @@ -3017,6 +3119,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { env: self.env, constraints: self.constraints, inferable: self.inferable, + context_tree: None, given: self.given, perform_expensive_checks: self.perform_expensive_checks, relation_visitor: self.relation_visitor, @@ -3055,7 +3158,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { } pub(super) struct EquivalenceChecker<'a, 'c, 'db> { - pub(super) env: &'a ProgramEnvironment<'db>, + env: &'a ProgramEnvironment<'db>, pub(super) constraints: &'c ConstraintSetBuilder<'db>, given: ConstraintSet<'db, 'c>, perform_expensive_checks: bool, @@ -3128,6 +3231,7 @@ pub(super) struct DisjointnessChecker<'a, 'c, 'db> { pub(super) env: &'a ProgramEnvironment<'db>, pub(super) constraints: &'c ConstraintSetBuilder<'db>, inferable: TypeVarSet<'db>, + context_tree: Option>, given: ConstraintSet<'db, 'c>, perform_expensive_checks: bool, @@ -3157,6 +3261,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { env, constraints, inferable, + context_tree: None, given: ConstraintSet::from_bool(constraints, false), perform_expensive_checks: true, disjointness_visitor, @@ -3186,6 +3291,32 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { } } + pub(super) fn report_context(&self) -> Option<&ErrorContextTree<'db>> { + self.context_tree + .as_ref() + .filter(|context| context.is_enabled()) + } + + /// Retain a failed subtyping or assignability check that proves disjointness. + pub(super) fn check_relation_with_context( + &self, + db: &'db dyn Db, + mut checker: TypeRelationChecker<'_, 'c, 'db>, + check: impl FnOnce(&TypeRelationChecker<'_, 'c, 'db>) -> ConstraintSet<'db, 'c>, + ) -> ConstraintSet<'db, 'c> { + checker.context_tree = self + .report_context() + .map(|_| ErrorContextTree::new(checker.relation)); + let result = check(&checker); + if let Some(context) = self.report_context() { + context.take(); + if result.is_never_satisfied(db, self.env) { + context.replace(&checker.into_error_context()); + } + } + result + } + fn as_equivalence_checker(&self) -> EquivalenceChecker<'_, 'c, 'db> { EquivalenceChecker { env: self.env, @@ -3243,26 +3374,54 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { .interface(db) .members(db) .when_any(db, self.constraints, |member| { - other + if let Some(context) = self.report_context() { + context.take(); + } + let attribute = other .member(db, env, member.name()) .place - .ignore_possibly_undefined() - .when_none_or(db, self.constraints, |attribute_type| { - self.protocol_member_has_disjoint_type_from_ty(db, &member, attribute_type) - .or(db, self.constraints, || { - self.protocol_member_write_is_definitely_missing_from_ty( - db, &member, other, - ) - }) - .or(db, self.constraints, || { - ConstraintSet::from_bool( - self.constraints, - member.has_incompatible_class_variable_declaration( - db, env, other, - ), - ) - }) + .ignore_possibly_undefined(); + let Some(attribute_type) = attribute else { + if let Some(context) = self.report_context() { + context.push(ErrorContext::ProtocolMemberNotDefined { + member_name: member.name().into(), + ty: other, + }); + if let Type::NominalInstance(nominal) = other + && nominal.class(db, env).is_final(db) + { + context.push(ErrorContext::FinalTypeMissingProtocolMembers { + final_type: other, + protocol: Type::ProtocolInstance(protocol), + }); + } + } + return self.always(); + }; + let result = self + .protocol_member_has_disjoint_type_from_ty(db, &member, attribute_type) + .or(db, self.constraints, || { + self.protocol_member_write_is_definitely_missing_from_ty(db, &member, other) }) + .or(db, self.constraints, || { + let incompatible = + member.has_incompatible_class_variable_declaration(db, env, other); + if incompatible && let Some(context) = self.report_context() { + context.push(ErrorContext::ProtocolMemberClassVarMismatch { + member_name: member.name().into(), + ty: other, + }); + } + ConstraintSet::from_bool(self.constraints, incompatible) + }); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, env) + { + context.push(ErrorContext::ProtocolMemberIncompatible { + member_name: member.name().into(), + }); + } + result }) } @@ -3311,6 +3470,25 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { db: &'db dyn Db, left: Type<'db>, right: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if let Some(context) = self.report_context() { + context.take(); + } + let result = self.check_type_pair_impl(db, left, right); + if let Some(context) = self.report_context() + && !result.is_always_satisfied(db, self.env) + { + // A failed alternative is not evidence for a later successful disjointness check. + context.take(); + } + result + } + + fn check_type_pair_impl( + &self, + db: &'db dyn Db, + left: Type<'db>, + right: Type<'db>, ) -> ConstraintSet<'db, 'c> { /// This lets us clearly mark below which match arms require a non-trivial amount of work /// to calculate, without sacrificing match guard exhaustiveness checks. If we are not @@ -3496,12 +3674,35 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::Union(union), other) | (other, Type::Union(union)) => { nontrivial_check(self, || { - union + let mut children = Vec::new(); + let result = union .elements(db) .iter() .when_all(db, self.constraints, |e| { - self.check_type_pair(db, *e, other) - }) + let result = self.check_type_pair(db, *e, other); + if let Some(context) = self.report_context() { + if context.is_empty() { + context.push(ErrorContext::DisjointTypes { + left: *e, + right: other, + }); + } + children.push(context.take()); + } + result + }); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, env) + { + context.set( + ErrorContext::DisjointUnion { + union: Type::Union(union), + other, + }, + children, + ); + } + result }) } @@ -3815,7 +4016,10 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { .interface(db) .members(db) .when_any(db, self.constraints, |member| { - match other.member(db, env, member.name()).place { + if let Some(context) = self.report_context() { + context.take(); + } + let result = match other.member(db, env, member.name()).place { Place::Defined(DefinedPlace { ty: attribute_type, .. }) => self.protocol_member_has_disjoint_type_from_ty( @@ -3824,7 +4028,15 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { attribute_type, ), Place::Undefined => self.never(), + }; + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, env) + { + context.push(ErrorContext::ProtocolMemberIncompatible { + member_name: member.name().into(), + }); } + result }) }) }), @@ -3917,15 +4129,14 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::SubclassOf(subclass_of_ty), other) | (other, Type::SubclassOf(subclass_of_ty)) => { nontrivial_check(self, || match subclass_of_ty.subclass_of() { - SubclassOfInner::Dynamic(_) => { - self.check_type_pair(db, KnownClass::Type.to_instance(db, env), other) - } - SubclassOfInner::Class(class) => { - self.check_type_pair(db, class.metaclass_instance_type(db, env), other) - } - SubclassOfInner::Protocol(_) => { + SubclassOfInner::Dynamic(_) | SubclassOfInner::Protocol(_) => { self.check_type_pair(db, KnownClass::Type.to_instance(db, env), other) } + SubclassOfInner::Class(_) => self.check_type_pair( + db, + subclass_of_ty.to_metaclass_instance(db, env), + other, + ), SubclassOfInner::TypeVar(_) => unreachable!(), }) } @@ -4262,6 +4473,16 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { self.check_type_pair(db, property.instance_fallback(db, env), other) }), + (Type::SlotDescriptor(_), other) | (other, Type::SlotDescriptor(_)) => { + nontrivial_check(self, || { + self.check_type_pair( + db, + KnownClass::MemberDescriptorType.to_instance(db, env), + other, + ) + }) + } + (Type::BoundSuper(left), Type::BoundSuper(right)) => nontrivial_check(self, || { self.as_equivalence_checker() .check_bound_super_pair(db, left, right) diff --git a/crates/ty_python_semantic/src/types/relation_error.rs b/crates/ty_python_semantic/src/types/relation_error.rs index 06570acce6..3bb12e97db 100644 --- a/crates/ty_python_semantic/src/types/relation_error.rs +++ b/crates/ty_python_semantic/src/types/relation_error.rs @@ -1,17 +1,41 @@ use crate::Db; use crate::types::relation::TypeRelation; /// This module defines a tree structure for collecting contextual information about type relation errors -/// ("why is this complex type not assignable to that other complex type?"). +/// (for example, why two types are not assignable or cannot overlap). use std::cell::{Cell, RefCell}; use std::rc::Rc; use ruff_python_ast::name::Name; +use ty_python_core::semantic_index; use crate::types::context::LintDiagnosticGuard; +use crate::types::infer::nearest_enclosing_class; use crate::types::tuple::TupleLength; use crate::types::{DisplaySettings, Type, TypedDictType}; use crate::{FxOrderSet, ProgramEnvironment}; +/// The relation explained by a diagnostic node, independently of the checker that owns the tree. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum ErrorRelation { + TypeRelation(TypeRelation), + Disjointness, +} + +impl From for ErrorRelation { + fn from(relation: TypeRelation) -> Self { + Self::TypeRelation(relation) + } +} + +impl ErrorRelation { + fn description(self) -> &'static str { + match self { + Self::TypeRelation(relation) => relation.description(), + Self::Disjointness => "disjoint from", + } + } +} + /// Identifies a parameter, either by name or by position. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ParameterDescription { @@ -45,6 +69,54 @@ impl std::fmt::Display for ParameterDescription { pub(crate) enum ErrorContext<'db> { /// No additional context is available. Empty, + DisjointTypes { + left: Type<'db>, + right: Type<'db>, + }, + DisjointUnion { + union: Type<'db>, + other: Type<'db>, + }, + InvariantTypeArgument { + left: Type<'db>, + right: Type<'db>, + }, + FinalClassDisjoint { + final_type: Type<'db>, + other: Type<'db>, + }, + FinalTypeMissingProtocolMembers { + final_type: Type<'db>, + protocol: Type<'db>, + }, + IncompatibleClassLayouts { + left: Type<'db>, + right: Type<'db>, + }, + DisjointTupleElement { + left: Type<'db>, + right: Type<'db>, + index: usize, + from_end: bool, + }, + DisjointReturnTypes { + left: Type<'db>, + right: Type<'db>, + }, + DisjointTupleLengths { + left: TupleLength, + right: TupleLength, + }, + TypedDictFieldTypeConflict { + field_name: Name, + left: Type<'db>, + right: Type<'db>, + }, + TypedDictRequirednessConflict { + field_name: Name, + required: TypedDictType<'db>, + not_required: TypedDictType<'db>, + }, NotAllUnionElementsAssignable { element: Type<'db>, union: Type<'db>, @@ -176,8 +248,8 @@ impl<'db> ErrorContext<'db> { &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, - relation: TypeRelation, - help_messages: &mut FxOrderSet, + relation: ErrorRelation, + help_messages: &mut FxOrderSet>, ) -> Option { let typed_dict_name = |typed_dict: &TypedDictType<'db>| match typed_dict { TypedDictType::Class(class) => format!("TypedDict `{}`", class.name(db)), @@ -190,6 +262,136 @@ impl<'db> ErrorContext<'db> { Self::Empty => { return None; } + Self::DisjointTypes { left, right } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "`{}` and `{}` are disjoint", + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::DisjointUnion { union, other } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*union, *other]); + format!( + "every element of union `{}` is disjoint from `{}`", + union.display_with(db, env, settings.clone().expand_numeric_tower_unions()), + other.display_with(db, env, settings), + ) + } + Self::InvariantTypeArgument { left, right } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "`{}` and `{}` are not mutual subtypes of each other, \ + but must be due to invariance", + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::FinalClassDisjoint { final_type, other } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*final_type, *other]); + format!( + "`{}` is `@final` and not a subclass of `{}`", + final_type.display_with(db, env, settings.clone()), + other.display_with(db, env, settings), + ) + } + Self::FinalTypeMissingProtocolMembers { + final_type, + protocol, + } => { + let settings = DisplaySettings::from_possibly_ambiguous_types( + db, + env, + [*final_type, *protocol], + ); + format!( + "`@final` type `{}` does not provide all members of protocol `{}`", + final_type.display_with(db, env, settings.clone()), + protocol.display_with(db, env, settings), + ) + } + Self::IncompatibleClassLayouts { left, right } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "`{}` and `{}` are disjoint due to incompatible instance layouts", + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::DisjointTupleElement { + left, + right, + index, + from_end, + } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "tuple element {}{} has disjoint types `{}` and `{}`", + index + 1, + if *from_end { " from the end" } else { "" }, + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::DisjointReturnTypes { left, right } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "return types `{}` and `{}` are disjoint", + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::DisjointTupleLengths { left, right } => { + let length = |length| match length { + TupleLength::Fixed(n) => n.to_string(), + TupleLength::Variable(prefix, suffix) => { + format!("at least {}", prefix + suffix) + } + }; + format!( + "the tuples have incompatible lengths: {} and {}", + length(*left), + length(*right) + ) + } + Self::TypedDictFieldTypeConflict { + field_name, + left, + right, + } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*left, *right]); + format!( + "field `{field_name}` has incompatible types `{}` and `{}`", + left.display_with(db, env, settings.clone()), + right.display_with(db, env, settings), + ) + } + Self::TypedDictRequirednessConflict { + field_name, + required, + not_required, + } => { + let required = Type::TypedDict(*required); + let not_required = Type::TypedDict(*not_required); + let settings = DisplaySettings::from_possibly_ambiguous_types( + db, + env, + [required, not_required], + ); + format!( + "field `{field_name}` is required in `{}` but mutable and not-required in `{}`", + required.display_with(db, env, settings.clone()), + not_required.display_with(db, env, settings), + ) + } Self::NotAllUnionElementsAssignable { element, union, @@ -311,11 +513,12 @@ impl<'db> ErrorContext<'db> { Self::OpenTypedDictNotAssignableToMapping { source, target } => { let name = source.defining_class().map(|class| class.name(db)); help_messages.insert(HelpMessages::OpenTypedDictNotAssignableToMapping { - typed_dict_name: name.cloned(), - relation, + typed_dict_name: name, + mapping_target: *target, }); help_messages.insert(HelpMessages::ExplainOpenTypedDictUnsoundness { - typed_dict_name: name.cloned(), + typed_dict_name: name, + mapping_target: *target, }); format!( @@ -481,44 +684,57 @@ impl<'db> ErrorContext<'db> { } #[derive(Clone, Debug, PartialEq, Eq, Hash)] -enum HelpMessages { +enum HelpMessages<'db> { RequiredFieldCouldBeRemoved, - TypedDictNotAssignableToDict(TypeRelation), + TypedDictNotAssignableToDict(ErrorRelation), ConsiderUsingMappingInsteadOfDict, TopCallableExplanation, ConsiderAddingADefaultValue { parameter_name: Option, }, OpenTypedDictNotAssignableToMapping { - typed_dict_name: Option, - relation: TypeRelation, + typed_dict_name: Option<&'db Name>, + mapping_target: Type<'db>, }, ExplainOpenTypedDictUnsoundness { - typed_dict_name: Option, + typed_dict_name: Option<&'db Name>, + mapping_target: Type<'db>, + }, + SuggestMakingParameterPositionalOnly { + ty: Type<'db>, + protocol: Type<'db>, + declaring_protocol_name: &'db Name, + method_name: Name, + parameter_name: Name, }, } -impl std::fmt::Display for HelpMessages { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { +impl<'db> HelpMessages<'db> { + fn display( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment, + relation: ErrorRelation, + ) -> impl std::fmt::Display { + std::fmt::from_fn(move |f| match self { HelpMessages::RequiredFieldCouldBeRemoved => f.write_str( "The required field could be removed through a destructive operation \ - like `del` on the target.", + like `del` on the target", ), HelpMessages::TypedDictNotAssignableToDict(relation) => { write!( f, "A TypedDict is not usually {} any `dict[..]` type; \ - `dict` types allow destructive operations like `clear()`.", + `dict` types allow destructive operations like `clear()`", relation.description() ) } HelpMessages::ConsiderUsingMappingInsteadOfDict => { - f.write_str("Consider using `Mapping[..]` instead of `dict[..]`.") + f.write_str("Consider using `Mapping[..]` instead of `dict[..]`") } HelpMessages::OpenTypedDictNotAssignableToMapping { typed_dict_name, - relation, + mapping_target, } => { let name = typed_dict_name .as_ref() @@ -526,13 +742,17 @@ impl std::fmt::Display for HelpMessages { .unwrap_or_else(|| "this TypedDict".to_string()); write!( f, - "{name} would be {relation} this `Mapping` type \ + "{name} would be {relation} `{mapping}` \ if it were declared with `closed=True`, \ - but TypedDicts are open by default.", - relation = relation.description() + but TypedDicts are open by default", + relation = relation.description(), + mapping = mapping_target.display(db, env) ) } - HelpMessages::ExplainOpenTypedDictUnsoundness { typed_dict_name } => { + HelpMessages::ExplainOpenTypedDictUnsoundness { + typed_dict_name, + mapping_target, + } => { let name = typed_dict_name .as_ref() .map(|name| format!("`{name}`")) @@ -540,7 +760,8 @@ impl std::fmt::Display for HelpMessages { write!( f, "A subclass of {name} could validly add a new field \ - of an arbitrary type, violating subtyping with the `Mapping` type" + of an arbitrary type, violating subtyping with `{mapping_type}`", + mapping_type = mapping_target.display(db, env) ) } HelpMessages::TopCallableExplanation => f.write_str( @@ -552,7 +773,26 @@ impl std::fmt::Display for HelpMessages { Some(name) => write!(f, "Parameter `{name}` must have a default value"), None => f.write_str("The parameter must have a default value"), }, - } + HelpMessages::SuggestMakingParameterPositionalOnly { + ty, + protocol, + declaring_protocol_name, + method_name, + parameter_name, + } => { + let settings = + DisplaySettings::from_possibly_ambiguous_types(db, env, [*ty, *protocol]); + write!( + f, + "`{source}` might be {relation} `{target}` \ + if the parameter `{parameter_name}` were made positional-only \ + in `{declaring_protocol_name}.{method_name}`", + source = ty.display_with(db, env, settings.clone()), + relation = relation.description(), + target = protocol.display_with(db, env, settings), + ) + } + }) } } @@ -560,6 +800,7 @@ impl std::fmt::Display for HelpMessages { struct ErrorContextNode<'db> { context: ErrorContext<'db>, children: Vec>, + relation: ErrorRelation, } impl Default for ErrorContextNode<'_> { @@ -567,6 +808,7 @@ impl Default for ErrorContextNode<'_> { Self { context: ErrorContext::Empty, children: Vec::new(), + relation: TypeRelation::Assignability.into(), } } } @@ -577,21 +819,55 @@ impl<'db> ErrorContextNode<'db> { matches!(self.context, ErrorContext::Empty) && self.children.is_empty() } - #[expect(clippy::too_many_arguments)] fn render_tree( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, - relation: TypeRelation, output_lines: &mut Vec, - help_messages: &mut FxOrderSet, + help_messages: &mut FxOrderSet<(ErrorRelation, HelpMessages<'db>)>, prefix: &str, continuation: &str, ) { - if let Some(line) = self.context.render(db, env, relation, help_messages) { + let mut node_help_messages = FxOrderSet::default(); + if let Some(line) = self + .context + .render(db, env, self.relation, &mut node_help_messages) + { output_lines.push(format!("{prefix}{line}")); } + if let ErrorContext::TypeNotCompatibleWithProtocol { ty, protocol } = &self.context + && let Type::ProtocolInstance(proto_instance) = protocol + && let [single_child] = self.children.as_slice() + && let ErrorContext::ProtocolMemberIncompatible { member_name } = &single_child.context + && let [single_grandchild] = single_child.children.as_slice() + && let ErrorContext::ParameterNameMismatch { target_name, .. } + | ErrorContext::ParameterMustAcceptKeywordArguments { target_name, .. } = + &single_grandchild.context + && let Some(protocol_member) = + proto_instance.interface(db).member_by_name(db, member_name) + && let Some(definition) = protocol_member.definition() + && let Some(declaring_protocol) = nearest_enclosing_class( + db, + semantic_index(db, definition.program_file(db)), + definition.scope(db), + ) + { + node_help_messages.insert(HelpMessages::SuggestMakingParameterPositionalOnly { + ty: *ty, + protocol: *protocol, + declaring_protocol_name: declaring_protocol.name(db), + method_name: member_name.clone(), + parameter_name: target_name.clone(), + }); + } + + help_messages.extend( + node_help_messages + .into_iter() + .map(|message| (self.relation, message)), + ); + let num_children = self.children.len(); for (index, child) in self.children.iter().enumerate() { let is_last = index == num_children - 1; @@ -603,7 +879,6 @@ impl<'db> ErrorContextNode<'db> { child.render_tree( db, env, - relation, output_lines, help_messages, &child_prefix, @@ -617,7 +892,7 @@ impl<'db> ErrorContextNode<'db> { pub(crate) struct ErrorContextTree<'db> { root: Rc>>, enabled: Cell, - relation: TypeRelation, + relation: ErrorRelation, } impl PartialEq for ErrorContextTree<'_> { @@ -630,19 +905,24 @@ impl Eq for ErrorContextTree<'_> {} impl<'db> ErrorContextTree<'db> { /// Create a new, empty error context tree with collection enabled. - pub(crate) fn new(relation: TypeRelation) -> Self { + pub(crate) fn new(relation: impl Into) -> Self { Self { root: Rc::default(), enabled: Cell::new(true), - relation, + relation: relation.into(), } } - pub(crate) fn from_context(context: ErrorContext<'db>, relation: TypeRelation) -> Self { + pub(crate) fn from_context( + context: ErrorContext<'db>, + relation: impl Into, + ) -> Self { + let relation = relation.into(); Self { root: Rc::new(RefCell::new(ErrorContextNode { context, children: Vec::new(), + relation, })), enabled: Cell::new(true), relation, @@ -669,7 +949,11 @@ impl<'db> ErrorContextTree<'db> { } let root = self.root.take(); let children = if root.is_empty() { vec![] } else { vec![root] }; - *self.root.borrow_mut() = ErrorContextNode { context, children }; + *self.root.borrow_mut() = ErrorContextNode { + context, + children, + relation: self.relation, + }; } /// Overwrite the error context tree with a new root context and child nodes. @@ -683,6 +967,7 @@ impl<'db> ErrorContextTree<'db> { } *self.root.borrow_mut() = ErrorContextNode { context, + relation: self.relation, children: children .into_iter() .map(|child_context| child_context.root.take()) @@ -700,6 +985,13 @@ impl<'db> ErrorContextTree<'db> { } } + /// Replace this tree with another tree, preserving each node's relation. + pub(crate) fn replace(&self, other: &Self) { + if self.is_enabled() { + *self.root.borrow_mut() = other.root.take(); + } + } + /// Render the error context tree as info sub-diagnostics on `diag`. pub(in crate::types) fn attach_to( &self, @@ -709,20 +1001,14 @@ impl<'db> ErrorContextTree<'db> { ) { let mut output_lines = Vec::new(); let mut help_messages = FxOrderSet::default(); - self.root.borrow().render_tree( - db, - env, - self.relation, - &mut output_lines, - &mut help_messages, - "", - "", - ); + self.root + .borrow() + .render_tree(db, env, &mut output_lines, &mut help_messages, "", ""); for line in output_lines { diag.info(line); } - for help_message in help_messages { - diag.help(help_message.to_string()); + for (relation, help_message) in help_messages { + diag.help(help_message.display(db, env, relation)); } } } diff --git a/crates/ty_python_semantic/src/types/restricted.rs b/crates/ty_python_semantic/src/types/restricted.rs index 6b237017c2..3aae949fb6 100644 --- a/crates/ty_python_semantic/src/types/restricted.rs +++ b/crates/ty_python_semantic/src/types/restricted.rs @@ -21,8 +21,8 @@ use ruff_python_ast::helpers::TypeModifier; use super::class::ClassType; -use super::variance::VarianceInferable; -use super::{BoundTypeVarIdentity, KnownClass, Type, TypeVarVariance, visitor}; +use super::variance::{VarianceInferable, VarianceTerm}; +use super::{BoundTypeVarIdentity, KnownClass, Type, visitor}; use crate::Db; use crate::types::ProgramEnvironment; @@ -105,7 +105,7 @@ impl<'db> Type<'db> { /// /// A dynamic type is literal, matching the way gradual types are admissible /// against every other restriction in the type system. - pub(crate) fn is_literal_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + fn is_literal_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { match self { Type::LiteralValue(_) => true, Type::Dynamic(_) | Type::Divergent(_) | Type::Never => true, @@ -275,7 +275,7 @@ impl<'db> VarianceInferable<'db> for RestrictedType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.type_argument(db).variance_of(db, env, typevar) } } diff --git a/crates/ty_python_semantic/src/types/safe_variance.rs b/crates/ty_python_semantic/src/types/safe_variance.rs index 6a9274f975..11c01207fb 100644 --- a/crates/ty_python_semantic/src/types/safe_variance.rs +++ b/crates/ty_python_semantic/src/types/safe_variance.rs @@ -11,11 +11,12 @@ //! widening annotation on it does not make the body another view of the class. use super::{ - BoundTypeVarInstance, ClassLiteral, MemberLookupPolicy, Type, TypeVarVariance, any_over_type, - is_private_member, + BoundTypeVarInstance, ClassBase, ClassLiteral, MemberLookupPolicy, Type, TypeVarVariance, + any_over_type, is_private_member, }; use crate::Db; use crate::types::ProgramEnvironment; +use crate::types::member::class_member; /// basedpython safe variance: a private member seen through a view of its class that is /// not the class's own. @@ -119,7 +120,8 @@ pub(super) fn private_member_view<'db>( // still names the class's type parameters rather than the receiver's arguments. a // `__getattr__` result is not a declared member of anything, so it is never private // however its name is spelled - let own_view = Type::instance(db, env, class.identity_specialization(db)); + let own_class = class.identity_specialization(db); + let own_view = Type::instance(db, env, own_class); let member = own_view.member_lookup_with_policy( db, env, @@ -131,6 +133,33 @@ pub(super) fn private_member_view<'db>( return None; } + // a member a code generator supplies is part of the surface that construct gives every one + // of its classes, not something the class kept to itself. a named tuple's `_asdict` and + // `_replace` are why this matters: python spells them with a leading underscore to keep the + // field namespace clear, not to hide them + // + // only the class that actually supplies the member answers this. a body declaration wins over + // synthesis, so a subclass that declares a name some generator also supplies is keeping a + // member of its own private and stays subject to erasure + for (base, base_specialization) in own_class + .iter_mro(db) + .filter_map(ClassBase::into_class) + .filter_map(|base| base.static_class_literal(db)) + { + if class_member(db, base.body_scope(db), attribute) + .ignore_possibly_undefined() + .is_some() + { + break; + } + if base + .own_synthesized_member(db, env, base_specialization, None, attribute) + .is_some() + { + return None; + } + } + let substituted: Box<[_]> = substituted() .filter(|typevar| { let identity = typevar.identity(db); diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index dfed4e5d24..399a0c431d 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -13,6 +13,7 @@ use crate::types::{TypeVarBoundOrConstraints, visitor}; use crate::{Db, FxOrderSet}; pub(crate) mod builder; +mod generic_gradual_intersections; pub(crate) use builder::{IntersectionBuilder, UnionBuilder}; @@ -899,7 +900,10 @@ impl<'db> IntersectionType<'db> { let non_union_elements = elements.clone().filter(|element| !element.is_union()); let initial = Self::from_elements(db, env, non_union_elements); - let insert_candidate = |candidates: &mut Vec>, new_ty: Type<'db>| -> Option<()> { + let insert_candidate = |candidates: &mut Vec>, + new_ty: Type<'db>, + check_budget: bool| + -> Option<()> { if new_ty.is_never() || candidates .iter() @@ -909,7 +913,7 @@ impl<'db> IntersectionType<'db> { } candidates.retain(|old| !old.is_redundant_with(db, env, new_ty)); - if candidates.len() >= MAX_INTERSECTION_DNF_TERMS { + if check_budget && candidates.len() >= MAX_INTERSECTION_DNF_TERMS { return None; } candidates.push(new_ty); @@ -918,7 +922,7 @@ impl<'db> IntersectionType<'db> { let mut frontier = Vec::new(); let mut next = Vec::new(); - insert_candidate(&mut frontier, initial)?; + insert_candidate(&mut frontier, initial, true)?; for (idx, clause) in elements.filter_map(Type::as_union).enumerate() { // Don't check the budget for the first union clause. That ensures that we have a @@ -926,13 +930,13 @@ impl<'db> IntersectionType<'db> { // result. For instance, this allows us to return the precise result for // `(A | B | C | D | E) & (A | B | F | G | H)` (in which each class is final), since // most of the pairs are disjoint. - let skip_budget_check = (idx == 0).then_some(()); + let check_budget = idx > 0; next.clear(); for candidate in &frontier { for alternative in clause.elements(db) { let refined = Self::from_two_elements(db, env, *candidate, *alternative); - insert_candidate(&mut next, refined).or(skip_budget_check)?; + insert_candidate(&mut next, refined, check_budget)?; } } @@ -1038,8 +1042,8 @@ impl<'db> IntersectionType<'db> { builder.build() } - /// Compute the `__class__` type when this intersection contains a positive class-backed - /// protocol constraint. + /// Compute the `__class__` type for class-backed protocols and `TypedDict` instances, + /// whose runtime classes differ from their internal meta-types. /// /// Negative instance constraints are not transferred: an object not satisfying `P` does not /// imply that other instances of its class cannot satisfy `P`. @@ -1052,7 +1056,7 @@ impl<'db> IntersectionType<'db> { matches!( positive, Type::ProtocolInstance(protocol) if protocol.class_origin(db).is_some() - ) + ) || positive.is_typed_dict() }) { return None; } diff --git a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs index ee2704b60e..c976ecf25f 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -18,6 +18,9 @@ //! eliminate the supertype from the intersection). //! * An intersection containing two non-overlapping types simplifies to [`Type::Never`]. //! +//! Relation-based intersection simplifications require a non-circular proof. During inference +//! cycles, an intersection can retain redundant or contradictory elements instead. +//! //! The implication of these invariants is that a [`UnionBuilder`] does not necessarily build a //! [`Type::Union`]. For example, if only one type is added to the [`UnionBuilder`], `build()` will //! just return that type directly. The same is true for [`IntersectionBuilder`]; for example, if a @@ -39,14 +42,15 @@ use std::hint::cold_path; use super::RecursivelyDefined; - +use super::generic_gradual_intersections::{GenericIntersection, generic_gradual_intersection}; use crate::types::enums::EnumComplement; use crate::types::regex; use crate::types::set_theoretic::expand_intersection_typevars_and_newtypes; +use crate::types::visitor::any_over_type; use crate::types::{ BytesLiteralType, ClassLiteral, EnumLiteralType, IntersectionType, KnownClass, KnownInstanceType, LiteralValueType, LiteralValueTypeKind, NegativeIntersectionElements, - StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, + StringLiteralType, SubclassOfType, Type, TypePair, TypeVarBoundOrConstraints, UnionType, }; use crate::{Db, FxOrderMap, FxOrderSet, ProgramEnvironment}; use rustc_hash::FxHashSet; @@ -96,64 +100,6 @@ fn split_truthiness_guarded_intersection<'db>( Some((core.build(), guard)) } -/// Return `true` if `general` and `specific` are specializations of the same generic class and -/// `general` only differs by using dynamic types for invariant type variables. For example, -/// `list[Any]` is an invariant-dynamic generalization of `list[int]`. -fn is_invariant_dynamic_generalization_of<'db>( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - general: Type<'db>, - specific: Type<'db>, -) -> bool { - // Fast path to avoid performance regressions. - if !general.has_dynamic(db, env) { - return false; - } - - if matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) { - return false; - } - - let ( - Some((general_class, general_specialization)), - Some((specific_class, specific_specialization)), - ) = ( - general.class_specialization(db, env), - specific.class_specialization(db, env), - ) - else { - return false; - }; - - // Top and bottom materializations are not gradual types. - if general_class != specific_class - || general_specialization.materialization_kind(db).is_some() - || specific_specialization.materialization_kind(db).is_some() - { - return false; - } - - let mut has_dynamic_replacement = false; - for ((typevar, general_type), specific_type) in general_specialization - .generic_context(db) - .variables(db) - .zip(general_specialization.types(db)) - .zip(specific_specialization.types(db)) - { - if general_type == specific_type { - continue; - } - if general_type.is_non_divergent_dynamic() - && typevar.variance(db) == TypeVarVariance::Invariant - { - has_dynamic_replacement = true; - continue; - } - return false; - } - has_dynamic_replacement -} - /// Try to merge a complementary guarded pair into an unguarded core. /// /// e.g. @@ -1212,6 +1158,17 @@ impl<'db> UnionBuilder<'db> { } if should_simplify_full && !matches!(element_type, Type::TypeAlias(_)) { + // Preserving aliases also excludes comparisons that expand aliases nested in + // type arguments. A recursive alias can rebuild this union during specialization. + if !self.unpack_aliases + && [ty, element_type].into_iter().any(|ty| { + any_over_type(db, &self.env, ty, false, |ty| { + matches!(ty, Type::TypeAlias(_)) + }) + }) + { + continue; + } if ty.is_redundant_with(db, &self.env, element_type) { return; } @@ -1528,6 +1485,89 @@ impl<'db> IntersectionBuilder<'db> { } } +/// The signs of a pair of intersection elements. For `Mixed`, the first is positive. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::SalsaValue)] +enum IntersectionPolarity { + Positive, + Negative, + Mixed, +} + +/// Describes the signed intersection elements, so `Disjoint` also covers `S & ~T` when `S <: T`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::SalsaValue, get_size2::GetSize)] +enum IntersectionSimplification { + Unchanged, + FirstRedundant, + SecondRedundant, + Disjoint, +} + +/// Simplify a pair of intersection elements using non-circular relation checks. +/// +/// If this simplification participates in an inference cycle, retain both signed +/// elements. Ordinary type relations keep their usual cycle handling, including for +/// recursive protocols. +/// +/// ```python +/// class C: +/// def __init__(self): +/// if not hasattr(self, "x"): +/// self.x = self.__str__ +/// ``` +/// +/// Inferring `C.x` needs the guarded type of `self`. The guard cannot use that unfinished +/// inference to prove that `C` already satisfies the protocol for `x` and erase the branch. +#[salsa::tracked( + returns(copy), + cycle_result=|_, _, _, _| IntersectionSimplification::Unchanged, + heap_size=ruff_memory_usage::heap_size, +)] +fn simplify_intersection_pair<'db>( + db: &'db dyn Db, + types: TypePair<'db>, + polarity: IntersectionPolarity, +) -> IntersectionSimplification { + let env = ProgramEnvironment::from_program(types.program(db)); + let first = types.first(db); + let second = types.second(db); + + match polarity { + IntersectionPolarity::Positive => { + // S & T = S if S <: T. + if first.is_redundant_with(db, &env, second) { + return IntersectionSimplification::SecondRedundant; + } + let first_redundant = second.is_redundant_with(db, &env, first); + if second.is_disjoint_from(db, &env, first) { + return IntersectionSimplification::Disjoint; + } + if first_redundant { + return IntersectionSimplification::FirstRedundant; + } + } + IntersectionPolarity::Negative => { + // ~S & ~T = ~T if S <: T; the narrower exclusion is redundant. + let first_redundant = first.is_redundant_with(db, &env, second); + if second.is_subtype_of(db, &env, first) { + return IntersectionSimplification::SecondRedundant; + } + if first_redundant { + return IntersectionSimplification::FirstRedundant; + } + } + IntersectionPolarity::Mixed => { + // S & ~T = Never if S <: T, and S & ~T = S if S and T are disjoint. + if first.is_subtype_of(db, &env, second) { + return IntersectionSimplification::Disjoint; + } + if first.is_disjoint_from(db, &env, second) { + return IntersectionSimplification::SecondRedundant; + } + } + } + IntersectionSimplification::Unchanged +} + #[derive(Debug, Clone, Default)] struct InnerIntersectionBuilder<'db> { positive: FxOrderSet>, @@ -1778,51 +1818,59 @@ impl<'db> InnerIntersectionBuilder<'db> { } let mut to_remove = SmallVec::<[usize; 1]>::new(); + let mut replacement = None; for (index, existing_positive) in self.positive.iter().enumerate() { - // S & T = S if S <: T or T is an invariant-dynamic generalization of S. - if existing_positive.is_redundant_with(db, env, new_positive) - || is_invariant_dynamic_generalization_of( - db, - env, - new_positive, - *existing_positive, - ) + if let Some(result) = + generic_gradual_intersection(db, env, new_positive, *existing_positive) { - return; - } - // same rule, reverse order - if new_positive.is_redundant_with(db, env, *existing_positive) - || is_invariant_dynamic_generalization_of( - db, - env, - *existing_positive, - new_positive, - ) - { - to_remove.push(index); + let GenericIntersection::Simplified(merged) = result else { + continue; + }; + if merged == *existing_positive { + return; + } + replacement = Some((index, merged)); + break; } - // A & B = Never if A and B are disjoint - if new_positive.is_disjoint_from(db, env, *existing_positive) { - *self = Self::default(); - self.positive.insert(Type::Never); - return; + match simplify_intersection_pair( + db, + TypePair::new(db, env.program(db), *existing_positive, new_positive), + IntersectionPolarity::Positive, + ) { + IntersectionSimplification::Unchanged => {} + IntersectionSimplification::SecondRedundant => return, + IntersectionSimplification::FirstRedundant => to_remove.push(index), + IntersectionSimplification::Disjoint => { + *self = Self::default(); + self.positive.insert(Type::Never); + return; + } } } + if let Some((index, value)) = replacement { + self.positive.swap_remove_index(index); + self.add_positive(db, env, value); + return; + } for index in to_remove.into_iter().rev() { self.positive.swap_remove_index(index); } let mut to_remove = SmallVec::<[usize; 1]>::new(); for (index, existing_negative) in self.negative.iter().enumerate() { - // S & ~T = Never if S <: T - if new_positive.is_subtype_of(db, env, *existing_negative) { - *self = Self::default(); - self.positive.insert(Type::Never); - return; - } - // A & ~B = A if A and B are disjoint - if existing_negative.is_disjoint_from(db, env, new_positive) { - to_remove.push(index); + match simplify_intersection_pair( + db, + TypePair::new(db, env.program(db), new_positive, *existing_negative), + IntersectionPolarity::Mixed, + ) { + IntersectionSimplification::Unchanged => {} + IntersectionSimplification::SecondRedundant => to_remove.push(index), + IntersectionSimplification::FirstRedundant => return, + IntersectionSimplification::Disjoint => { + *self = Self::default(); + self.positive.insert(Type::Never); + return; + } } } for index in to_remove.into_iter().rev() { @@ -1930,20 +1978,27 @@ impl<'db> InnerIntersectionBuilder<'db> { continue; } - // ~S & ~T = ~T if S <: T - if existing_negative.is_redundant_with(db, env, new_negative) { - to_remove.push(index); - } - // same rule, reverse order - if new_negative.is_subtype_of(db, env, *existing_negative) { - return; + match simplify_intersection_pair( + db, + TypePair::new(db, env.program(db), *existing_negative, new_negative), + IntersectionPolarity::Negative, + ) { + IntersectionSimplification::Unchanged => {} + IntersectionSimplification::SecondRedundant => return, + IntersectionSimplification::FirstRedundant => to_remove.push(index), + IntersectionSimplification::Disjoint => { + *self = Self::default(); + self.positive.insert(Type::Never); + return; + } } } for index in to_remove.into_iter().rev() { self.negative.swap_remove_index(index); } - for existing_positive in &self.positive { + let mut to_remove = SmallVec::<[usize; 1]>::new(); + for (index, existing_positive) in self.positive.iter().enumerate() { if let Some(new_enum) = new_negative_enum { if let Some(existing_enum) = existing_positive.as_enum_literal() && existing_enum.enum_class(db) == new_enum.enum_class(db) @@ -1965,18 +2020,26 @@ impl<'db> InnerIntersectionBuilder<'db> { } } - // S & ~T = Never if S <: T - if existing_positive.is_subtype_of(db, env, new_negative) { - *self = Self::default(); - self.positive.insert(Type::Never); - return; - } - // A & ~B = A if A and B are disjoint - if existing_positive.is_disjoint_from(db, env, new_negative) { - return; + match simplify_intersection_pair( + db, + TypePair::new(db, env.program(db), *existing_positive, new_negative), + IntersectionPolarity::Mixed, + ) { + IntersectionSimplification::Unchanged => {} + IntersectionSimplification::SecondRedundant => return, + IntersectionSimplification::FirstRedundant => to_remove.push(index), + IntersectionSimplification::Disjoint => { + *self = Self::default(); + self.positive.insert(Type::Never); + return; + } } } + for index in to_remove.into_iter().rev() { + self.positive.swap_remove_index(index); + } + self.negative.insert(new_negative); } } diff --git a/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs new file mode 100644 index 0000000000..e6f2067c69 --- /dev/null +++ b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs @@ -0,0 +1,368 @@ +use crate::types::generics::specialization_variance; +use crate::types::tuple::TupleSpec; +use crate::types::visitor::contains_growing_type; +use crate::types::{ + ClassBase, ClassType, IntersectionType, KnownClass, MaterializationKind, Type, TypeVarVariance, + UnionType, +}; +use crate::{Db, ProgramEnvironment}; + +pub(super) enum GenericIntersection<'db> { + /// Replace both intersection elements with this equivalent type. + /// For example, intersecting `list[int]` and `list[Any]` produces `list[int]`. + Simplified(Type<'db>), + + /// Preserve both elements and skip subsequent subtype and disjointness checks for this pair. + /// + /// Those checks can expand a recursive generic type's body and rebuild the intersection with + /// different type arguments. For example: + /// + /// ```python + /// class Co[T]: + /// def get(self) -> T: ... + /// + /// class Child[T](Co[T]): ... + /// + /// type Growing[T] = T | list[Co[Growing[list[T]]] & Child[object]] + /// ``` + /// + /// Checking `Co[Growing[int]] & Child[object]` can require checking + /// `Co[Growing[list[int]]] & Child[object]`, then another intersection with `list[list[int]]`, + /// and so on. Unlike returning `None`, this result tells the caller not to try other reductions + /// that could restart that expansion. + Recursive, +} + +/// Simplify same-class gradual intersections and intersections with top-materialized subclasses. +/// +/// For example, `list[int] & list[Any]` simplifies to `list[int]`, while +/// `Sequence[int] & Sequence[Any]` simplifies to `Sequence[int & Any]`. +/// A recursive pair is returned separately so callers can also skip relation-based reductions. +pub(super) fn generic_gradual_intersection<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + left: Type<'db>, + right: Type<'db>, +) -> Option> { + dynamic_generalization_intersection(db, env, left, right) + .or_else(|| dynamic_generalization_intersection(db, env, right, left)) + .map(GenericIntersection::Simplified) + .or_else(|| base_top_intersection(db, env, left, right)) + .or_else(|| base_top_intersection(db, env, right, left)) +} + +/// Intersect a fully static nominal base with a generic subclass. +/// +/// The subclass's identity MRO determines which subclass type variables specialize the base. +/// Restricting those variables by the base's variance preserves invariant subclass +/// materializations instead of incorrectly collapsing, for example, +/// `Sequence[int] & Top[list[Any]]` to `list[int]`. +/// Subclass arguments that do not specialize the base retain their gradualness. +/// The widest type a covariant parameter can hold, which is its bound where it has one. +/// +/// `frozenset[out Element: Hashable]` tops out at `frozenset[Hashable]`, so `object` alone does not +/// recognise every top-generalized argument — though `object` is still one, being wider. +fn typevar_top<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar: crate::types::BoundTypeVarInstance<'db>, +) -> Type<'db> { + typevar + .typevar(db) + .upper_bound(db, env) + .map_or_else(Type::object, |bound| bound.top_materialization(db, env)) +} + +fn base_top_intersection<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + base: Type<'db>, + subclass: Type<'db>, +) -> Option> { + if !matches!(base, Type::NominalInstance(_) | Type::ProtocolInstance(_)) + || !matches!( + subclass, + Type::NominalInstance(_) | Type::ProtocolInstance(_) + ) + || base.has_dynamic(db, env) + { + return None; + } + + let (base_class, base_specialization) = base.class_specialization(db, env)?; + let (subclass_class, subclass_specialization) = subclass.class_specialization(db, env)?; + + // As a deliberately unsound exception, allow `Iterable` as the base when the subclass is + // nominal or is the `Iterator` protocol. We assume containers and iterators obey their + // behavioral contracts, including agreement between iteration and indexing. + let is_iterable_special_case = base_class.known(db) == Some(KnownClass::Iterable) + && (subclass.is_nominal_instance() + || subclass_class.known(db) == Some(KnownClass::Iterator)); + if !is_iterable_special_case && (!base.is_nominal_instance() || !subclass.is_nominal_instance()) + { + return None; + } + + if base_class == subclass_class { + return None; + } + + let inherited_specialization = subclass_class + .identity_specialization(db) + .iter_mro(db) + .find_map(|ancestor| match ancestor { + ClassBase::Class(ClassType::Generic(alias)) if alias.origin(db) == base_class => { + Some(alias.specialization(db)) + } + _ => None, + })?; + + // Inspect lazy attributes only after establishing that the classes are related. Expanding a + // recursive generic alias or member can re-enter intersection simplification with ever-growing + // type arguments. Exact recursive specializations can still be checked. + if contains_growing_type(db, env, base) { + return Some(GenericIntersection::Recursive); + } + if base_specialization.materialization_kind(db).is_some() + || subclass_specialization.materialization_kind(db) == Some(MaterializationKind::Bottom) + || !base.is_fully_static(db, env) + { + return None; + } + + let subclass_context = subclass_specialization.generic_context(db); + let mut types = subclass_specialization.types(db).to_vec(); + let mut changed = false; + + for ((base_typevar, base_type), inherited_type) in base_specialization + .generic_context(db) + .variables(db) + .zip(base_specialization.types(db)) + .zip(inherited_specialization.types(db)) + { + let Type::TypeVar(subclass_typevar) = *inherited_type else { + return None; + }; + let subclass_index = subclass_context + .variables(db) + .position(|typevar| typevar.identity(db) == subclass_typevar.identity(db))?; + let subclass_type = types[subclass_index]; + + if subclass_type == *base_type { + continue; + } + + let is_top_generalization = match specialization_variance(db, subclass_typevar) { + TypeVarVariance::Covariant => { + subclass_type == Type::object() + || subclass_type == typevar_top(db, env, subclass_typevar) + } + TypeVarVariance::Contravariant => subclass_type.is_never(), + TypeVarVariance::Invariant => { + subclass_specialization.materialization_kind(db) == Some(MaterializationKind::Top) + && subclass_type.is_non_divergent_dynamic() + } + TypeVarVariance::Bivariant => false, + }; + + if !is_top_generalization { + return None; + } + + types[subclass_index] = match specialization_variance(db, base_typevar) { + TypeVarVariance::Covariant => { + IntersectionType::from_two_elements(db, env, subclass_type, *base_type) + } + TypeVarVariance::Contravariant => { + UnionType::from_two_elements(db, env, subclass_type, *base_type) + } + TypeVarVariance::Invariant => *base_type, + TypeVarVariance::Bivariant => return None, + }; + changed = true; + } + + if !changed { + return None; + } + + if subclass_class.known(db) == Some(KnownClass::Tuple) { + let tuple = subclass_specialization.tuple(db)?; + // A homogeneous tuple would lose the shape of any fixed prefix or suffix. + if tuple.fixed_elements().next().is_some() { + return None; + } + let TupleSpec::Variable(variable) = tuple else { + return None; + }; + variable.variable().homogeneous_type()?; + return Some(GenericIntersection::Simplified(Type::homogeneous_tuple( + db, env, types[0], + ))); + } + + let specialization = subclass_context.specialize(db, types); + let specialized = Type::instance( + db, + env, + subclass_class.apply_optional_specialization(db, Some(specialization)), + ); + Some(GenericIntersection::Simplified( + if subclass_specialization.materialization_kind(db) == Some(MaterializationKind::Top) { + specialized.top_materialization(db, env) + } else { + specialized + }, + )) +} + +/// Intersect two specializations of the same generic class if `general` only differs from +/// `specific` by using dynamic types. +/// +/// For example, `list[Any]` dynamically generalizes `list[int]`, while `list[str]` does not. +fn dynamic_generalization_intersection<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + general: Type<'db>, + specific: Type<'db>, +) -> Option> { + // Fast path to avoid performance regressions. + if !general.has_dynamic(db, env) + || matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) + || matches!(specific, Type::TypeVar(_) | Type::NewTypeInstance(_)) + { + return None; + } + + let ( + Some((general_class, general_specialization)), + Some((specific_class, specific_specialization)), + ) = ( + general.class_specialization(db, env), + specific.class_specialization(db, env), + ) + else { + return None; + }; + + // Top and bottom materializations are not gradual types. + if general_class != specific_class + || general_specialization == specific_specialization + || general_specialization.materialization_kind(db).is_some() + || specific_specialization.materialization_kind(db).is_some() + { + return None; + } + + if general_class.known(db) == Some(KnownClass::Tuple) { + let general_tuple = general_specialization.tuple(db)?; + let specific_tuple = specific_specialization.tuple(db)?; + + if let (TupleSpec::Variable(general_variable), TupleSpec::Variable(specific_variable)) = + (general_tuple, specific_tuple) + { + if general_tuple.fixed_elements().next().is_some() + || specific_tuple.fixed_elements().next().is_some() + || specific.has_dynamic(db, env) + { + return None; + } + + let general_element = general_variable.variable().homogeneous_type()?; + if !general_element.is_non_divergent_dynamic() { + return None; + } + let specific_element = specific_variable.variable().homogeneous_type()?; + + return Some(Type::homogeneous_tuple( + db, + env, + IntersectionType::from_two_elements(db, env, specific_element, general_element), + )); + } + + let general_tuple = general_tuple.as_fixed_length()?; + let specific_tuple = specific_tuple.as_fixed_length()?; + if general_tuple.len() != specific_tuple.len() + || general_tuple + .iter_all_elements() + .zip(specific_tuple.iter_all_elements()) + .any(|(general, specific)| { + general != specific && !general.is_non_divergent_dynamic() + }) + { + return None; + } + if specific.has_dynamic(db, env) { + return None; + } + + return Some(Type::heterogeneous_tuple( + db, + env, + specific_tuple + .iter_all_elements() + .zip(general_tuple.iter_all_elements()) + .map(|(specific, general)| { + IntersectionType::from_two_elements(db, env, specific, general) + }), + )); + } + + let generic_context = general_specialization.generic_context(db); + if generic_context + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + .any(|((_, general), specific)| general != specific && !general.is_non_divergent_dynamic()) + { + return None; + } + + let has_variant_replacement = generic_context + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + .any(|((typevar, general), specific)| { + general != specific + && matches!( + specialization_variance(db, typevar), + TypeVarVariance::Covariant | TypeVarVariance::Contravariant + ) + }); + + if !has_variant_replacement { + return Some(specific); + } + + if specific.has_dynamic(db, env) { + return None; + } + + let types: Vec<_> = generic_context + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + .map(|((typevar, general), specific)| { + if general == specific { + return *specific; + } + match specialization_variance(db, typevar) { + TypeVarVariance::Covariant => { + IntersectionType::from_two_elements(db, env, *specific, *general) + } + TypeVarVariance::Contravariant => { + UnionType::from_two_elements(db, env, *specific, *general) + } + TypeVarVariance::Invariant | TypeVarVariance::Bivariant => *specific, + } + }) + .collect(); + let specialization = generic_context.specialize(db, types); + + Some(Type::instance( + db, + env, + general_class.apply_optional_specialization(db, Some(specialization)), + )) +} diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 5e290e52c4..e7acd7ddb0 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -23,7 +23,7 @@ use smallvec::{SmallVec, smallvec_inline}; use super::{DynamicType, Type, TypeVarVariance, UnionType, semantic_index}; use crate::types::UnpackedKwargs; -use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; +use crate::types::callable::CallableTypeKind; use crate::types::constraints::{ ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, OwnedConstraintSet, PathBounds, Solutions, @@ -33,7 +33,9 @@ use crate::types::generics::{ ApplySpecialization, GenericContext, Specialization, SpecializationBuilder, TypeVarInference, walk_generic_context, }; -use crate::types::infer::{TypeExpressionFlags, infer_deferred_types}; +use crate::types::infer::{ + TypeExpressionFlags, infer_deferred_types, infer_function_default_types, +}; use crate::types::inferred_signature::inferred_parameter_type; use crate::types::instance::ProtocolInstanceType; use crate::types::relation::{ @@ -50,12 +52,13 @@ use crate::types::{ CallableType, ErrorContext, ErrorContextTree, FindLegacyTypeVarsVisitor, KnownClass, MaterializationKind, ParamSpecAttrKind, ParameterDescription, SelfBinding, TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarNonce, TypedDictType, UnionBuilder, - VarianceInferable, infer_complete_scope_types, todo_type, + VarianceInferable, VarianceTerm, infer_complete_scope_types, todo_type, }; use crate::{Db, FxOrderSet}; +use ruff_db::parsed::parsed_module; use ruff_python_ast::helpers::ReturnGuardForm; use ruff_python_ast::{self as ast, ParameterBorrow, name::Name}; -use ty_python_core::definition::Definition; +use ty_python_core::definition::{Definition, DefinitionKind, ParameterDefinitionNodeKind}; /// Selects which binding context to use for type variables that only appear in a return-position /// `Callable`. @@ -75,7 +78,7 @@ pub(super) enum ReturnCallableTypeVarScope { /// be deferred. (This prevents spurious salsa cycles when we need the signature of the function /// while in the middle of inferring its definition scope — for instance, when applying /// decorators.) -fn function_signature_expression_type<'db>( +pub(super) fn function_signature_expression_type<'db>( db: &'db dyn Db, definition: Definition<'db>, expression: &ast::Expr, @@ -389,7 +392,9 @@ impl<'db> CallableSignature<'db> { type_mapping.update_signature_generic_context(db, env, context) }), ), - definition: signature.definition, + // Keep the enclosing method's definition for binding `Self` and + // other receiver type variables after specializing its parameters. + definition: self_signature.definition, source_overload_index: signature.source_overload_index, receiver_constraints: { let mapped = self_signature.map_receiver_constraints( @@ -603,11 +608,13 @@ impl<'db> VarianceInferable<'db> for &CallableSignature<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - self.overloads - .iter() - .map(|signature| signature.variance_of(db, env, typevar)) - .collect() + ) -> VarianceTerm<'db> { + VarianceTerm::join( + db, + self.overloads + .iter() + .map(|signature| signature.variance_of(db, env, typevar)), + ) } } @@ -802,6 +809,18 @@ pub(super) fn walk_signature<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, signature: &Signature<'db>, visitor: &V, +) { + walk_signature_without_return_type(db, signature, visitor); + visitor.visit_type(db, signature.return_ty); +} + +pub(super) fn walk_signature_without_return_type< + 'db, + V: super::visitor::TypeVisitor<'db> + ?Sized, +>( + db: &'db dyn Db, + signature: &Signature<'db>, + visitor: &V, ) { if let Some(generic_context) = &signature.generic_context { walk_generic_context(db, *generic_context, visitor); @@ -814,7 +833,6 @@ pub(super) fn walk_signature<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( for parameter in &signature.parameters { visitor.visit_type(db, parameter.annotated_type()); } - visitor.visit_type(db, signature.return_ty); } /// Describes how a `functools.partial(...)` call binds one overload's parameters. @@ -1199,7 +1217,7 @@ impl<'db> Signature<'db> { .flat_map(|context| context.variables(db)) .map(Type::TypeVar); let parameters = self.parameters.iter().flat_map(|parameter| { - std::iter::once(parameter.annotated_type()).chain(parameter.default_type()) + std::iter::once(parameter.annotated_type()).chain(parameter.eager_default_type()) }); let types = typevars .chain(self.receiver_constraint_types()) @@ -1228,7 +1246,7 @@ impl<'db> Signature<'db> { typevars, visitor, ); - if let Some(ty) = param.default_type() { + if let Some(ty) = param.eager_default_type() { ty.find_legacy_typevars_impl(db, env, binding_context, typevars, visitor); } } @@ -1375,7 +1393,7 @@ impl<'db> Signature<'db> { .any(|parameter| { !parameter.is_variadic() && !parameter.is_keyword_variadic() - && parameter.default_type().is_none() + && !parameter.has_default() }) } @@ -1404,9 +1422,7 @@ impl<'db> Signature<'db> { base: &Signature<'db>, ) { for parameter in &mut Arc::make_mut(&mut self.parameters.data).value { - if parameter.default_type().is_some() - || parameter.is_variadic() - || parameter.is_keyword_variadic() + if parameter.has_default() || parameter.is_variadic() || parameter.is_keyword_variadic() { continue; } @@ -1420,7 +1436,7 @@ impl<'db> Signature<'db> { .find(|candidate| { candidate.name() == Some(name) && parameter_kind_tag(&candidate.kind) == kind }) - .and_then(Parameter::default_type) + .and_then(|candidate| candidate.default_type(db)) else { continue; }; @@ -1688,6 +1704,14 @@ impl<'db> Signature<'db> { if receiver.has_typevar(db, env) { return false; } + // basedpython: a use-site projection is a *view* of the receiver — `S[out int]` is an + // `S[int]` a caller has undertaken only to read. Whether it satisfies the domain is a + // question about the object, and the object is the same one, so a projected receiver is + // never provably in violation. Judging the view instead rejects every overload of every + // method reached through one. + if receiver.has_use_site_projection(db, env) { + return false; + } !match domain { TypeVarBoundOrConstraints::UpperBound(bound) => { @@ -1701,58 +1725,59 @@ impl<'db> Signature<'db> { } } - /// Returns this signature bound to `receiver_type` if its explicit receiver annotation is - /// compatible with the bound receiver. + /// Specializes this signature using the type variables determined by its bound receiver. /// /// Matching the receiver can constrain type variables that occur elsewhere in the signature. - /// Exact bounds determine an unambiguous specialization; one-sided constraints remain attached - /// to the bound signature for later relation checks. - pub(crate) fn bind_self_if_compatible( + /// Exact bounds determine an unambiguous specialization; one-sided constraints remain + /// available to normal call inference. A `ParamSpec` can capture multiple overloads, so one + /// signature can expand into several. The receiver remains in each returned signature so + /// bound-method calls can still check it and report receiver-related diagnostics. + pub(crate) fn specialize_for_bound_receiver( &self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, receiver_type: Type<'db>, typing_self_type: Type<'db>, - ) -> Option { - if !self.can_bind_self_to(db, env, receiver_type) { - return None; - } - + ) -> Option> { let bound_signature = self.bind_self_with_receiver(db, env, Some(receiver_type), Some(typing_self_type)); let Some(receiver_constraints) = bound_signature.receiver_constraints.as_ref() else { - return Some(bound_signature); + return Some(CallableSignature::single(self.clone())); }; let constraints = ConstraintSetBuilder::new(); let when = constraints.load(db, env, receiver_constraints); let inferable = self.inferable_typevars(db); - match when.solutions(db, env, &constraints, inferable) { - Solutions::Unsatisfiable => return None, - Solutions::Unconstrained => return Some(bound_signature), + match when.solutions(db, env, inferable) { + Ok(Solutions::Unsatisfiable) => return None, + Ok(Solutions::Unconstrained) | Err(_) => { + return Some(CallableSignature::single(self.clone())); + } // Each receiver path can leave a different type variable unconstrained. Preserve the // original relation instead of combining those independent solutions. - Solutions::Constrained(solutions) if solutions.len() > 1 => { - return Some(bound_signature); + Ok(Solutions::Constrained(solutions)) if solutions.as_slice().len() > 1 => { + return Some(CallableSignature::single(self.clone())); } - Solutions::Constrained(_) => {} + Ok(Solutions::Constrained(_)) => {} } let Some(generic_context) = self.generic_context else { - return Some(bound_signature); + return Some(CallableSignature::single(self.clone())); }; - let mut builder = SpecializationBuilder::new(db, env, &constraints, inferable); + let mut builder = SpecializationBuilder::new(db, env, &constraints, generic_context); builder.add_constraint_set(when).ok()?; let concrete_class_receiver = matches!(receiver_type, Type::ClassLiteral(_) | Type::GenericAlias(_)); - let specialization = builder.build_with(generic_context, |typevar, bounds| { + let specialization = builder.build_merged_with(|typevar, bounds| { if let Some(bounds) = bounds - && let Some(lower) = bounds.lower - && let Some(upper) = bounds.upper.as_single_bound(db, env) + && let Some(lower) = bounds.evidence_lower() + && bounds.has_upper_evidence() + && let Some(upper) = bounds.as_single_upper_bound(db, env) && lower.is_equivalent_to(db, env, upper) - && let Ok(Some(solution)) = PathBounds::default_solve(db, env, &constraints, bounds) + && let Some(solution) = + PathBounds::default_solve(db, env, &constraints, bounds).as_type() { return Some(solution); } @@ -1761,9 +1786,13 @@ impl<'db> Signature<'db> { && concrete_class_receiver && bound_signature .variance_of(db, env, typevar.identity(db)) + .evaluate(db) .is_covariant() - && bounds.lower.is_some_and(|lower| !lower.is_never()) - && let Ok(Some(solution)) = PathBounds::default_solve(db, env, &constraints, bounds) + && bounds + .evidence_lower() + .is_some_and(|lower| !lower.is_never()) + && let Some(solution) = + PathBounds::default_solve(db, env, &constraints, bounds).as_type() { return Some(solution); } @@ -1771,10 +1800,46 @@ impl<'db> Signature<'db> { Some(Type::TypeVar(typevar)) }); - Some( - self.apply_specialization(db, specialization) - .bind_self_with_receiver(db, env, Some(receiver_type), Some(typing_self_type)), - ) + let type_mapping = + TypeMapping::ApplySpecialization(ApplySpecialization::specialization(specialization)); + let mut specialized = CallableSignature::single(self.clone()).apply_type_mapping_impl( + db, + &type_mapping, + TypeContext::default(), + &ApplyTypeMappingVisitor::new(env), + ); + + // The captured `ParamSpec` can carry overload indices from another callable. Keep + // this method's overload index so call diagnostics refer to the correct declaration. + for signature in &mut specialized.overloads { + signature.source_overload_index = self.source_overload_index; + } + + Some(specialized) + } + + /// Returns this signature bound to `receiver_type` if its explicit receiver annotation is + /// compatible with the bound receiver. + pub(crate) fn bind_self_if_compatible( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + receiver_type: Type<'db>, + typing_self_type: Type<'db>, + ) -> Option> { + if !self.can_bind_self_to(db, env, receiver_type) { + return None; + } + + self.specialize_for_bound_receiver(db, env, receiver_type, typing_self_type) + .map(|signature| { + signature.bind_self_with_receiver( + db, + env, + Some(receiver_type), + Some(typing_self_type), + ) + }) } /// Returns `true` if this signature's first parameter can accept the bound `self` type. @@ -1818,8 +1883,6 @@ impl<'db> Signature<'db> { return true; } - // TODO: Expand type aliases here so `type Alias = Self` in a class body - // participates in receiver-specific overload pruning. expected_self_ty = expected_self_ty.bind_self_typevars(db, env, self_type); // `Self` binding can make the receiver annotation trivially compatible. @@ -1856,6 +1919,101 @@ impl<'db> Signature<'db> { .is_some_and(|parameter| parameter.is_positional() && !parameter.inferred_annotation) } + /// Returns whether the receiver can determine a method type variable used elsewhere. + /// + /// Receiver-only variables cannot affect the rest of the signature, class type variables are + /// handled by class or constructor inference, and `typing.Self` is handled by receiver binding. + pub(crate) fn has_receiver_determined_method_typevar( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + let Some(receiver) = self + .parameters + .get(0) + .filter(|parameter| parameter.is_positional() && !parameter.inferred_annotation) + else { + return false; + }; + let Some(generic_context) = self.generic_context else { + return false; + }; + let Some(definition) = self.definition else { + return false; + }; + let annotation = receiver.annotated_type(); + + let mut typevars = match annotation { + Type::TypeVar(typevar) => Either::Left(std::iter::once(typevar)), + Type::SubclassOf(subclass) if let Some(typevar) = subclass.into_type_var() => { + Either::Left(std::iter::once(typevar)) + } + _ => Either::Right(generic_context.variables(db)), + }; + + typevars.any(|typevar| { + let variable = typevar.typevar(db); + let identity = variable.identity(db); + + typevar.binding_context(db).definition() == Some(definition) + && !variable.is_self(db) + && annotation.references_typevar_through_aliases(db, env, identity) + && (self + .return_ty + .references_typevar_through_aliases(db, env, identity) + || self.parameters.iter().skip(1).any(|parameter| { + parameter + .annotated_type() + .references_typevar_through_aliases(db, env, identity) + })) + }) + } + + /// basedpython: this signature with `Self` substituted inside its type variables' bounds. + /// + /// Returns a clone unchanged where no bound names `Self`, which is nearly every signature. + pub(crate) fn with_self_bounded_typevars( + &self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + self_type: Type<'db>, + ) -> Self { + let Some(generic_context) = self.generic_context else { + return self.clone(); + }; + if !generic_context.has_self_bounded_variable(db, env) { + return self.clone(); + } + let binding_context = self.definition.map(BindingContext::Definition); + let bind_self = + TypeMapping::BindSelf(SelfBinding::new(db, env, self_type, binding_context)); + let visitor = ApplyTypeMappingVisitor::new(env); + + // The bound is read off the type variable wherever it turns up, not off this list, so + // every occurrence has to become the rewritten variable — in the parameters and the + // return as much as here. + let mut signature = self.clone(); + for variable in generic_context.variables(db) { + let rewritten = variable.map_bound_or_constraints(db, |original| { + Some(original?.apply_type_mapping_impl(db, env, &bind_self, &visitor)) + }); + if rewritten == variable { + continue; + } + let substitute = TypeMapping::ApplySpecialization(ApplySpecialization::Single( + variable, + Type::TypeVar(rewritten), + )); + signature = signature.apply_type_mapping_impl( + db, + &substitute, + TypeContext::default(), + &visitor, + ); + } + signature + } + pub(crate) fn has_implicit_positional_receiver_annotation(&self) -> bool { self.parameters .get(0) @@ -1997,7 +2155,7 @@ impl<'db> Signature<'db> { fn apply_specialization(&self, db: &'db dyn Db, specialization: Specialization<'db>) -> Self { let env = &ProgramEnvironment::from_program(specialization.generic_context(db).program(db)); let type_mapping = - TypeMapping::ApplySpecialization(ApplySpecialization::Specialization(specialization)); + TypeMapping::ApplySpecialization(ApplySpecialization::specialization(specialization)); self.apply_type_mapping_impl( db, &type_mapping, @@ -2124,14 +2282,16 @@ impl<'db> Signature<'db> { .collect(); if promoted_typevars.is_empty() { - return Some(inference.specialization(db)); + return Some(inference.merged_specialization(db)); } - Some(inference.specialization_with(db, |typevar, inferred| { - promoted_typevars - .contains(&typevar.identity(db)) - .then(|| inferred.map_or(Type::TypeVar(typevar), |ty| ty.promote(db, env))) - })) + Some( + inference.merged_specialization_with(db, |typevar, inferred| { + promoted_typevars + .contains(&typevar.identity(db)) + .then(|| inferred.map_or(Type::TypeVar(typevar), |ty| ty.promote(db, env))) + }), + ) } fn needs_self_mapping( @@ -2140,8 +2300,6 @@ impl<'db> Signature<'db> { env: &ProgramEnvironment<'db>, receiver_is_removed: bool, ) -> bool { - // TODO: Expand type aliases here so `type Alias = Self` in parameters or returns - // triggers binding when a method is accessed on a concrete receiver. self.return_ty.contains_self(db, env) || self .parameters @@ -2166,7 +2324,7 @@ impl<'db> Signature<'db> { } } - pub(crate) fn is_non_generic(&self) -> bool { + fn is_non_generic(&self) -> bool { self.generic_context.is_none() } @@ -2315,7 +2473,6 @@ impl<'db> Signature<'db> { ) })), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_upper_bound( db, @@ -2406,7 +2563,7 @@ impl<'db> VarianceInferable<'db> for &Signature<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { tracing::trace!( "Checking variance of `{tvar}` in `{self:?}`", tvar = typevar.identity.name(db) @@ -2436,11 +2593,13 @@ impl<'db> VarianceInferable<'db> for &Signature<'db> { Either::Right(self.parameters.iter().map(parameter_variance)) }; - itertools::chain( - parameter_variances, - Some(self.return_ty.variance_of(db, env, typevar)), + VarianceTerm::join( + db, + itertools::chain( + parameter_variances, + Some(self.return_ty.variance_of(db, env, typevar)), + ), ) - .collect() } } @@ -2595,7 +2754,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }, )), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_upper_bound( db, @@ -2650,7 +2808,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_lower_bound( db, @@ -2665,9 +2822,8 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.without_context_collection(|| { source_overloads .iter() - .map(|signature| signature.return_ty) - .when_any(db, self.constraints, |source_return| { - self.check_type_pair(db, source_return, target_return) + .when_any(db, self.constraints, |signature| { + self.check_paramspec_return_pair(db, signature, target_return) }) }) }; @@ -2791,33 +2947,15 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { target }; - // `inferable` has different roles in the two type-variable evaluation modes: - // - // * Eager comparisons decide whether the relation holds immediately. An unbound generic - // method's `Self` can have an upper bound such as `C[T]`, so `T` must also be - // inferable; otherwise, a concrete receiver such as `C[int]` is compared against a - // fixed, symbolic `T` and valid higher-order calls are rejected. - // * Lazy comparisons record constraints for every type variable, regardless of whether - // it is inferable. Here, `signature_inferable` also determines which type variables - // `reduce_inferable` existentially removes below, so it must contain only variables - // actually bound by these signatures. Including an enclosing class's `T` would turn a - // decorator's return constraint `T <= R` into `exists T. T <= R`, losing the - // relationship needed to infer `R = T`. - let include_bound_dependencies = self.typevar_evaluation == TypeVarEvaluation::Eager; let signature_typevars = |signature: &Signature<'db>| { signature .generic_context - .map_or(TypeVarSet::None, |context| { - if include_bound_dependencies { - context.inferable_typevars(db) - } else { - TypeVarSet::from_typevars(db, context.variables(db)) - } - }) + .map_or(TypeVarSet::None, |context| context.inferable_typevars(db)) }; let source_inferable = signature_typevars(source); let target_inferable = signature_typevars(target); let signature_inferable = source_inferable.merge(db, target_inferable); + let inferable = self.inferable.merge(db, signature_inferable); // `inner` will create a constraint set that references these newly inferable typevars. @@ -2872,6 +3010,35 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .visit(&key, || self.always(), work) } + fn check_paramspec_return_pair( + &self, + db: &'db dyn Db, + source: &Signature<'db>, + target: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if self.relation.is_assignability() + && self.typevar_evaluation == TypeVarEvaluation::Lazy + && target.resolve_type_alias(db).is_dynamic() + && let Type::TypeVar(typevar) = source.return_ty.resolve_type_alias(db) + && source.parameters().iter().any(|parameter| { + any_over_type(db, self.env, parameter.annotated_type(), false, |ty| { + matches!(ty, Type::TypeVar(other) if other.is_same_typevar_as(db, typevar)) + }) + }) + { + // Comparing the generic callable `(value: T) -> T` against the declared type + // `Callable[P, Any]` contributes the constraint `T <= Any`, despite the gradual return + // type not constraining the callable-scoped type variable, so we ignore the constraints + // in this case. + // + // TODO: Remove this special case once `ParamSpec` inference correctly handles captured + // type variables when solving return-type constraints. + self.always() + } else { + self.check_type_pair(db, source.return_ty, target) + } + } + fn check_signature_pair_inner( &self, db: &'db dyn Db, @@ -2956,6 +3123,20 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let mut source_parameters = source.parameters.expand_starred_variadic_annotations(db); let mut target_parameters = target.parameters.expand_starred_variadic_annotations(db); + // Expanding `*args: *tuple[*tuple[int, ...], str]` creates a synthetic positional `str` + // parameter immediately after the variadic `int` parameter. Check whether either + // signature contains such a positional suffix. + let source_has_unpacked_suffix = source_parameters.variadic().is_some_and(|(index, _)| { + source_parameters + .get(index + 1) + .is_some_and(Parameter::is_positional) + }); + let target_has_unpacked_suffix = target_parameters.variadic().is_some_and(|(index, _)| { + target_parameters + .get(index + 1) + .is_some_and(Parameter::is_positional) + }); + // Gradual variadics and TypeVarTuples need their original suffix boundaries for // materialization and inference. Named source prefixes must also remain visible when a // target keyword could fill the same parameter. @@ -3093,7 +3274,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // Avoid returning early after checking the return types in case there is a `ParamSpec` type // variable in either signature to ensure that the `ParamSpec` binding is still applied even // if the return types are incompatible. - let return_type_constraints = self.check_type_pair(db, source.return_ty, target.return_ty); + let return_type_constraints = if target_parameters.as_paramspec_with_prefix().is_some() { + self.check_paramspec_return_pair(db, source, target.return_ty) + } else { + self.check_type_pair(db, source.return_ty, target.return_ty) + }; let return_type_checks = !result .intersect(db, self.constraints, return_type_constraints) .is_never_satisfied(db, env); @@ -3106,6 +3291,55 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }); } + // Concrete target keywords can collide with a fixed source prefix even when the source + // has a gradual or ParamSpec tail. Check them before those specialized paths can return. + let mut keyword_collision_checks = true; + if (source_has_unpacked_suffix || target_has_unpacked_suffix) + && target_parameters.is_standard() + { + // A target call can fill a source parameter positionally and also pass its name as a + // keyword. A matching target prefix protects that name only when the same call must + // already have filled the target parameter, including every prefix before a suffix. + for (source_index, source_parameter) in source_parameters.positional().enumerate() { + let Some(source_name) = source_parameter.keyword_name() else { + continue; + }; + + if target_parameters.variadic().is_none() + && source_index >= target_parameters.positional().count() + { + continue; + } + + let target_keyword = target_parameters.keyword_by_name(source_name.as_str()); + if target_keyword.is_some_and(|(target_index, target_parameter)| { + target_parameter.is_positional() + && (target_index <= source_index || target_has_unpacked_suffix) + }) { + continue; + } + + let Some((_, keyword)) = + target_keyword.or_else(|| target_parameters.keyword_variadic()) + else { + continue; + }; + + // The keyword must be uninhabited to avoid the collision. Keep this as a + // constraint so gradual types can materialize to `Never` and inferable type + // variables can be constrained to it. + let no_collision = self.check_type_pair(db, keyword.annotated_type(), Type::Never); + if result + .intersect(db, self.constraints, no_collision) + .is_never_satisfied(db, env) + { + // Still allow the ParamSpec handling below to preserve its inferred binding. + keyword_collision_checks = false; + break; + } + } + } + let check_types = |result: &mut ConstraintSet<'db, 'c>, target_ty: Type<'db>, source_ty: Type<'db>, @@ -3198,7 +3432,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { Type::unknown(), )), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_prefix_matches = ConstraintSet::constrain_typevar_lower_bound( db, @@ -3229,7 +3462,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { Type::unknown(), )), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_upper_bound( db, @@ -3359,7 +3591,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(source.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_prefix_matches = ConstraintSet::constrain_typevar_lower_bound( @@ -3388,7 +3619,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(target.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_prefix_matches = ConstraintSet::constrain_typevar_upper_bound( @@ -3428,7 +3658,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(source.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_lower_bound( db, @@ -3575,7 +3804,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(source.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_prefix_matches = ConstraintSet::constrain_typevar_lower_bound( db, @@ -3603,7 +3831,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(target.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_matches = ConstraintSet::constrain_typevar_upper_bound( db, @@ -3720,7 +3947,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .with_source_overload_index(target.source_overload_index()), ), CallableTypeKind::ParamSpecValue, - CallableFunctionProvenance::None, )); let param_spec_prefix_matches = ConstraintSet::constrain_typevar_upper_bound( db, @@ -3739,7 +3965,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } - if !return_type_checks { + if !return_type_checks || !keyword_collision_checks { return result; } @@ -3905,9 +4131,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { default_type: source_default, .. } => { - if source_default.is_none() - && target_param.default_type().is_some() - { + if source_default.is_none() && target_param.has_default() { return self.never(); } if !check_types( @@ -4537,6 +4761,24 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { default_type: target_default, } => { if let Some(source_param) = source_keywords.remove(&**target_name) { + // The suffix forces a target prefix to be positional, so it cannot + // provide a source parameter that must be supplied by keyword. + if target_has_unpacked_suffix + && source_param.is_keyword_only() + && !source_param.has_default() + && !target_param.is_keyword_only() + { + if let Some(context) = self.report_context() { + context.push(ErrorContext::ExtraRequiredParameter { + parameter: ParameterDescription::new( + target_index, + source_param.name(), + ), + }); + } + return self.never(); + } + match source_param.kind() { ParameterKind::PositionalOrKeyword { default_type: source_default, @@ -4599,6 +4841,32 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } return self.never(); }; + + // An explicit source keyword takes precedence over its `**kwargs`. Unless + // the target also binds that name explicitly, values from its keyword + // variadic must therefore be compatible with the source parameter too. + for source_param in &source_parameters { + let Some(source_name) = source_param.keyword_name() else { + continue; + }; + if !source_keywords.contains_key(source_name.as_str()) + || target_parameters + .keyword_by_name(source_name.as_str()) + .is_some() + { + continue; + } + if !check_types( + &mut result, + target_param.annotated_type(), + source_param.annotated_type(), + Some(source_name), + target_index, + ) { + return result; + } + } + if !check_types( &mut result, target_param.annotated_type(), @@ -4623,7 +4891,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { reason = "any required unmatched keyword parameter makes the relation invalid" )] for (_, source_param) in source_keywords { - if source_param.default_type().is_none() { + if !source_param.has_default() { if let Some(context) = self.report_context() { let parameter = ParameterDescription::new(target_index, source_param.name()); context.push(ErrorContext::ExtraRequiredParameter { parameter }); @@ -5089,6 +5357,23 @@ impl<'db> Parameters<'db> { matches!(self.data.kind, ParametersKind::Top) } + /// Returns whether this is the bottom parameter list, `(*args: object, **kwargs: object)`, + /// which accepts every call. + pub(crate) fn is_bottom(&self) -> bool { + // `Parameters::top()` stores the same parameter list, but `ParametersKind::Top` + // makes it reject every call. Bottom parameters use `ParametersKind::Standard`, + // so check the kind before checking the parameter types. + self.is_standard() + && matches!( + self.as_slice(), + [variadic, keyword_variadic] + if variadic.is_variadic() + && variadic.annotated_type().is_object() + && keyword_variadic.is_keyword_variadic() + && keyword_variadic.annotated_type().is_object() + ) + } + /// Returns `true` if the parameters are a standard parameter list (not gradual, top, /// `ParamSpec`, or `Concatenate`). pub(crate) fn is_standard(&self) -> bool { @@ -5216,7 +5501,7 @@ impl<'db> Parameters<'db> { /// Return parameters that represents `(*args: object, **kwargs: object)`, the bottom signature /// (accepts any call, so subtype of all other signatures.) - fn bottom() -> Self { + pub(crate) fn bottom() -> Self { Self::new( [ Parameter::variadic(Name::new_static("args")).with_annotated_type(Type::object()), @@ -5262,15 +5547,11 @@ impl<'db> Parameters<'db> { node_index: _, } = parameters; + let index = semantic_index(db, definition.program_file(db)); let env = ProgramEnvironment::from_definition(definition); let default_type = |param: &ast::ParameterWithDefault| { - param.default().map(|default| { - // Use the same approach as function_signature_expression_type to avoid cycles. - // Defaults are always deferred (see infer_function_definition), so we can go - // directly to infer_deferred_types without first checking infer_definition_types. - infer_deferred_types(db, definition) - .expression_type(default) - .replace_parameter_defaults(db, &env) + param.default().map(|_| { + ParameterDefault::Deferred(index.expect_single_definition(¶m.parameter)) }) }; @@ -5929,6 +6210,13 @@ impl<'db> Parameter<'db> { self } + /// Set the inferred type without displaying it as an explicit annotation. + pub(super) fn with_inferred_type(mut self, inferred_type: Type<'db>) -> Self { + self.annotated_type = inferred_type; + self.inferred_annotation = true; + self + } + pub(crate) fn with_starred_annotation(mut self) -> Self { self.annotation_kind = ParameterAnnotationKind::Starred; self @@ -5979,11 +6267,13 @@ impl<'db> Parameter<'db> { /// /// A variadic parameter stands for a run of arguments rather than one, so there is nothing /// for a caller to leave out and nothing a default would mean. - pub(crate) fn set_default_type(&mut self, default: Type<'db>) { + fn set_default_type(&mut self, default: Type<'db>) { match &mut self.kind { ParameterKind::PositionalOnly { default_type, .. } | ParameterKind::PositionalOrKeyword { default_type, .. } - | ParameterKind::KeywordOnly { default_type, .. } => *default_type = Some(default), + | ParameterKind::KeywordOnly { default_type, .. } => { + *default_type = Some(ParameterDefault::Inferred(default)); + } ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => { panic!("cannot set default value for variadic parameter") } @@ -6096,61 +6386,27 @@ impl<'db> Parameter<'db> { kind, } = self; - let annotated_type = if nested { - annotated_type.recursive_type_normalized_impl(db, env, div, true)? - } else { - annotated_type - .recursive_type_normalized_impl(db, env, div, true) - .unwrap_or(div) + let normalize_type = |ty: Type<'db>| { + let normalized = ty.recursive_type_normalized_impl(db, env, div, true); + if nested { + normalized + } else { + Some(normalized.unwrap_or(div)) + } }; + let annotated_type = normalize_type(*annotated_type)?; - let kind = match kind { - ParameterKind::PositionalOnly { name, default_type } => ParameterKind::PositionalOnly { - name: name.clone(), - default_type: match default_type { - Some(ty) if nested => { - Some(ty.recursive_type_normalized_impl(db, env, div, true)?) - } - Some(ty) => Some( - ty.recursive_type_normalized_impl(db, env, div, true) - .unwrap_or(div), - ), - None => None, - }, - }, - ParameterKind::PositionalOrKeyword { name, default_type } => { - ParameterKind::PositionalOrKeyword { - name: name.clone(), - default_type: match default_type { - Some(ty) if nested => { - Some(ty.recursive_type_normalized_impl(db, env, div, true)?) - } - Some(ty) => Some( - ty.recursive_type_normalized_impl(db, env, div, true) - .unwrap_or(div), - ), - None => None, - }, + let mut kind = kind.clone(); + match &mut kind { + ParameterKind::PositionalOnly { default_type, .. } + | ParameterKind::PositionalOrKeyword { default_type, .. } + | ParameterKind::KeywordOnly { default_type, .. } => { + if let Some(ParameterDefault::Inferred(ty)) = default_type { + *ty = normalize_type(*ty)?; } } - ParameterKind::KeywordOnly { name, default_type } => ParameterKind::KeywordOnly { - name: name.clone(), - default_type: match default_type { - Some(ty) if nested => { - Some(ty.recursive_type_normalized_impl(db, env, div, true)?) - } - Some(ty) => Some( - ty.recursive_type_normalized_impl(db, env, div, true) - .unwrap_or(div), - ), - None => None, - }, - }, - ParameterKind::Variadic { name } => ParameterKind::Variadic { name: name.clone() }, - ParameterKind::KeywordVariadic { name } => { - ParameterKind::KeywordVariadic { name: name.clone() } - } - }; + ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => {} + } Some(Self { annotated_type, @@ -6362,9 +6618,31 @@ impl<'db> Parameter<'db> { .map(|name| ParameterDisplayName { name, prefix }) } - /// Default-value type of the parameter, if any. - pub(crate) fn default_type(&self) -> Option> { - self.kind.default_type() + /// Returns whether this parameter has a default without inferring its type. + pub(crate) fn has_default(&self) -> bool { + self.default().is_some() + } + + fn default(&self) -> Option> { + match self.kind { + ParameterKind::PositionalOnly { default_type, .. } + | ParameterKind::PositionalOrKeyword { default_type, .. } + | ParameterKind::KeywordOnly { default_type, .. } => default_type, + ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => None, + } + } + + /// Infer the default-value type only when its value is needed, such as for display or a + /// dataclass field specifier. Callable compatibility only needs [`Self::has_default`]. + pub(crate) fn default_type(&self, db: &'db dyn Db) -> Option> { + self.default().map(|default| default.ty(db)) + } + + /// Returns a default type stored directly in the signature, without running inference. + /// Deferred source defaults return `None`, even if their type is already cached. Use + /// [`Self::default_type`] when the actual default type is needed. + pub(crate) fn eager_default_type(&self) -> Option> { + self.default().and_then(ParameterDefault::eager_type) } /// Rewrites a positional-or-keyword parameter as keyword-only while preserving its metadata. @@ -6380,6 +6658,73 @@ impl<'db> Parameter<'db> { } } +/// A parameter default whose presence is known without evaluating its type. +/// +/// Defaults on function definitions retain the parameter's stable definition identity. Synthesized +/// signatures, including partially applied callables, can instead supply an already inferred type. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub enum ParameterDefault<'db> { + /// An already inferred default. + Inferred(Type<'db>), + /// A source parameter whose default is inferred on demand. + Deferred(Definition<'db>), +} + +impl<'db> ParameterDefault<'db> { + fn ty(self, db: &'db dyn Db) -> Type<'db> { + match self { + Self::Inferred(ty) => ty, + Self::Deferred(parameter) => parameter_default_type(db, parameter), + } + } + + fn eager_type(self) -> Option> { + match self { + Self::Inferred(ty) => Some(ty), + Self::Deferred(_) => None, + } + } + + fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + match self { + Self::Inferred(ty) => Self::Inferred(f(ty)), + // A source default is a runtime value, not part of the callable's type parameters. + // Specializing or otherwise transforming the signature must not evaluate it. + Self::Deferred(_) => self, + } + } +} + +#[salsa::tracked( + returns(copy), + cycle_initial=|_, id, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, ty: Type<'db>, parameter: Definition<'db>| { + ty.cycle_normalized(db, &ProgramEnvironment::from_definition(parameter), *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size +)] +fn parameter_default_type<'db>(db: &'db dyn Db, parameter: Definition<'db>) -> Type<'db> { + let DefinitionKind::Parameter(ParameterDefinitionNodeKind::Parameter(node)) = + parameter.kind(db) + else { + return Type::unknown(); + }; + let Some(function) = parameter.scope(db).node(db).as_function() else { + return Type::unknown(); + }; + let program_file = parameter.program_file(db); + let function = semantic_index(db, program_file).expect_single_definition(function); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let Some(default) = node.node(&module).default() else { + return Type::unknown(); + }; + // Use the function's default inference so the default retains its annotation context. + // Nested callable defaults still need the existing cycle-breaking normalization. + infer_function_default_types(db, function) + .expression_type(default) + .replace_parameter_defaults(db, &ProgramEnvironment::from_definition(function)) +} + #[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum ParameterKind<'db> { /// Positional-only parameter, e.g. `def f(x, /): ...` @@ -6389,14 +6734,14 @@ pub enum ParameterKind<'db> { /// It is possible for signatures to be defined in ways that leave positional-only parameters /// nameless (e.g. via `Callable` annotations). name: Option, - default_type: Option>, + default_type: Option>, }, /// Positional-or-keyword parameter, e.g. `def f(x): ...` PositionalOrKeyword { /// Parameter name. name: Name, - default_type: Option>, + default_type: Option>, }, /// Variadic parameter, e.g. `def f(*args): ...` @@ -6409,7 +6754,7 @@ pub enum ParameterKind<'db> { KeywordOnly { /// Parameter name. name: Name, - default_type: Option>, + default_type: Option>, }, /// Variadic keywords parameter, e.g. `def f(**kwargs): ...` @@ -6420,28 +6765,20 @@ pub enum ParameterKind<'db> { } impl<'db> ParameterKind<'db> { - fn default_type(&self) -> Option> { - match self { - ParameterKind::PositionalOnly { default_type, .. } - | ParameterKind::PositionalOrKeyword { default_type, .. } - | ParameterKind::KeywordOnly { default_type, .. } => *default_type, - ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => None, - } - } - #[expect(clippy::ref_option)] fn cycle_normalized_default( db: &'db dyn Db, env: &ProgramEnvironment<'db>, - current: &Option>, - previous: &Option>, + current: &Option>, + previous: &Option>, cycle: &salsa::Cycle, - ) -> Option> { - match (current, previous) { - (Some(curr), Some(prev)) => Some(curr.cycle_normalized(db, env, *prev, cycle)), - (Some(curr), None) => Some(curr.recursive_type_normalized(db, env, cycle)), - (None, _) => *current, - } + ) -> Option> { + current.map(|current| { + current.map_type(|ty| match previous.and_then(ParameterDefault::eager_type) { + Some(previous) => ty.cycle_normalized(db, env, previous, cycle), + None => ty.recursive_type_normalized(db, env, cycle), + }) + }) } fn cycle_normalized( @@ -6515,14 +6852,17 @@ impl<'db> ParameterKind<'db> { tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'_, 'db>, ) -> Self { - let apply_to_default_type = |default_type: &Option>| { - if type_mapping == &TypeMapping::ReplaceParameterDefaults && default_type.is_some() { - Some(Type::unknown()) - } else { - default_type - .as_ref() - .map(|ty| ty.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)) - } + let apply_to_default_type = |default_type: &Option>| { + default_type.map(|default| match type_mapping { + TypeMapping::ReplaceParameterDefaults => { + ParameterDefault::Inferred(Type::unknown()) + } + // Defaults describe values, not the set of accepted arguments. Promoting the + // enclosing callable must not widen those values. + TypeMapping::Promote(..) => default, + _ => default + .map_type(|ty| ty.apply_type_mapping_impl(db, env, type_mapping, tcx, visitor)), + }) }; match self { @@ -6563,55 +6903,28 @@ mod tests { } #[track_caller] - fn assert_params<'db>(signature: &Signature<'db>, expected: &[Parameter<'db>]) { + fn assert_params<'db>( + db: &'db dyn Db, + signature: &Signature<'db>, + expected: &[Parameter<'db>], + ) { + let without_definition = |parameter: &Parameter<'db>| { + parameter + .clone() + .with_definition(None) + .with_source_parameter_index(None) + .with_optional_default_type(parameter.default_type(db)) + }; assert_eq!( signature .parameters .iter() - .map(ParameterWithoutDefinition::from) - .collect::>(), - expected - .iter() - .map(ParameterWithoutDefinition::from) + .map(without_definition) .collect::>(), + expected.iter().map(without_definition).collect::>(), ); } - #[derive(Debug, Eq, PartialEq)] - struct ParameterWithoutDefinition<'a, 'db> { - annotated_type: &'a Type<'db>, - annotation_kind: ParameterAnnotationKind, - inferred_annotation: bool, - is_context: bool, - is_receiver: bool, - kind: &'a ParameterKind<'db>, - } - - impl<'a, 'db> From<&'a Parameter<'db>> for ParameterWithoutDefinition<'a, 'db> { - fn from(parameter: &'a Parameter<'db>) -> Self { - let Parameter { - annotated_type, - definition: _, - annotation_kind, - inferred_annotation, - is_context, - is_receiver, - borrow: _, - source_parameter_index: _, - kind, - } = parameter; - - Self { - annotated_type, - annotation_kind: *annotation_kind, - inferred_annotation: *inferred_annotation, - is_context: *is_context, - is_receiver: *is_receiver, - kind, - } - } - } - #[track_caller] fn assert_params_have_definitions(signature: &Signature<'_>) { for parameter in &signature.parameters { @@ -6648,7 +6961,7 @@ mod tests { let sig = func.signature(&db); assert!(sig.return_ty.is_unknown()); - assert_params(&sig, &[]); + assert_params(&db, &sig, &[]); } #[test] @@ -6680,6 +6993,7 @@ mod tests { ); assert_params_have_definitions(&sig); assert_params( + &db, &sig, &[ Parameter::positional_only(Some(Name::new_static("a"))), diff --git a/crates/ty_python_semantic/src/types/soundness.rs b/crates/ty_python_semantic/src/types/soundness.rs index 6374f5a0f5..bd391f4083 100644 --- a/crates/ty_python_semantic/src/types/soundness.rs +++ b/crates/ty_python_semantic/src/types/soundness.rs @@ -9,7 +9,7 @@ //! - does this expression's type rest on such an assumption? (the gates: //! [`call_result_is_typevar_derived`], [`is_specialized_generic_instance`]) //! - can the inferred type be validated with `isinstance` at runtime, and -//! what second argument does that check take? ([`runtime_check_target`]) +//! what second argument does that check take? (`runtime_check_target`) use ruff_db::files::File; @@ -258,7 +258,7 @@ pub fn is_specialized_generic_instance<'db>( /// unsolved typevars) or its name cannot be resolved at module scope in /// `file`. the check is deliberately shallow: `list[str]` validates as /// `list` — the element claim is validated at its own projection sites -pub fn runtime_check_target<'db>( +pub(crate) fn runtime_check_target<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, file: File, @@ -272,7 +272,7 @@ pub fn runtime_check_target<'db>( /// Prefers a [`CheckKind::Parametric`] deep check when `ty` is a user-defined /// generic specialization whose instances carry `__orig_class__` (so the type /// arguments are checkable at runtime); otherwise falls back to the shallow -/// [`CheckKind::Isinstance`] of [`runtime_check_target`]. `None` when neither +/// [`CheckKind::Isinstance`] of `runtime_check_target`. `None` when neither /// applies (no faithful runtime test). pub fn runtime_check_plan<'db>( db: &'db dyn Db, @@ -325,7 +325,7 @@ pub fn parameter_runtime_check_plan<'db>( /// erased at runtime, so only the origin class can be tested. this is what /// separates `list[int]` (a builtin, erased — only `list` is checkable) from /// `A[int]` (a user generic, whose instances carry `__orig_class__`) -pub fn erases_type_arguments<'db>( +pub(crate) fn erases_type_arguments<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, file: File, diff --git a/crates/ty_python_semantic/src/types/special_form.rs b/crates/ty_python_semantic/src/types/special_form.rs index 399f140030..35a62126c4 100644 --- a/crates/ty_python_semantic/src/types/special_form.rs +++ b/crates/ty_python_semantic/src/types/special_form.rs @@ -1,7 +1,7 @@ //! An enumeration of special forms in the Python type system. //! Each of these is considered to inhabit a unique type in our model of the type system. -use super::{ClassType, Type, TypeFormType, class::KnownClass}; +use super::{ClassType, Type, TypeFormType, TypingModule, class::KnownClass}; use crate::ProgramEnvironment; use crate::db::Db; use crate::types::IntersectionType; @@ -14,6 +14,7 @@ use crate::types::{ enclosing_class_for_self, function_known_decorator_flags, is_class_type_parameters_scope, }, }; +use ruff_python_ast::PythonVersion; use strum_macros::EnumString; use ty_module_resolver::{ImportingFile, KnownModule, file_to_module, resolve_module_confident}; use ty_python_core::{ @@ -117,7 +118,7 @@ pub enum SpecialFormType { /// The symbol `typing.TypeGuard` (which can also be found as `typing_extensions.TypeGuard`) TypeGuard, /// The symbol `typing.TypedDict` or `typing_extensions.TypedDict`. - TypedDict(TypedDictModule), + TypedDict(TypingModule), /// The symbol `typing.TypeIs` (which can also be found as `typing_extensions.TypeIs`) TypeIs, @@ -139,52 +140,12 @@ pub enum SpecialFormType { NamedTuple, } -/// The module or modules from which `TypedDict` may have been imported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] -pub enum TypedDictModule { - /// `typing.TypedDict`. - Typing, - /// `typing_extensions.TypedDict`. - TypingExtensions, -} - -impl TypedDictModule { - /// Return the module for a `TypedDict` special form, including a union of the special forms - /// exported by `typing` and `typing_extensions`. - pub(super) fn from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option { - match ty { - Type::SpecialForm(SpecialFormType::TypedDict(module)) => Some(module), - Type::Union(union) => { - let mut elements = union.elements(db).iter(); - let Type::SpecialForm(SpecialFormType::TypedDict(module)) = elements.next()? else { - return None; - }; - elements.try_fold(*module, |module, element| { - let Type::SpecialForm(SpecialFormType::TypedDict(element_module)) = element - else { - return None; - }; - // `typing_extensions.TypedDict` always offers strictly more functionality than `typing.TypedDict`. - // If any element is from `typing`, we therefore infer that the type is a `typing.TypedDict`, - // since an operation on a union is only valid if the operation is valid on all elements in the - // union. - Some(match (module, element_module) { - (TypedDictModule::TypingExtensions, TypedDictModule::TypingExtensions) => { - TypedDictModule::TypingExtensions - } - _ => TypedDictModule::Typing, - }) - }) - } - _ => None, - } - } -} - impl SpecialFormType { /// Return the [`KnownClass`] which this symbol is an instance of - pub(crate) const fn class(self) -> KnownClass { + pub(crate) fn class(self, db: &dyn Db, env: &ProgramEnvironment<'_>) -> KnownClass { match self { + Self::Union if env.python_version(db) >= PythonVersion::PY314 => KnownClass::Type, + Self::Annotated | Self::Literal | Self::LiteralString @@ -197,7 +158,6 @@ impl SpecialFormType { | Self::TypeForm | Self::TypingSelf | Self::TypingCallable - | Self::CollectionsAbcCallable | Self::Concatenate | Self::Unpack | Self::TypeAlias @@ -226,7 +186,7 @@ impl SpecialFormType { // as being valid. Self::Protocol => KnownClass::ProtocolMeta, - Self::Generic | Self::Any => KnownClass::Type, + Self::Generic | Self::Any | Self::CollectionsAbcCallable => KnownClass::Type, Self::LegacyStdlibAlias(_) => KnownClass::StdlibAlias, @@ -237,14 +197,14 @@ impl SpecialFormType { /// Return the instance type which this type is a subtype of. /// /// For example, the symbol `typing.Literal` is an instance of `typing._SpecialForm`, - /// so `SpecialFormType::Literal.instance_fallback(db, python_version)` + /// so `SpecialFormType::Literal.instance_fallback(db, env)` /// returns `Type::NominalInstance(NominalInstanceType { class: })`. pub(super) fn instance_fallback<'db>( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Type<'db> { - self.class().to_instance(db, env) + self.class(db, env).to_instance(db, env) } /// Return `true` if this special form is guaranteed to be a singleton at runtime. @@ -297,7 +257,7 @@ impl SpecialFormType { env: &ProgramEnvironment<'_>, class: ClassType, ) -> bool { - self.class().is_subclass_of(db, env, class) + self.class(db, env).is_subclass_of(db, env, class) } pub(super) fn try_from_file_and_name( @@ -508,8 +468,8 @@ impl SpecialFormType { SpecialFormTypeBuilder::Unpack => &[Self::Unpack], SpecialFormTypeBuilder::Tuple => &[Self::Tuple], SpecialFormTypeBuilder::TypedDict => &[ - Self::TypedDict(TypedDictModule::Typing), - Self::TypedDict(TypedDictModule::TypingExtensions), + Self::TypedDict(TypingModule::Typing), + Self::TypedDict(TypingModule::TypingExtensions), ], SpecialFormTypeBuilder::TypeOf => &[Self::TypeOf], SpecialFormTypeBuilder::List => { @@ -562,8 +522,8 @@ impl SpecialFormType { /// Return `true` if `module` is a module from which this `SpecialFormType` variant can validly originate. /// - /// Most variants can only exist in one module, which is the same as `self.class().canonical_module(db)`. - /// Some variants could validly be defined in either `typing` or `typing_extensions`, however. + /// Some variants are defined in only one module; others can be defined in either + /// `typing` or `typing_extensions`. const fn check_module(self, module: KnownModule) -> bool { match self { Self::TypeQualifier(qualifier) => qualifier.check_module(module), @@ -574,7 +534,7 @@ impl SpecialFormType { | Self::Tuple | Self::Type | Self::Generic - | Self::TypedDict(TypedDictModule::Typing) + | Self::TypedDict(TypingModule::Typing) | Self::TypingCallable => module.is_typing(), Self::Annotated @@ -615,7 +575,7 @@ impl SpecialFormType { KnownModule::CollectionsAbc | KnownModule::CollectionsAbcInternal ), - Self::TypedDict(TypedDictModule::TypingExtensions) => module.is_typing_extensions(), + Self::TypedDict(TypingModule::TypingExtensions) => module.is_typing_extensions(), } } @@ -624,7 +584,7 @@ impl SpecialFormType { db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Type<'db> { - self.class().to_class_literal(db, env) + self.class(db, env).to_class_literal(db, env) } /// Return true if this special form is callable at runtime. @@ -826,8 +786,8 @@ impl SpecialFormType { &[KnownModule::Typing, KnownModule::TypingExtensions] } - SpecialFormType::TypedDict(TypedDictModule::Typing) => &[KnownModule::Typing], - SpecialFormType::TypedDict(TypedDictModule::TypingExtensions) => { + SpecialFormType::TypedDict(TypingModule::Typing) => &[KnownModule::Typing], + SpecialFormType::TypedDict(TypingModule::TypingExtensions) => { &[KnownModule::TypingExtensions] } diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 6fb9d63e41..25166a52bc 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -5,12 +5,12 @@ use crate::place::PlaceAndQualifiers; use crate::types::class::DynamicClassLiteral; use crate::types::constraints::ConstraintSet; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; -use crate::types::variance::VarianceInferable; +use crate::types::variance::{VarianceInferable, VarianceTerm}; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, ClassLiteral, ClassType, DynamicType, FindLegacyTypeVarsVisitor, KnownClass, MaterializationKind, MemberLookupPolicy, ProtocolInstanceType, SpecialFormType, Type, TypeContext, TypeMapping, TypeQualifiers, - TypeVarBoundOrConstraints, TypeVarVariance, TypedDictType, UnionType, todo_type, + TypeRecursionContext, TypeVarBoundOrConstraints, TypedDictType, UnionType, todo_type, }; use ty_python_core::definition::Definition; @@ -223,7 +223,7 @@ impl<'db> SubclassOfType<'db> { SubclassOfInner::TypeVar(typevar) => { let mapped = typevar.apply_type_mapping_impl(db, env, type_mapping, visitor); Self::try_from_instance(db, visitor.env, mapped) - .unwrap_or_else(|| mapped.to_meta_type(db, visitor.env)) + .unwrap_or_else(|| visitor.project_meta_type(db, mapped)) } } } @@ -316,9 +316,9 @@ impl<'db> SubclassOfType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Type<'db> { - // This kind of looks like a no-op, but it's not. For `type[C]` where `C` has metaclass - // `M`, `to_meta_type` transforms `type[C]` to `type[M]`, and then `to_instance` makes it - // just `M`. And `to_meta_type` will transpose `type[T: C]` into `T: type[C]`, collapse to + // This kind of looks like a no-op, but it's not. For `type[C]` with guaranteed metaclass + // `M`, `to_meta_type` produces `type[M]`, and then `to_instance` makes it just `M`. + // And `to_meta_type` will transpose `type[T: C]` into `T: type[C]`, collapse to // the upper bound `type[C]`, and transform that to the meta-type `type[M]`, which // `to_instance` then resolves to `M`. self.to_meta_type(db, env) @@ -328,18 +328,36 @@ impl<'db> SubclassOfType<'db> { /// Compute the metatype of this `type[T]`. /// - /// For `type[C]` where `C` is a concrete class, this returns `type[metaclass(C)]`. + /// For a concrete class `C`, this returns `type[M]`, where `M` is its guaranteed metaclass, + /// excluding the lookup-only typeshed fallback. /// For `type[T]` where `T` is a `TypeVar`, this computes the metatype based on the /// `TypeVar`'s bounds or constraints. - pub(crate) fn to_meta_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { - match self.subclass_of.with_transposed_type_var(db, env) { + fn to_meta_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { + Type::SubclassOf(self).to_meta_type(db, env) + } + + pub(super) fn to_meta_type_with_recursion( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + context: &TypeRecursionContext<'db>, + ) -> Type<'db> { + match self + .subclass_of + .with_transposed_type_var_with_recursion(db, env, context) + { SubclassOfInner::Dynamic(dynamic) => { SubclassOfType::from(db, env, SubclassOfInner::Dynamic(dynamic)) } - SubclassOfInner::Class(class) => { - SubclassOfType::try_from_type(db, env, class.metaclass(db)) - .unwrap_or(SubclassOfType::subclass_of_unknown()) - } + // A metaclass selected at runtime can already have a type such as `type[M]`, + // rather than being a class literal. Projecting to instances preserves this + // constraint when computing its possible subclasses. + SubclassOfInner::Class(class) => class + .inferred_metaclass(db) + .for_inheritance(db, env) + .to_instance_approximation(db, env) + .map(|instance| instance.to_meta_type_with_recursion(db, env, context)) + .unwrap_or(SubclassOfType::subclass_of_unknown()), // Structural implementations of a protocol can have arbitrary metaclasses. The only // guaranteed upper bound is therefore `type`, not the protocol origin's metaclass. SubclassOfInner::Protocol(_) => KnownClass::Type.to_subclass_of(db, env), @@ -352,11 +370,11 @@ impl<'db> SubclassOfType<'db> { // `with_transposed_type_var` always adds a bound for unbounded TypeVars None => unreachable!(), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - bound.to_meta_type(db, env) - } - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - constraints.as_type(db, env).to_meta_type(db, env) + bound.to_meta_type_with_recursion(db, env, context) } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => constraints + .as_type(db, env) + .to_meta_type_with_recursion(db, env, context), } } } @@ -374,13 +392,13 @@ impl<'db> VarianceInferable<'db> for SubclassOfType<'db> { self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, - typevar: BoundTypeVarIdentity<'_>, - ) -> TypeVarVariance { + typevar: BoundTypeVarIdentity<'db>, + ) -> VarianceTerm<'db> { match self.subclass_of { SubclassOfInner::Class(class) => class.variance_of(db, env, typevar), SubclassOfInner::Protocol(protocol) => protocol.variance_of(db, env, typevar), SubclassOfInner::TypeVar(inner) => Type::TypeVar(inner).variance_of(db, env, typevar), - SubclassOfInner::Dynamic(_) => TypeVarVariance::Bivariant, + SubclassOfInner::Dynamic(_) => VarianceTerm::BIVARIANT, } } } @@ -592,6 +610,15 @@ impl<'db> SubclassOfInner<'db> { self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, + ) -> Self { + self.with_transposed_type_var_with_recursion(db, env, &TypeRecursionContext::default()) + } + + fn with_transposed_type_var_with_recursion( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + context: &TypeRecursionContext<'db>, ) -> Self { let Some(bound_typevar) = self.into_type_var() else { return self; @@ -604,12 +631,14 @@ impl<'db> SubclassOfInner<'db> { .unwrap_or(SubclassOfType::subclass_of_unknown()), ), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - TypeVarBoundOrConstraints::UpperBound(bound.to_meta_type(db, env)) + TypeVarBoundOrConstraints::UpperBound( + bound.to_meta_type_with_recursion(db, env, context), + ) } Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - TypeVarBoundOrConstraints::Constraints( - constraints.map(db, |constraint| constraint.to_meta_type(db, env)), - ) + TypeVarBoundOrConstraints::Constraints(constraints.map(db, |constraint| { + constraint.to_meta_type_with_recursion(db, env, context) + })) } }) }); diff --git a/crates/ty_python_semantic/src/types/subscript.rs b/crates/ty_python_semantic/src/types/subscript.rs index 06107519e8..2193a37afc 100644 --- a/crates/ty_python_semantic/src/types/subscript.rs +++ b/crates/ty_python_semantic/src/types/subscript.rs @@ -1007,7 +1007,9 @@ impl<'db> Type<'db> { Some(Ok(Type::any())) } - (Type::SpecialForm(special_form), _) if special_form.class().is_special_form() => { + (Type::SpecialForm(special_form), _) + if special_form.class(db, env).is_special_form() => + { Some(Ok(todo_type!("Inference of subscript on special form"))) } @@ -1033,6 +1035,7 @@ impl<'db> Type<'db> { | Type::AlwaysTruthy | Type::ProtocolInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::BoundSuper(_) | Type::TypeIs(_) | Type::TypeGuard(_) diff --git a/crates/ty_python_semantic/src/types/tests.rs b/crates/ty_python_semantic/src/types/tests.rs index e6d83c1640..12ef8fa3c3 100644 --- a/crates/ty_python_semantic/src/types/tests.rs +++ b/crates/ty_python_semantic/src/types/tests.rs @@ -1,15 +1,122 @@ use super::*; use crate::db::tests::{TestDbBuilder, setup_db}; -use crate::place::{typing_extensions_symbol, typing_symbol}; +use crate::place::{global_symbol, typing_extensions_symbol, typing_symbol}; +use crate::types::call::bind::CallableDescription; use crate::types::type_alias::PEP695TypeAliasType; use crate::{Db, ProgramEnvironment}; +use ruff_db::files::system_path_to_file; use ruff_db::system::DbWithWritableSystem as _; +use ruff_db::testing::assert_function_query_was_not_run_by_name; use ruff_python_ast as ast; use ruff_python_ast::PythonVersion; +use salsa::plumbing::AsId; use test_case::test_case; use ty_python_core::program::Program; use ty_python_core::{ProgramFile, TestProgramDb as _}; +#[test] +fn member_lookup_result_size() { + // Property diagnostics must not enlarge every cached member lookup. + assert_eq!( + size_of::>(), + size_of::, MemberLookupError<'_>>>(), + ); +} + +#[test] +fn property_deprecations_do_not_infer_accessor_signatures() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/accessors.py", + r#" + from typing_extensions import deprecated + + @deprecated("old getter") + def getter(self: object) -> int: ... + + def setter(self: object, value: int) -> None: ... + "#, + )?; + let accessor_ids = { + let file = system_path_to_file(&db, "/src/accessors.py")?; + let env = db.program_environment(); + let file = ProgramFile::new(&db, file, env.program(&db)); + let getter = global_symbol(&db, file, "getter").place.expect_type(); + let setter = global_symbol(&db, file, "setter").place.expect_type(); + let property = PropertyInstanceType::new(&db, Some(getter), Some(setter), None); + let deprecations = Type::PropertyInstance(property) + .property_deprecations(&db) + .ok_or_else(|| anyhow::anyhow!("expected a deprecated getter"))?; + assert_eq!(deprecations.functions(&db, ast::ExprContext::Load).len(), 1); + assert_eq!( + CallableDescription::from_overload( + &db, + deprecations.functions(&db, ast::ExprContext::Load)[0], + ) + .name(), + "getter" + ); + assert!( + deprecations + .functions(&db, ast::ExprContext::Store) + .is_empty() + ); + + let [Type::FunctionLiteral(getter), Type::FunctionLiteral(setter)] = [getter, setter] + else { + anyhow::bail!("expected accessor functions"); + }; + [getter.as_id(), setter.as_id()] + }; + let events = db.take_salsa_events(); + for accessor in accessor_ids { + assert_function_query_was_not_run_by_name( + &db, + "FunctionType < 'db >::signature_", + Some(accessor), + &events, + ); + } + Ok(()) +} + +#[test] +fn bounded_intersection_preserves_late_union_elements() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let wide = UnionType::from_elements(db, &env, (1..=6).map(Type::int_literal)); + let narrow = UnionType::from_elements(db, &env, (5..=7).map(Type::int_literal)); + let expected = UnionType::from_elements(db, &env, (5..=6).map(Type::int_literal)); + + // The first union exceeds the budget, but its last two elements survive the intersection. + for elements in [[wide, narrow], [narrow, wide]] { + assert_eq!( + IntersectionType::bounded_from_elements(db, &env, elements), + Some(expected) + ); + } +} + +#[test] +fn bounded_intersection_returns_none_when_budget_exhausted() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let wide = UnionType::from_elements(db, &env, (1..=6).map(Type::int_literal)); + + // A single union requires no distribution and is returned exactly, regardless of its size. + assert_eq!( + IntersectionType::bounded_from_elements(db, &env, [wide]), + Some(wide) + ); + // Exceeding the budget must return `None`, not a partial intersection. + assert_eq!( + IntersectionType::bounded_from_elements(db, &env, [wide, wide]), + None + ); +} + /// Explicitly test for Python version <3.13 and >=3.13, to ensure that /// the fallback to `typing_extensions` is working correctly. /// See [`KnownClass::canonical_module`] for more information. @@ -545,6 +652,33 @@ fn divergent_type() { ); } +#[test] +fn unrestricted_tuple_materialization_absorbs_divergent_approximations() { + let db = setup_db(); + let db = &db; + let env = db.program_environment(); + let div = Type::divergent(salsa::plumbing::Id::from_bits(1)); + let list_of = |tuple| KnownClass::List.to_specialized_instance(db, &env, &[tuple]); + let approximation = |element| list_of(Type::heterogeneous_tuple(db, &env, [element])); + let top = list_of(Type::homogeneous_tuple(db, &env, Type::any())).top_materialization(db, &env); + + // This fixed top absorbs every exact-tuple approximation, including a marker nested + // more deeply in a later iteration. Removing the marker here therefore converges. + let first = approximation(div); + for candidate in [first, approximation(first)] { + assert_eq!(UnionType::from_elements(db, &env, [candidate, top]), top); + assert_eq!(UnionType::from_elements(db, &env, [top, candidate]), top); + } + + // An unresolved marker is not itself an unrestricted gradual element type. Its + // homogeneous tuple must not acquire the same family as `tuple[Any, ...]`. + let divergent_top = + list_of(Type::homogeneous_tuple(db, &env, div)).top_materialization(db, &env); + let empty = list_of(Type::empty_tuple(db, &env)); + assert!(!empty.is_subtype_of(db, &env, divergent_top)); + assert!(!empty.is_redundant_with(db, &env, divergent_top)); +} + #[test] fn type_alias_variance() { use crate::db::tests::TestDb; @@ -631,88 +765,73 @@ type RecursiveAlias2[T] = None | list[T] | list[RecursiveAlias2[T]] let env = db.program_environment(); let covariant = get_type_alias(db, "CovariantAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(covariant)).variance_of( - db, - &env, - get_bound_typevar(db, covariant) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(covariant)) + .variance_of(db, &env, get_bound_typevar(db, covariant)) + .evaluate(db), TypeVarVariance::Covariant ); let contravariant = get_type_alias(db, "ContravariantAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(contravariant)).variance_of( - db, - &env, - get_bound_typevar(db, contravariant) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(contravariant)) + .variance_of(db, &env, get_bound_typevar(db, contravariant)) + .evaluate(db), TypeVarVariance::Contravariant ); let invariant = get_type_alias(db, "InvariantAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(invariant)).variance_of( - db, - &env, - get_bound_typevar(db, invariant) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(invariant)) + .variance_of(db, &env, get_bound_typevar(db, invariant)) + .evaluate(db), TypeVarVariance::Invariant ); let bivariant = get_type_alias(db, "BivariantAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(bivariant)).variance_of( - db, - &env, - get_bound_typevar(db, bivariant) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(bivariant)) + .variance_of(db, &env, get_bound_typevar(db, bivariant)) + .evaluate(db), TypeVarVariance::Bivariant ); let covariant_alias = get_type_alias(db, "CovariantAliasAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(covariant_alias)).variance_of( - db, - &env, - get_bound_typevar(db, covariant_alias) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(covariant_alias)) + .variance_of(db, &env, get_bound_typevar(db, covariant_alias)) + .evaluate(db), TypeVarVariance::Covariant ); let contravariant_alias = get_type_alias(db, "ContravariantAliasAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(contravariant_alias)).variance_of( - db, - &env, - get_bound_typevar(db, contravariant_alias) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(contravariant_alias)) + .variance_of(db, &env, get_bound_typevar(db, contravariant_alias)) + .evaluate(db), TypeVarVariance::Contravariant ); let invariant_alias = get_type_alias(db, "InvariantAliasAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(invariant_alias)).variance_of( - db, - &env, - get_bound_typevar(db, invariant_alias) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(invariant_alias)) + .variance_of(db, &env, get_bound_typevar(db, invariant_alias)) + .evaluate(db), TypeVarVariance::Invariant ); let bivariant_alias = get_type_alias(db, "BivariantAliasAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(bivariant_alias)).variance_of( - db, - &env, - get_bound_typevar(db, bivariant_alias) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(bivariant_alias)) + .variance_of(db, &env, get_bound_typevar(db, bivariant_alias)) + .evaluate(db), TypeVarVariance::Bivariant ); let paramspec_contravariant = get_type_alias(db, "ParamSpecContravariantAlias"); assert_eq!( KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(paramspec_contravariant)) - .variance_of(db, &env, get_bound_typevar(db, paramspec_contravariant)), + .variance_of(db, &env, get_bound_typevar(db, paramspec_contravariant)) + .evaluate(db), TypeVarVariance::Contravariant ); @@ -723,47 +842,40 @@ type RecursiveAlias2[T] = None | list[T] | list[RecursiveAlias2[T]] db, &env, get_bound_typevar(db, paramspec_default_contravariant) - ), + ) + .evaluate(db), TypeVarVariance::Contravariant ); let paramspec_concatenate = get_type_alias(db, "ParamSpecConcatenateAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(paramspec_concatenate)).variance_of( - db, - &env, - get_bound_typevar(db, paramspec_concatenate) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(paramspec_concatenate)) + .variance_of(db, &env, get_bound_typevar(db, paramspec_concatenate)) + .evaluate(db), TypeVarVariance::Contravariant ); let paramspec_bivariant = get_type_alias(db, "ParamSpecBivariantAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(paramspec_bivariant)).variance_of( - db, - &env, - get_bound_typevar(db, paramspec_bivariant) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(paramspec_bivariant)) + .variance_of(db, &env, get_bound_typevar(db, paramspec_bivariant)) + .evaluate(db), TypeVarVariance::Bivariant ); let recursive = get_type_alias(db, "RecursiveAlias"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(recursive)).variance_of( - db, - &env, - get_bound_typevar(db, recursive) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(recursive)) + .variance_of(db, &env, get_bound_typevar(db, recursive)) + .evaluate(db), TypeVarVariance::Bivariant ); let recursive2 = get_type_alias(db, "RecursiveAlias2"); assert_eq!( - KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(recursive2)).variance_of( - db, - &env, - get_bound_typevar(db, recursive2) - ), + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(recursive2)) + .variance_of(db, &env, get_bound_typevar(db, recursive2)) + .evaluate(db), TypeVarVariance::Invariant ); diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index 404f26983f..b5ff96eeb7 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -10,11 +10,8 @@ //! //! The description of which elements can appear in a `tuple` is called a [`TupleSpec`]. Other //! things besides `tuple` instances can be described by a tuple spec — for instance, the targets -//! of an unpacking assignment. A `tuple` specialization that includes `Never` as one of its -//! fixed-length elements cannot be instantiated. We reduce the entire `tuple` type down to -//! `Never`. The same is not true of tuple specs in general. (That means that it is [`TupleType`] -//! that adds that "collapse `Never`" behavior, whereas [`TupleSpec`] allows you to add any element -//! types, including `Never`.) +//! of an unpacking assignment. A `tuple` specialization can include `Never` as a fixed-length +//! element because a user-defined tuple subclass can inhabit that type. use crate::{Program, ProgramEnvironment}; use std::cmp::Ordering; @@ -31,6 +28,7 @@ use crate::types::class::{ClassType, KnownClass}; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker, TypeVarEvaluation}; use crate::types::set_theoretic::RecursivelyDefined; +use crate::types::visitor::any_over_type_expanding_aliases; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, ErrorContext, FindLegacyTypeVarsVisitor, IntersectionType, Type, TypeContext, TypeMapping, UnionBuilder, UnionType, @@ -176,34 +174,7 @@ impl<'db> TupleType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, spec: &TupleSpec<'db>, - ) -> Option { - // If a fixed-length (i.e., mandatory) element of the tuple is `Never`, then it's not - // possible to instantiate the tuple as a whole. - // - // basedpython: a divergence marker does not count as `Never` here, even though it - // bottom-materializes to one. the marker is not a claim that no value exists — it stands - // for a type the fixed-point iteration has not finished computing, and answering `Never` - // for the tuple built around it throws the marker away. that is the one thing cycle - // recovery cannot lose: with no marker left there is nothing for - // `recursive_type_normalized` to fold on, and a recursion routed through a tuple element - // - // ```python - // def h(n: int): - // if n: - // return "a" - // t = (h(n),) - // return "b" + t[0] - // ``` - // - // gains a string literal every round instead of settling on `str`, the way the same - // recursion written directly or through a `list` always has - if spec - .fixed_elements() - .any(|element| matches!(element, Type::Never)) - { - return None; - } - + ) -> Self { // If the variable-length portion is Never, it can only be instantiated with zero elements. // That means this isn't a variable-length tuple after all! if let TupleSpec::Variable(tuple) = spec @@ -214,10 +185,10 @@ impl<'db> TupleType<'db> { .iter_prefix_elements() .chain(tuple.iter_suffix_elements()), )); - return Some(TupleType::new_internal(db, env.program(db), tuple)); + return TupleType::new_internal(db, env.program(db), tuple); } - Some(TupleType::new_internal(db, env.program(db), spec)) + TupleType::new_internal(db, env.program(db), spec) } pub(crate) fn empty(db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Self { @@ -232,7 +203,7 @@ impl<'db> TupleType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, types: impl IntoIterator>, - ) -> Option { + ) -> Self { TupleType::new(db, env, &TupleSpec::heterogeneous(types)) } @@ -242,7 +213,7 @@ impl<'db> TupleType<'db> { prefix: impl IntoIterator>, variable: Type<'db>, suffix: impl IntoIterator>, - ) -> Option { + ) -> Self { Self::mixed_with_segment( db, env, @@ -258,7 +229,7 @@ impl<'db> TupleType<'db> { prefix: impl IntoIterator>, variable: VariableSegment<'db>, suffix: impl IntoIterator>, - ) -> Option { + ) -> Self { TupleType::new( db, env, @@ -333,7 +304,7 @@ impl<'db> TupleType<'db> { type_mapping: &TypeMapping<'a, 'db>, tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'_, 'db>, - ) -> Option { + ) -> Self { TupleType::new( db, visitor.env, @@ -497,17 +468,16 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // (or any other dynamic type), then the `...` is the _gradual choice_ of all // possible lengths. This means that `tuple[Any, ...]` can match any tuple of any // length. - let VariableSegment::Homogeneous(source_variable) = source.variable() else { - // Unlike a dynamic homogeneous segment, a symbolic type variable tuple ranges - // over all specializations rather than making a gradual choice of length. - return self.never(); - }; - if !self.is_eager_assignability() || !source_variable.is_dynamic() { + // + // Unlike a dynamic homogeneous segment, a symbolic type variable tuple ranges + // over all specializations rather than making a gradual choice of length. + let env = self.env; + if !self.is_eager_assignability() + || source.variable().gradual_element_type(db, env).is_none() + { return self.never(); } - let env = self.env; - // In addition, the other tuple must have enough elements to match up with this // tuple's prefix and suffix, and each of those elements must pairwise satisfy the // relation. @@ -574,6 +544,9 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let env = self.env; + // TODO: Extend lazy inference for mixed gradual tuples: let gradual segments + // supply fixed target elements, and generate constraints for source packs that + // overlap fixed target elements. if self.typevar_evaluation == TypeVarEvaluation::Lazy && let VariableSegment::TypeVarTuple(typevartuple) = target.variable() { @@ -614,8 +587,68 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }); } + // These checks must hold for every specialization of a non-inferable pack. + // Lazy evaluation needs to retain pack constraints for later solving; its empty + // `inferable` set does not imply universal quantification. + if self.is_eager_assignability() { + match (source.variable(), target.variable()) { + (source_segment, VariableSegment::TypeVarTuple(target_pack)) + if !target_pack.is_inferable(db, self.inferable) + && let Some(source_element) = + source_segment.gradual_element_type(db, env) => + { + // The pack may be empty, so the source cannot require more elements + // than the target's fixed ends. For longer packs, source endpoints + // extending into the pack must be assignable to every element type, + // which we check against `Never`. This also covers their overlap with + // the opposite fixed end when the pack is short. + if source.len().minimum() > target.len().minimum() { + return self.never(); + } + return self.check_tuple_boundaries( + db, + source, + target, + source_element, + Type::Never, + ); + } + (VariableSegment::TypeVarTuple(source_pack), target_segment) + if !source_pack.is_inferable(db, self.inferable) + && let Some(target_element) = + target_segment.gradual_element_type(db, env) => + { + // Conversely, the target's required elements must fit even with an + // empty source pack. A target endpoint extending into the pack must + // accept any possible element. An empty protocol expresses this without + // inheriting `object`'s permissive assignability to hash protocols. + if source.len().minimum() < target.len().minimum() { + return self.never(); + } + return self.check_tuple_boundaries( + db, + source, + target, + Type::protocol_with_methods(db, env, []), + target_element, + ); + } + _ => {} + } + } + if matches!(target.variable(), VariableSegment::TypeVarTuple(_)) { - return self.never(); + // A fully gradual source imposes no length or element constraints, even + // when the target pack is inferable. + return ConstraintSet::from_bool( + self.constraints, + self.is_eager_assignability() + && source.len().minimum() == 0 + && matches!( + source.variable(), + VariableSegment::Homogeneous(Type::Dynamic(_)) + ), + ); } // When prenormalizing below, we assume that a dynamic variable-length portion of @@ -713,6 +746,40 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } } + + /// Compare the fixed ends, pairing any overhanging elements with the provided variable types. + /// Use raw endpoints: a symbolic pack cannot be prenormalized as a homogeneous `object` segment. + fn check_tuple_boundaries( + &self, + db: &'db dyn Db, + source: &VariableLengthTuple, VariableSegment<'db>>, + target: &VariableLengthTuple, VariableSegment<'db>>, + source_variable: Type<'db>, + target_variable: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + source + .iter_prefix_elements() + .zip_longest(target.iter_prefix_elements()) + .chain( + source + .iter_suffix_elements() + .rev() + .zip_longest(target.iter_suffix_elements().rev()), + ) + .when_all(db, self.constraints, |pair| { + if let EitherOrBoth::Right(target) = pair + && matches!(source.variable(), VariableSegment::TypeVarTuple(_)) + { + // The synthesized protocol stands for an arbitrary pack element. Diagnostics + // should describe the original tuple types, not this internal placeholder. + return self.without_context_collection(|| { + self.check_type_pair(db, source_variable, target) + }); + } + let (source, target) = pair.or(source_variable, target_variable); + self.check_type_pair(db, source, target) + }) + } } impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { @@ -734,25 +801,42 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { // Two tuples with an incompatible number of required elements must always be disjoint. let (self_min, self_max) = left.len().size_hint(); let (other_min, other_max) = right.len().size_hint(); - if self_max.is_some_and(|max| max < other_min) { - return self.always(); - } - if other_max.is_some_and(|max| max < self_min) { + if self_max.is_some_and(|max| max < other_min) + || other_max.is_some_and(|max| max < self_min) + { + if let Some(context) = self.report_context() { + context.push(ErrorContext::DisjointTupleLengths { + left: left.len(), + right: right.len(), + }); + } return self.always(); } // If any of the required elements are pairwise disjoint, the tuples are disjoint as well. let any_disjoint = |a: &[Type<'db>], b: &[Type<'db>], rev: bool| { + let check_element = |(index, (&left, &right))| { + let result = self.check_type_pair(db, left, right); + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, self.env) + { + context.push(ErrorContext::DisjointTupleElement { + left, + right, + index, + from_end: rev, + }); + } + result + }; if rev { - std::iter::zip(a.iter().rev(), b.iter().rev()).when_any( - db, - self.constraints, - |(&left_elem, &right_elem)| self.check_type_pair(db, left_elem, right_elem), - ) + std::iter::zip(a.iter().rev(), b.iter().rev()) + .enumerate() + .when_any(db, self.constraints, check_element) } else { - std::iter::zip(a, b).when_any(db, self.constraints, |(&left_elem, &right_elem)| { - self.check_type_pair(db, left_elem, right_elem) - }) + std::iter::zip(a, b) + .enumerate() + .when_any(db, self.constraints, check_element) } }; @@ -804,10 +888,6 @@ fn to_class_type_cycle_initial<'db>( } /// A tuple spec describes the contents of a tuple type, which might be fixed- or variable-length. -/// -/// Tuple specs are used for more than just `tuple` instances, so they allow `Never` to appear as a -/// fixed-length element type. [`TupleType`] adds that additional invariant (since a tuple that -/// must contain an element that can't be instantiated, can't be instantiated itself). pub(crate) type TupleSpec<'db> = Tuple, VariableSegment<'db>>; /// The variable-length portion of a [`TupleSpec`]. @@ -831,6 +911,20 @@ impl<'db> VariableSegment<'db> { } } + /// Return the homogeneous element type if this segment has gradual arity, including aliases. + fn gradual_element_type( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Option> { + let element = self.homogeneous_type()?; + // A static constructor or an alias cycle rules out gradual arity, even if it contains Any. + (!any_over_type_expanding_aliases(db, env, element, |ty| { + !matches!(ty, Type::TypeAlias(_)) && !ty.is_dynamic() + })) + .then_some(element) + } + pub(crate) const fn typevartuple(self) -> Option> { match self { Self::Homogeneous(_) => None, @@ -899,44 +993,6 @@ impl FixedLengthTuple { } impl<'db> FixedLengthTuple> { - fn resize( - &self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - new_length: TupleLength, - ) -> Result, ResizeTupleError> { - match new_length { - TupleLength::Fixed(new_length) => match self.len().cmp(&new_length) { - Ordering::Less => Err(ResizeTupleError::TooFewValues), - Ordering::Greater => Err(ResizeTupleError::TooManyValues), - Ordering::Equal => Ok(Tuple::Fixed(self.clone())), - }, - - TupleLength::Variable(prefix, suffix) => { - // The number of rhs values that will be consumed by the starred target. - let Some(variable) = self.len().checked_sub(prefix + suffix) else { - return Err(ResizeTupleError::TooFewValues); - }; - - // Extract rhs values into the prefix, then into the starred target, then into the - // suffix. - let mut elements = self.iter_all_elements(); - let prefix: Vec<_> = elements.by_ref().take(prefix).collect(); - let variable = UnionType::from_elements_leave_aliases( - db, - env, - elements.by_ref().take(variable), - ); - let suffix = elements.by_ref().take(suffix); - Ok(VariableLengthTuple::mixed( - prefix, - VariableSegment::Homogeneous(variable), - suffix, - )) - } - } - } - fn recursive_type_normalized_impl( &self, db: &'db dyn Db, @@ -1069,7 +1125,7 @@ impl VariableLengthTuple { } } - fn mixed( + pub(super) fn mixed( prefix: impl IntoIterator, variable: V, suffix: impl IntoIterator, @@ -1148,10 +1204,6 @@ impl VariableLengthTuple { self.variable_segment } - fn variable_element_mut(&mut self) -> &mut V { - &mut self.variable_segment - } - pub(crate) fn prefix_elements(&self) -> &[T] { &self.fixed_elements[..self.prefix_len] } @@ -1163,10 +1215,6 @@ impl VariableLengthTuple { self.prefix_elements().iter().copied() } - fn prefix_elements_mut(&mut self) -> &mut [T] { - &mut self.fixed_elements[..self.prefix_len] - } - pub(crate) fn suffix_elements(&self) -> &[T] { &self.fixed_elements[self.prefix_len..] } @@ -1178,10 +1226,6 @@ impl VariableLengthTuple { self.suffix_elements().iter().copied() } - fn suffix_elements_mut(&mut self) -> &mut [T] { - &mut self.fixed_elements[self.prefix_len..] - } - fn fixed_elements(&self) -> impl Iterator + '_ { self.fixed_elements.iter() } @@ -2105,64 +2149,6 @@ impl<'db> VariableLengthTuple, VariableSegment<'db>> { .skip_while(move |element| element.is_equivalent_to(db, env, variable)) } - fn resize( - &self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - new_length: TupleLength, - ) -> Result, ResizeTupleError> { - match new_length { - TupleLength::Fixed(new_length) => { - // The number of elements that will get their value from our variable-length - // portion. - let Some(variable_count) = new_length.checked_sub(self.len().minimum()) else { - return Err(ResizeTupleError::TooManyValues); - }; - Ok(Tuple::Fixed(FixedLengthTuple::from_elements( - (self.iter_prefix_elements()) - .chain(std::iter::repeat_n( - self.variable().element_type(db), - variable_count, - )) - .chain(self.iter_suffix_elements()), - ))) - } - - TupleLength::Variable(prefix_length, suffix_length) => { - // "Overflow" are elements of our prefix/suffix that will be folded into the - // result's variable-length portion. "Underflow" are elements of the result - // prefix/suffix that will come from our variable-length portion. - let self_prefix_length = self.prefix_elements().len(); - let prefix_underflow = prefix_length.saturating_sub(self_prefix_length); - let self_suffix_length = self.suffix_elements().len(); - let suffix_overflow = self_suffix_length.saturating_sub(suffix_length); - let suffix_underflow = suffix_length.saturating_sub(self_suffix_length); - // Compute the variable element first, since underflow positions can - // receive any element that could appear in the variable portion. - // For example, `tuple[I0, *tuple[I1, ...], I2]` unpacked as - // `[a, b, *c]` means `b` could be `I1` (variable non-empty) or - // `I2` (variable empty, suffix shifts left), so it should be `I1 | I2`. - let variable = UnionType::from_elements_leave_aliases( - db, - env, - self.iter_prefix_elements() - .skip(prefix_length) - .chain(std::iter::once(self.variable().element_type(db))) - .chain(self.iter_suffix_elements().take(suffix_overflow)), - ); - let prefix = (self.iter_prefix_elements().take(prefix_length)) - .chain(std::iter::repeat_n(variable, prefix_underflow)); - let suffix = std::iter::repeat_n(variable, suffix_underflow) - .chain(self.iter_suffix_elements().skip(suffix_overflow)); - Ok(VariableLengthTuple::mixed( - prefix, - VariableSegment::Homogeneous(variable), - suffix, - )) - } - } - } - fn recursive_type_normalized_impl( &self, db: &'db dyn Db, @@ -2366,6 +2352,155 @@ pub enum Tuple { } impl Tuple { + /// Maps the variable segment without changing the fixed elements or their positions. + fn map_variable(self, map: impl FnOnce(V) -> W) -> Tuple { + match self { + Self::Fixed(fixed) => Tuple::Fixed(fixed), + Self::Variable(variable) => Tuple::Variable(VariableLengthTuple { + fixed_elements: variable.fixed_elements, + prefix_len: variable.prefix_len, + variable_segment: map(variable.variable_segment), + }), + } + } + + /// Matches the source sequence `self` to the target shape specified by `length`. + /// + /// For `a, b = (1, "two")`, `length` is `TupleLength::Fixed(2)`, and each target gets one + /// element. For `first, *rest, last = [1, "two", 3, 4]`, `length` is + /// `TupleLength::Variable(1, 1)`: the returned prefix and suffix contain `1` and `4`, while + /// the variable segment keeps `"two"` and `3` separate. The caller uses those elements to + /// infer the new list for `rest`, retaining their source expressions when available. + /// + /// The source can itself have an unknown-length segment: + /// + /// ```python + /// def example(items: list[int]): + /// first, second, *rest = (0, *items, "last") + /// ``` + /// + /// `variable_elements` exposes the possible elements represented by that source segment. + /// For type inference in this example, it supplies `[int]`. `combine` produces one element + /// for a fixed target with multiple possible sources: `second` can receive an integer from + /// `items` or `"last"` when `items` is empty, so its type is `int | Literal["last"]`. + /// The returned variable segment still keeps the candidates for `rest` separate. + /// + /// A length error means the source's known length bounds cannot fit the targets. + pub(crate) fn unpack( + &self, + length: TupleLength, + variable_elements: impl Fn(&V) -> Vec, + combine: impl Fn(&[T]) -> T, + ) -> Result>, ResizeTupleError> + where + T: Clone, + { + match (length, self) { + // Both lengths are fixed, as in `a, b = (1, "two")`; every target needs one value. + (TupleLength::Fixed(length), Self::Fixed(values)) => match values.len().cmp(&length) { + // `a, b = (1,)` leaves a target without a value. + Ordering::Less => Err(ResizeTupleError::TooFewValues), + // `a, b = (1, 2, 3)` leaves a value without a target. + Ordering::Greater => Err(ResizeTupleError::TooManyValues), + // `a, b = (1, "two")` pairs both targets with their corresponding values. + Ordering::Equal => Ok(Tuple::Fixed(values.clone())), + }, + // `first, *rest, last = [1, "two", 3, 4]` reserves `1` and `4` for the fixed + // targets and collects `"two"` and `3`. With `[1, 4]`, the capture is empty. + // `first, *rest, last = [1]` cannot fill both fixed targets. + (TupleLength::Variable(prefix, suffix), Self::Fixed(values)) => { + let Some(end) = values + .len() + .checked_sub(suffix) + .filter(|end| *end >= prefix) + else { + return Err(ResizeTupleError::TooFewValues); + }; + Ok(VariableLengthTuple::mixed( + values.0[..prefix].iter().cloned(), + values.0[prefix..end].to_vec(), + values.0[end..].iter().cloned(), + )) + } + // The fixed ends supply `a` and `d`; a successful unpacking must take both + // `b` and `c` from `items`: + // + // ```python + // def example(items: list[str]): + // a, b, c, d = (1, *items, 2) + // ``` + // + // The source's length is unknown, but its fixed elements impose a minimum. + // With `a, b = (1, *items, 2, 3)` instead, even an empty `items` leaves too many values. + (TupleLength::Fixed(length), Self::Variable(values)) => { + let Some(count) = length.checked_sub(values.len().minimum()) else { + return Err(ResizeTupleError::TooManyValues); + }; + let variable = combine(&variable_elements(&values.variable_segment)); + Ok(Tuple::heterogeneous( + values + .prefix_elements() + .iter() + .cloned() + .chain(std::iter::repeat_n(variable, count)) + .chain(values.suffix_elements().iter().cloned()), + )) + } + // Extra fixed values overflow into the capture. Here `a` and `b` receive `1` + // and `4`, while `rest` collects `2`, the elements of `items`, and `3`: + // + // ```python + // def overflow(items: list[int]): + // a, *rest, b = (1, 2, *items, 3, 4) + // ``` + // + // Conversely, targets beyond the source's known prefix or suffix underflow + // into the variable portion. Such a target can also receive a fixed value + // that shifts across that portion when it is empty. Here `b` can be `"last"`: + // + // ```python + // def underflow(items: list[int]): + // a, b, *rest = (1, *items, "last") + // ``` + (TupleLength::Variable(prefix, suffix), Self::Variable(values)) => { + let prefix_underflow = prefix.saturating_sub(values.prefix_elements().len()); + let suffix_overflow = values.suffix_elements().len().saturating_sub(suffix); + let suffix_underflow = suffix.saturating_sub(values.suffix_elements().len()); + let collected: Vec<_> = values + .prefix_elements() + .iter() + .skip(prefix) + .cloned() + .chain(variable_elements(&values.variable_segment)) + .chain( + values + .suffix_elements() + .iter() + .take(suffix_overflow) + .cloned(), + ) + .collect(); + let variable = combine(&collected); + Ok(VariableLengthTuple::mixed( + values + .prefix_elements() + .iter() + .take(prefix) + .cloned() + .chain(std::iter::repeat_n(variable.clone(), prefix_underflow)), + collected, + std::iter::repeat_n(variable, suffix_underflow).chain( + values + .suffix_elements() + .iter() + .skip(suffix_overflow) + .cloned(), + ), + )) + } + } + } + /// Returns the inner fixed-length tuple if this is a `Tuple::Fixed` variant. pub(crate) fn as_fixed_length(&self) -> Option<&FixedLengthTuple> { match self { @@ -2390,7 +2525,7 @@ impl Tuple { } } - fn into_all_elements_with_kind(self) -> impl Iterator> { + pub(crate) fn into_all_elements_with_kind(self) -> impl Iterator> { match self { Tuple::Fixed(tuple) => { Either::Left(tuple.owned_elements().into_iter().map(TupleElement::Fixed)) @@ -2540,10 +2675,19 @@ impl<'db> Tuple, VariableSegment<'db>> { env: &ProgramEnvironment<'db>, new_length: TupleLength, ) -> Result { - match self { - Tuple::Fixed(tuple) => tuple.resize(db, env, new_length), - Tuple::Variable(tuple) => tuple.resize(db, env, new_length), - } + Ok(self + .unpack( + new_length, + |segment| vec![segment.element_type(db)], + |elements| { + UnionType::from_elements_leave_aliases(db, env, elements.iter().copied()) + }, + )? + .map_variable(|elements| { + VariableSegment::Homogeneous(UnionType::from_elements_leave_aliases( + db, env, elements, + )) + })) } fn recursive_type_normalized_impl( @@ -2809,155 +2953,159 @@ impl<'db> PyIndex<'db> for &TupleSpec<'db> { } } -enum TupleElement { +pub(crate) enum TupleElement { Fixed(T), Prefix(T), Variable(V), Suffix(T), } -/// Unpacks tuple values in an unpacking assignment. -/// -/// You provide a [`TupleLength`] specifying how many assignment targets there are, and which one -/// (if any) is a starred target. You then call [`unpack_tuple`][TupleUnpacker::unpack_tuple] to -/// unpack the values from a rhs tuple into those targets. If the rhs is a union, call -/// `unpack_tuple` separately for each element of the union. We will automatically wrap the types -/// assigned to the starred target in `list`. -pub(crate) struct TupleUnpacker<'db> { - db: &'db dyn Db, - env: ProgramEnvironment<'db>, - targets: Tuple>, -} - -impl<'db> TupleUnpacker<'db> { - pub(crate) fn new(db: &'db dyn Db, env: &ProgramEnvironment<'db>, len: TupleLength) -> Self { - let new_builders = - |len: usize| std::iter::repeat_with(|| UnionBuilder::new(db, env)).take(len); - let targets = match len { - TupleLength::Fixed(len) => { - Tuple::Fixed(FixedLengthTuple::from_elements(new_builders(len))) - } - TupleLength::Variable(prefix, suffix) => VariableLengthTuple::mixed( - new_builders(prefix), - UnionBuilder::new(db, env), - new_builders(suffix), - ), - }; - Self { - db, - env: env.clone(), - targets, - } - } - - /// Unpacks a single rhs tuple into the target tuple that we are building. If you want to - /// unpack a single type into each target, call this method with a homogeneous tuple. - /// - /// The lengths of the targets and the rhs have to be compatible, but not necessarily - /// identical. The lengths only have to be identical if both sides are fixed-length; if either - /// side is variable-length, we will pull multiple values out of the rhs variable-length - /// portion, and assign multiple values to the starred target, as needed. - pub(crate) fn unpack_tuple(&mut self, values: &TupleSpec<'db>) -> Result<(), ResizeTupleError> { - let db = self.db; - let values = values.resize(db, &self.env, self.targets.len())?; - match (&mut self.targets, &values) { - (Tuple::Fixed(targets), Tuple::Fixed(values)) => { - targets.unpack_tuple(values); - } - (Tuple::Variable(targets), Tuple::Variable(values)) => { - targets.unpack_tuple(db, &self.env, values); - } - _ => panic!("should have ensured that tuples are the same length"), - } - Ok(()) - } - - /// Returns the unpacked types for each target. If you called - /// [`unpack_tuple`][TupleUnpacker::unpack_tuple] multiple times, each target type will be the - /// union of the type unpacked into that target from each of the rhs tuples. If there is a - /// starred target, we will each unpacked type in `list`. - pub(crate) fn into_types(self) -> impl Iterator> { - let Self { db, env, targets } = self; - targets - .into_all_elements_with_kind() - .map(move |builder| match builder { - TupleElement::Variable(builder) => builder.try_build().unwrap_or_else(|| { - KnownClass::List.to_specialized_instance(db, &env, &[Type::unknown()]) - }), - TupleElement::Fixed(builder) - | TupleElement::Prefix(builder) - | TupleElement::Suffix(builder) => { - builder.try_build().unwrap_or_else(Type::unknown) - } - }) - } -} - -impl<'db> FixedLengthTuple> { - fn unpack_tuple(&mut self, values: &FixedLengthTuple>) { - // We have already verified above that the two tuples have the same length. - for (target, value) in self.0.iter_mut().zip(values.iter_all_elements()) { - target.add_in_place(value); - } - } -} - -impl<'db> VariableLengthTuple> { - fn unpack_tuple( - &mut self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - values: &VariableLengthTuple, VariableSegment<'db>>, - ) { - // We have already verified above that the two tuples have the same length. - for (target, value) in - (self.prefix_elements_mut().iter_mut()).zip(values.iter_prefix_elements()) - { - target.add_in_place(value); - } - self.variable_element_mut() - .add_in_place(KnownClass::List.to_specialized_instance( - db, - env, - &[values.variable().element_type(db)], - )); - for (target, value) in - (self.suffix_elements_mut().iter_mut()).zip(values.iter_suffix_elements()) - { - target.add_in_place(value); - } - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum ResizeTupleError { TooFewValues, TooManyValues, } -/// A builder for creating a new [`TupleSpec`] +/// A builder for a fixed or variable-length sequence. #[derive(Clone)] -pub(crate) enum TupleSpecBuilder<'db> { - Fixed(Vec>), +pub(crate) enum TupleBuilder { + Fixed(Vec), Variable { - prefix: Vec>, - segment: VariableSegment<'db>, - suffix: Vec>, + prefix: Vec, + segment: V, + suffix: Vec, }, } -impl<'db> TupleSpecBuilder<'db> { +pub(crate) type TupleSpecBuilder<'db> = TupleBuilder, VariableSegment<'db>>; + +impl TupleBuilder { pub(crate) fn with_capacity(capacity: usize) -> Self { - TupleSpecBuilder::Fixed(Vec::with_capacity(capacity)) + Self::Fixed(Vec::with_capacity(capacity)) + } + + pub(crate) fn push(&mut self, element: T) { + match self { + Self::Fixed(elements) => elements.push(element), + Self::Variable { suffix, .. } => suffix.push(element), + } + } + + /// Appends `other`, preserving the elements whose positions are known from either end. + /// + /// A literal expansion preserves every position: + /// + /// ```python + /// result = (1, *[2, 3]) + /// ``` + /// + /// An expansion of unknown length leaves the enclosing prefix and suffix fixed: + /// + /// ```python + /// def example(items: list[str]): + /// return (1, *items, 2) + /// ``` + /// + /// Here `1` and `2` remain fixed around the variable segment contributed by `items`. + /// + /// When both sequences have variable segments, they must share one in the result: + /// + /// ```python + /// def example(xs: list[int], ys: list[str]): + /// return (1, *xs, 2, *(3, *ys, 4)) + /// ``` + /// + /// At the final expansion, the builder holds `(1, *xs, 2)` and `other` represents + /// `(3, *ys, 4)`. `merge` receives the left suffix `[2]`, the mutable left segment for + /// `xs`, the right segment for `ys`, and the right prefix `[3]`, in that order. It folds + /// the suffix, prefix, and right segment into the left segment. Only `1` and `4` remain + /// fixed in the result. The caller decides how to combine the segments' types or source + /// expressions; `merge` is not called unless both sequences have variable segments. + pub(crate) fn concat_with( + mut self, + other: &Tuple, + merge: impl FnOnce(&[T], &mut V, &V, &[T]), + ) -> Self + where + T: Clone, + V: Clone, + { + match (&mut self, other) { + // Expanding the literal appends two known positions to the fixed prefix `1`: + // + // ```python + // result = (1, *[2, 3]) + // ``` + (Self::Fixed(left), Tuple::Fixed(right)) => { + left.extend_from_slice(right.elements_slice()); + self + } + // The outer expansion extends the fixed prefix to `1, 2`, with the variable + // segment from `items` followed by the suffix `3`: + // + // ```python + // def example(items: list[str]): + // return (1, *(2, *items, 3)) + // ``` + (Self::Fixed(left), Tuple::Variable(right)) => { + left.extend_from_slice(right.prefix_elements()); + Self::Variable { + prefix: std::mem::take(left), + segment: right.variable_segment.clone(), + suffix: right.suffix_elements().to_vec(), + } + } + // The final expansion extends the existing suffix `2` to `2, 3, 4`, without + // changing the prefix or variable segment: + // + // ```python + // def example(items: list[str]): + // return (1, *items, 2, *[3, 4]) + // ``` + (Self::Variable { suffix, .. }, Tuple::Fixed(right)) => { + suffix.extend_from_slice(right.elements_slice()); + self + } + // Neither `2` nor `3` has a fixed offset from either end, because both `xs` + // and `ys` have unknown length. They join the combined variable segment, + // leaving the outer prefix `1` and suffix `4`: + // + // ```python + // def example(xs: list[int], ys: list[str]): + // return (1, *xs, 2, *(3, *ys, 4)) + // ``` + ( + Self::Variable { + segment, suffix, .. + }, + Tuple::Variable(right), + ) => { + merge( + suffix, + segment, + &right.variable_segment, + right.prefix_elements(), + ); + suffix.clear(); + suffix.extend_from_slice(right.suffix_elements()); + self + } + } } - pub(crate) fn push(&mut self, element: Type<'db>) { + pub(super) fn build(self) -> Tuple { match self { - TupleSpecBuilder::Fixed(elements) => elements.push(element), - TupleSpecBuilder::Variable { suffix, .. } => suffix.push(element), + Self::Fixed(elements) => Tuple::Fixed(FixedLengthTuple(elements.into_boxed_slice())), + Self::Variable { + prefix, + segment, + suffix, + } => Tuple::Variable(VariableLengthTuple::new_from_vec(prefix, segment, suffix)), } } +} +impl<'db> TupleSpecBuilder<'db> { /// Concatenates an unpacked `TypeVarTuple` as the variable-length portion of this tuple. pub(crate) fn concat_variadic_typevar( self, @@ -2972,63 +3120,22 @@ impl<'db> TupleSpecBuilder<'db> { /// Concatenates another tuple to the end of this tuple, returning a new tuple. pub(crate) fn concat( - mut self, + self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, other: &TupleSpec<'db>, ) -> Self { - match (&mut self, other) { - (TupleSpecBuilder::Fixed(left_tuple), TupleSpec::Fixed(right_tuple)) => { - left_tuple.extend_from_slice(&right_tuple.0); - self - } - - (TupleSpecBuilder::Fixed(left_tuple), TupleSpec::Variable(variable_tuple)) => { - left_tuple.extend_from_slice(variable_tuple.prefix_elements()); - TupleSpecBuilder::Variable { - prefix: std::mem::take(left_tuple), - segment: variable_tuple.variable(), - suffix: variable_tuple.suffix_elements().to_vec(), - } - } - - ( - TupleSpecBuilder::Variable { - prefix: _, - segment: _, - suffix, - }, - TupleSpec::Fixed(right), - ) => { - suffix.extend_from_slice(&right.0); - self - } - - ( - TupleSpecBuilder::Variable { - prefix: left_prefix, - segment: left_segment, - suffix: left_suffix, - }, - TupleSpec::Variable(right), - ) => { - let variable = UnionType::from_elements_leave_aliases( - db, - env, - left_suffix - .iter() - .copied() - .chain(std::iter::once(left_segment.element_type(db))) - .chain(std::iter::once(right.variable().element_type(db))) - .chain(right.iter_prefix_elements()), - ); - TupleSpecBuilder::Variable { - prefix: std::mem::take(left_prefix), - segment: VariableSegment::Homogeneous(variable), - suffix: right.suffix_elements().to_vec(), - } - } - } + self.concat_with(other, |suffix, left, right, prefix| { + *left = VariableSegment::Homogeneous(UnionType::from_elements_leave_aliases( + db, + env, + suffix + .iter() + .copied() + .chain([left.element_type(db), right.element_type(db)]) + .chain(prefix.iter().copied()), + )); + }) } fn iter_element_types(&self, db: &'db dyn Db) -> impl Iterator> + '_ { @@ -3126,7 +3233,7 @@ impl<'db> TupleSpecBuilder<'db> { // Fixed-length tuples with different lengths cannot intersect. (TupleSpecBuilder::Fixed(_), TupleSpec::Fixed(_)) => None, - (TupleSpecBuilder::Fixed(our_elements), TupleSpec::Variable(var)) => var + (TupleSpecBuilder::Fixed(our_elements), TupleSpec::Variable(_)) => other .resize(db, env, TupleLength::Fixed(our_elements.len())) .ok() .and_then(|tuple| self.intersect(db, env, &tuple)), @@ -3174,7 +3281,8 @@ impl<'db> TupleSpecBuilder<'db> { let self_built = self.clone().build(); let self_len = self_built.len(); - var.resize(db, env, self_len) + other + .resize(db, env, self_len) .ok() .and_then(|resized| self.intersect(db, env, &resized)) .or_else(|| { @@ -3188,19 +3296,6 @@ impl<'db> TupleSpecBuilder<'db> { } } } - - pub(super) fn build(self) -> TupleSpec<'db> { - match self { - TupleSpecBuilder::Fixed(elements) => { - TupleSpec::Fixed(FixedLengthTuple(elements.into_boxed_slice())) - } - TupleSpecBuilder::Variable { - prefix, - segment, - suffix, - } => TupleSpec::Variable(VariableLengthTuple::new_from_vec(prefix, segment, suffix)), - } - } } impl<'db> From<&TupleSpec<'db>> for TupleSpecBuilder<'db> { diff --git a/crates/ty_python_semantic/src/types/tuple/promotion.rs b/crates/ty_python_semantic/src/types/tuple/promotion.rs index 018a6c4a71..beb29f9f87 100644 --- a/crates/ty_python_semantic/src/types/tuple/promotion.rs +++ b/crates/ty_python_semantic/src/types/tuple/promotion.rs @@ -30,8 +30,8 @@ impl<'db> TupleSizePromotionConstraints<'db> { expression: &ast::Expr, ty: Type<'db>, ) { - if !Self::is_promotable_tuple_literal(db, env, expression, ty) { - self.record_unpromotable_type(db, env, typevar_identity, ty); + if !Self::allows_expression(db, env, Some(expression), ty) { + self.blocked_typevars.insert(typevar_identity); } } @@ -44,9 +44,7 @@ impl<'db> TupleSizePromotionConstraints<'db> { typevar_identity: BoundTypeVarIdentity<'db>, ty: Type<'db>, ) { - if any_over_type(db, env, ty, true, |ty| { - ty.tuple_instance_spec(db, env).is_some() - }) { + if !Self::allows_expression(db, env, None, ty) { self.blocked_typevars.insert(typevar_identity); } } @@ -57,6 +55,29 @@ impl<'db> TupleSizePromotionConstraints<'db> { !self.blocked_typevars.contains(&typevar_identity) } + /// Reports whether an inferred collection element allows tuple size promotion. Tuple types + /// from annotations or nonliteral expressions keep their shape. + /// + /// For `items = [(1,), (2, 3)]`, both tuple literals are eligible, so their differing lengths + /// may be widened to `tuple[int, ...]`. With `pair = (2, 3)` followed by + /// `items = [(1,), pair]`, the nonliteral `pair` blocks promotion for the collection. + /// + /// The supplied `ty` should already have undergone literal promotion, so `(2, 3)` has the + /// homogeneous type `tuple[int, int]` when checking its eligibility. + /// If no source expression is available, any tuple type blocks tuple-size promotion. + pub(crate) fn allows_expression( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + expression: Option<&ast::Expr>, + ty: Type<'db>, + ) -> bool { + expression + .is_some_and(|expression| Self::is_promotable_tuple_literal(db, env, expression, ty)) + || !any_over_type(db, env, ty, true, |ty| { + ty.tuple_instance_spec(db, env).is_some() + }) + } + /// Returns true if the given expression is either a non-starred homogeneous tuple literal or the /// empty tuple (and hence is eligible for tuple size promotion). fn is_promotable_tuple_literal( diff --git a/crates/ty_python_semantic/src/types/type_alias.rs b/crates/ty_python_semantic/src/types/type_alias.rs index dcf588b207..69b7294a6b 100644 --- a/crates/ty_python_semantic/src/types/type_alias.rs +++ b/crates/ty_python_semantic/src/types/type_alias.rs @@ -4,13 +4,13 @@ use std::fmt::Write; use crate::{ Db, FxOrderSet, types::{ - ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, GenericContext, - KnownInstanceType, MaterializationKind, Type, TypeContext, TypeMapping, TypeVarVariance, - definition_expression_type, + ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, + GenericContext, KnownClass, KnownInstanceType, MaterializationKind, Type, TypeContext, + TypeMapping, TypeRecursionContext, TypingModule, VarianceTerm, definition_expression_type, display::qualified_name_components_from_scope, generics::{ApplySpecialization, Specialization, bind_typevar}, match_type::{MatchTypeOutcome, evaluate_match_type}, - variance::VarianceInferable, + variance::{VarianceInferable, VarianceOrigin}, visitor, }, }; @@ -24,6 +24,81 @@ use ruff_db::parsed::parsed_module; use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast}; +impl<'db> Type<'db> { + /// Returns whether expanding aliases and unions can return to the same alias without entering + /// another type. For example, `type A = int | A` is invalid, but + /// `type A = int | list[A]` is a valid recursive alias. + pub(super) fn has_unguarded_alias_cycle(self, db: &'db dyn Db) -> bool { + AliasCycleSummary::from_type(db, self).cyclic + } +} + +/// An alias's cycles and the type variables exposed outside containers and other enclosing types. +/// Only arguments substituted for these variables can introduce an unguarded cycle. +#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::SalsaValue, get_size2::GetSize)] +struct AliasCycleSummary<'db> { + cyclic: bool, + typevars: Box<[BoundTypeVarInstance<'db>]>, +} + +impl<'db> AliasCycleSummary<'db> { + fn from_type(db: &'db dyn Db, ty: Type<'db>) -> Self { + let mut typevars = FxOrderSet::default(); + let cyclic = Self::collect(db, ty, &mut typevars); + Self { + cyclic, + typevars: typevars.into_iter().collect(), + } + } + + fn collect( + db: &'db dyn Db, + ty: Type<'db>, + typevars: &mut FxOrderSet>, + ) -> bool { + match ty { + Type::TypeAlias(alias) => { + // Inspect the definition independently of its arguments. Nested applications like + // `Recursive[Recursive[int]]` can be finite even when `Recursive` has growing + // recursive references beneath a container. + let summary = alias.cycle_summary(db); + if summary.cyclic { + return true; + } + let specialization = alias.specialization(db).or_else(|| { + alias + .generic_context(db) + .map(|context| context.default_specialization(db, None)) + }); + + // Process supplied arguments after completing the definition's summary. An + // exposed argument can still close a cycle in the caller, as in + // `type Identity[T] = T; type Cycle = Identity[Cycle]`. + summary.typevars.iter().any(|&typevar| { + if let Some(argument) = + specialization.and_then(|specialization| specialization.get(db, typevar)) + && argument != Type::TypeVar(typevar) + { + Self::collect(db, argument, typevars) + } else { + typevars.insert(typevar); + false + } + }) + } + Type::TypeVar(typevar) => { + typevars.insert(typevar); + false + } + Type::Union(union) => union + .elements(db) + .iter() + .any(|&element| Self::collect(db, element, typevars)), + _ => ty.is_divergent(), + } + } +} + #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct PEP695TypeAliasType<'db> { #[returns(ref)] @@ -83,13 +158,14 @@ impl<'db> PEP695TypeAliasType<'db> { /// first `case` whose pattern matches the subject, evaluated against this alias's own /// specialization. An application that cannot pick a case yet has no value, and is /// reported as `Unknown` while it waits to be specialized. - pub(crate) fn value_type(self, db: &'db dyn Db) -> Type<'db> { + fn value_type(self, db: &'db dyn Db) -> Type<'db> { if !self.is_match_type(db) { return apply_type_alias_specialization( db, self.raw_value_type(db), self.generic_context(db), self.specialization(db), + None, ); } match evaluate_match_type(db, self) { @@ -112,7 +188,13 @@ impl<'db> PEP695TypeAliasType<'db> { /// Match-type evaluation needs this for the subject and for the winning case's body, /// both of which are written in terms of the alias's type parameters. pub(crate) fn apply_own_specialization(self, db: &'db dyn Db, ty: Type<'db>) -> Type<'db> { - apply_type_alias_specialization(db, ty, self.generic_context(db), self.specialization(db)) + apply_type_alias_specialization( + db, + ty, + self.generic_context(db), + self.specialization(db), + None, + ) } /// The RHS type of a PEP-695 style type alias with *no* specialization applied. @@ -194,6 +276,9 @@ pub struct ManualPEP695TypeAliasType<'db> { #[returns(copy)] pub definition: Definition<'db>, + #[returns(copy)] + pub(super) typing_module: TypingModule, + #[returns(copy)] pub(super) specialization: Option>, @@ -224,6 +309,7 @@ impl<'db> ManualPEP695TypeAliasType<'db> { self.raw_value_type(db), self.generic_context(db), self.specialization(db), + None, ) } @@ -270,6 +356,7 @@ impl<'db> ManualPEP695TypeAliasType<'db> { db, self.name(db), self.definition(db), + self.typing_module(db), Some(f(generic_context)), self.materialization_kind(db), ) @@ -320,6 +407,7 @@ fn apply_type_alias_specialization<'db>( ty: Type<'db>, generic_context: Option>, specialization: Option>, + recursion_context: Option<&TypeRecursionContext<'db>>, ) -> Type<'db> { let Some(generic_context) = generic_context else { return ty; @@ -341,7 +429,7 @@ fn apply_type_alias_specialization<'db>( &env, &type_mapping, TypeContext::default(), - &ApplyTypeMappingVisitor::new(&env), + &ApplyTypeMappingVisitor::new(&env).with_recursion_context(recursion_context), ) } @@ -359,6 +447,7 @@ pub(super) fn walk_type_alias_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( visitor: &V, ) { if !visitor.should_visit_lazy_type_attributes() { + visitor.notify_skipped_lazy_type_attributes(); return; } match type_alias { @@ -373,6 +462,34 @@ pub(super) fn walk_type_alias_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( #[salsa::tracked] impl<'db> TypeAliasType<'db> { + /// Summarize an alias's raw definition once, sharing the result across references. + /// Specializations reuse this summary and check their exposed arguments separately. + fn cycle_summary(self, db: &'db dyn Db) -> &'db AliasCycleSummary<'db> { + #[salsa::tracked( + returns(ref), + cycle_initial=|_, _, _, ()| AliasCycleSummary { cyclic: true, ..AliasCycleSummary::default() }, + heap_size=ruff_memory_usage::heap_size + )] + fn cycle_summary<'db>( + db: &'db dyn Db, + alias: TypeAliasType<'db>, + (): (), + ) -> AliasCycleSummary<'db> { + AliasCycleSummary::from_type(db, alias.raw_value_type(db)) + } + + cycle_summary(db, self.unspecialized(db), ()) + } + + pub(super) fn known_class(self, db: &'db dyn Db) -> KnownClass { + match self { + TypeAliasType::PEP695(_) => KnownClass::TypeAliasType, + TypeAliasType::ManualPEP695(type_alias) => { + type_alias.typing_module(db).type_alias_class() + } + } + } + pub(crate) fn name(self, db: &'db dyn Db) -> &'db str { match self { TypeAliasType::PEP695(type_alias) => type_alias.name(db), @@ -398,6 +515,47 @@ impl<'db> TypeAliasType<'db> { } } + /// Resolve this alias while preserving active recursion guards. + /// + /// During meta-type projection, results can depend on which aliases or type variables are + /// already being projected and must stay out of the materialization cache. The raw alias body + /// is still inferred independently by Salsa. Other operations retain ordinary caching unless + /// their recursion state also requires context-dependent expansion. + pub(super) fn value_type_with_recursion( + self, + db: &'db dyn Db, + context: Option<&TypeRecursionContext<'db>>, + ) -> Type<'db> { + let Some(context) = context.filter(|context| context.meta_type.is_active()) else { + return self.value_type(db); + }; + + let alias = self.with_materialization_kind(db, None); + let value_type = apply_type_alias_specialization( + db, + alias.raw_value_type(db), + alias.generic_context(db), + alias.specialization(db), + Some(context), + ); + + let Some(materialization_kind) = self.materialization_kind(db) else { + return value_type; + }; + let env = match alias { + TypeAliasType::PEP695(alias) => ProgramEnvironment::from_scope(alias.rhs_scope(db)), + TypeAliasType::ManualPEP695(alias) => { + ProgramEnvironment::from_definition(alias.definition(db)) + } + }; + value_type.materialize( + db, + &env, + materialization_kind, + &ApplyTypeMappingVisitor::new(&env).with_recursion_context(Some(context)), + ) + } + /// Materialize the alias body lazily, keeping this alias as the recursive fallback. /// /// Comparing a recursive specialization with its materialization can request this same body @@ -445,6 +603,7 @@ impl<'db> TypeAliasType<'db> { db, alias.name(db), alias.definition(db), + alias.typing_module(db), None, None, )) @@ -481,6 +640,7 @@ impl<'db> TypeAliasType<'db> { db, alias.name(db), alias.definition(db), + alias.typing_module(db), alias.specialization(db), materialization_kind, )) @@ -536,30 +696,32 @@ impl<'db> VarianceInferable<'db> for TypeAliasType<'db> { db: &'db dyn Db, _: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - self.variance_of_owner(db, typevar) + ) -> VarianceTerm<'db> { + VarianceTerm::variable(db, VarianceOrigin::TypeAlias(self), typevar) } } #[salsa::tracked] impl<'db> TypeAliasType<'db> { + /// Measure the alias's own parameters in its raw RHS, and external parameters through its + /// specialization arguments. For `type Items[T] = list[T]`, querying `Items[int]` for its + /// formal `T` still describes `list[T]`, not the specialized `list[int]`. #[salsa::tracked( returns(copy), - cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, + cycle_initial=|_, _, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size )] - fn variance_of_owner( + pub(in crate::types) fn variance_equation( self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { let env = ProgramEnvironment::from_definition(self.definition(db)); let Some(generic_context) = self.generic_context(db) else { return self.value_type(db).variance_of(db, &env, typevar); }; - // Infer an alias's own type-parameter variance from the raw RHS. Applying specialization - // here would recursively request the same `variance_of` query. + // Applying specialization here can re-enter variance inference for the same alias. if generic_context .variables(db) .any(|alias_typevar| alias_typevar.identity(db) == typevar) @@ -574,15 +736,15 @@ impl<'db> TypeAliasType<'db> { // For external typevars, variance flows through the specialization arguments. Expanding // the specialized alias body here can create ever-larger recursive alias applications. - generic_context + let variances = generic_context .variables(db) .zip(specialization.types(db)) .map(|(alias_typevar, argument_ty)| { raw_value_type .variance_of(db, &env, alias_typevar.identity(db)) - .compose_thunk(|| argument_ty.variance_of(db, &env, typevar)) - }) - .collect() + .compose_thunk(db, || argument_ty.variance_of(db, &env, typevar)) + }); + VarianceTerm::join(db, variances) } } diff --git a/crates/ty_python_semantic/src/types/type_form.rs b/crates/ty_python_semantic/src/types/type_form.rs index b85763b23c..e56c990bd1 100644 --- a/crates/ty_python_semantic/src/types/type_form.rs +++ b/crates/ty_python_semantic/src/types/type_form.rs @@ -1,8 +1,5 @@ -use super::variance::VarianceInferable; -use super::{ - BoundTypeVarIdentity, CycleDetector, IntersectionType, Type, TypeVarVariance, UnionType, - visitor, -}; +use super::variance::{VarianceInferable, VarianceTerm}; +use super::{BoundTypeVarIdentity, CycleDetector, IntersectionType, Type, UnionType, visitor}; use crate::Db; use crate::ProgramEnvironment; @@ -103,7 +100,7 @@ impl<'db> VarianceInferable<'db> for TypeFormType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { self.type_argument(db).variance_of(db, env, typevar) } } diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index c30eb24040..b09b096212 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -18,16 +18,16 @@ use super::diagnostic::{ }; use super::infer::{TypeExpressionFlags, infer_deferred_types}; use super::{ - ApplyTypeMappingVisitor, ErrorContext, IntersectionType, Type, TypeMapping, TypeQualifiers, - UnionBuilder, definition_expression_annotation, definition_expression_type, visitor, + ApplyTypeMappingVisitor, BoundTypeVarIdentity, ErrorContext, IntersectionType, Type, + TypeMapping, TypeQualifiers, TypeVarVariance, UnionBuilder, VarianceInferable, VarianceTerm, + definition_expression_annotation, definition_expression_type, visitor, }; use crate::types::TypeContext; use crate::types::TypeDefinition; use crate::types::class::FieldKind; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::relation::{DisjointnessChecker, TypeRelation, TypeRelationChecker}; -use crate::types::typevar::BoundTypeVarIdentity; -use crate::types::variance::{TypeVarVariance, VarianceInferable}; +use crate::types::variance::VarianceOrigin; use crate::{Db, ProgramEnvironment}; use ty_python_core::Truthiness; use ty_python_core::definition::Definition; @@ -483,41 +483,24 @@ impl<'db> TypedDictType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Option> { - let extra_items = self.explicit_extra_items(db)?; - if extra_items.is_read_only() - || self.items(db).values().any(|field| { - field.is_required() - || field.is_read_only() - || !field - .declared_ty - .is_equivalent_to(db, env, extra_items.declared_ty) - }) - { - return None; - } - Some(extra_items.declared_ty) + self.dict_value_type_if(db, |field_ty, extra_items_ty| { + field_ty.is_equivalent_to(db, env, extra_items_ty) + }) } - /// Returns the value type if this `TypedDict` is assignable to `dict[str, VT]`. - /// - /// This uses mutual assignability rather than equivalence so gradual value types can satisfy - /// the mutable `dict` contract. - pub(crate) fn assignable_dict_value_type( + /// Like [`Self::dict_value_type`], but uses the caller's equivalence or mutual-assignability + /// check so recursive comparisons can share their cycle guards. + pub(super) fn dict_value_type_if( self, db: &'db dyn Db, - env: &ProgramEnvironment<'db>, + types_match: impl Fn(Type<'db>, Type<'db>) -> bool, ) -> Option> { let extra_items = self.explicit_extra_items(db)?; if extra_items.is_read_only() || self.items(db).values().any(|field| { field.is_required() || field.is_read_only() - || !field - .declared_ty - .is_assignable_to(db, env, extra_items.declared_ty) - || !extra_items - .declared_ty - .is_assignable_to(db, env, field.declared_ty) + || !types_match(field.declared_ty, extra_items.declared_ty) }) { return None; @@ -575,9 +558,34 @@ impl<'db> TypedDictType<'db> { } } + pub(super) fn variance_of_items( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar: BoundTypeVarIdentity<'db>, + ) -> VarianceTerm<'db> { + let variances = self + .items(db) + .values() + .map(|field| (field.declared_ty, field.is_read_only())) + .chain( + self.explicit_extra_items(db) + .map(|extra_items| (extra_items.declared_ty, extra_items.is_read_only())), + ) + .map(|(ty, is_read_only)| { + let polarity = if is_read_only { + TypeVarVariance::Covariant + } else { + TypeVarVariance::Invariant + }; + ty.with_polarity(polarity).variance_of(db, env, typevar) + }); + VarianceTerm::join(db, variances) + } + /// basedpython: the fields and still-pending `{**Kwargs}` packs of a dict-literal type, if /// this `TypedDict` was synthesized from one. - pub(crate) fn synthesized_shape( + fn synthesized_shape( self, db: &'db dyn Db, ) -> Option<(&'db TypedDictSchema<'db>, &'db [Type<'db>])> { @@ -1149,9 +1157,12 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { ) -> ConstraintSet<'db, 'c> { let left_items = left.items(db); let right_items = right.items(db); - let fields_in_common = btreemap_values_with_same_key(left_items, right_items); + let fields_in_common = btreemap_items_with_same_key(left_items, right_items); let common_fields_disjoint = - fields_in_common.when_any(db, self.constraints, |(left_field, right_field)| { + fields_in_common.when_any(db, self.constraints, |(name, left_field, right_field)| { + if let Some(context) = self.report_context() { + context.take(); + } // Condition 1 above. if left_field.is_required() || right_field.is_required() { if (!left_field.is_required() && !left_field.is_read_only()) @@ -1159,37 +1170,86 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { { // One side demands a `Required` source field, while the other side demands a // `NotRequired` one. They must be disjoint. + if let Some(context) = self.report_context() { + let (required, not_required) = if left_field.is_required() { + (left, right) + } else { + (right, left) + }; + context.push(ErrorContext::TypedDictRequirednessConflict { + field_name: name.clone(), + required, + not_required, + }); + } return self.always(); } } - if !left_field.is_read_only() && !right_field.is_read_only() { + let result = if !left_field.is_read_only() && !right_field.is_read_only() { // Condition 2 above. This field is mutable on both sides, so the so the types must // be compatible, i.e. mutually assignable. - let relation_checker = self.as_relation_checker(TypeRelation::Assignability); - relation_checker - .check_type_pair(db, left_field.declared_ty, right_field.declared_ty) - .and(db, self.constraints, || { - relation_checker.check_type_pair( + self.check_relation_with_context( + db, + self.as_relation_checker(TypeRelation::Assignability), + |relation_checker| { + relation_checker + .check_type_pair( + db, + left_field.declared_ty, + right_field.declared_ty, + ) + .and(db, self.constraints, || { + relation_checker.check_type_pair( + db, + right_field.declared_ty, + left_field.declared_ty, + ) + }) + }, + ) + .negate(db, self.constraints) + } else if !left_field.is_read_only() { + // Half of condition 3 above. + self.check_relation_with_context( + db, + self.as_relation_checker(TypeRelation::Assignability), + |checker| { + checker.check_type_pair( db, - right_field.declared_ty, left_field.declared_ty, + right_field.declared_ty, ) - }) - .negate(db, self.constraints) - } else if !left_field.is_read_only() { - // Half of condition 3 above. - self.as_relation_checker(TypeRelation::Assignability) - .check_type_pair(db, left_field.declared_ty, right_field.declared_ty) - .negate(db, self.constraints) + }, + ) + .negate(db, self.constraints) } else if !right_field.is_read_only() { // The other half of condition 3 above. - self.as_relation_checker(TypeRelation::Assignability) - .check_type_pair(db, right_field.declared_ty, left_field.declared_ty) - .negate(db, self.constraints) + self.check_relation_with_context( + db, + self.as_relation_checker(TypeRelation::Assignability), + |checker| { + checker.check_type_pair( + db, + right_field.declared_ty, + left_field.declared_ty, + ) + }, + ) + .negate(db, self.constraints) } else { // Condition 4 above. self.check_type_pair(db, left_field.declared_ty, right_field.declared_ty) + }; + if let Some(context) = self.report_context() + && result.is_always_satisfied(db, self.env) + { + context.push(ErrorContext::TypedDictFieldTypeConflict { + field_name: name.clone(), + left: left_field.declared_ty, + right: right_field.declared_ty, + }); } + result }); let required_fields_disjoint = common_fields_disjoint.or(db, self.constraints, || { @@ -1333,43 +1393,6 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { } } -impl<'db> VarianceInferable<'db> for TypedDictType<'db> { - /// basedpython: a `{"key": T}` literal is a non-generic class, so a type variable written in - /// its schema has no other route to variance inference and would otherwise read as bivariant. - /// - /// Only synthesized literals are walked. A class-based `TypedDict` can name itself in its own - /// fields, and walking a class body here has no recursion guard; those keep the bivariant - /// answer they have always had. - /// - /// A dict-literal field is always mutable — there is no spelling for a read-only one — so the - /// `TypedDict` is invariant in every field it declares. - /// - /// A pending `{**Kwargs}` pack is deliberately *not* walked. Reporting the pack as invariant - /// here is the right answer, but it routes the enclosing call through the declared-type - /// preference in `Bindings::infer_specialization`, which then adopts the declared pack without - /// checking the arguments against it — `a: A[foo=int] = A(bar=1)` starts passing. Until a pack - /// pinned by a type context also drives the call's arity, bivariant is the safe answer. - fn variance_of( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - let Some((schema, _packs)) = self.synthesized_shape(db) else { - return TypeVarVariance::Bivariant; - }; - schema - .values() - .map(|field| { - field - .declared_ty - .with_polarity(TypeVarVariance::Invariant) - .variance_of(db, env, typevar) - }) - .collect() - } -} - pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, typed_dict: TypedDictType<'db>, @@ -1387,6 +1410,7 @@ pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } if !visitor.should_visit_lazy_type_attributes() { + visitor.notify_skipped_lazy_type_attributes(); return; } } @@ -1400,6 +1424,28 @@ pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } } +impl<'db> VarianceInferable<'db> for TypedDictType<'db> { + fn variance_of( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar: BoundTypeVarIdentity<'db>, + ) -> VarianceTerm<'db> { + match self { + Self::Class(class) if class.static_class_literal(db).is_some() => { + // Compose each type parameter's variance with its type argument. Inferred variance + // is computed on the unspecialized class: expanding specialized fields here would + // not terminate for a recursive item such as `child: Node[list[T]]`. + class.variance_of(db, env, typevar) + } + Self::Class(class) => { + VarianceTerm::variable(db, VarianceOrigin::TypedDict(class), typevar) + } + Self::Synthesized(_) => self.variance_of_items(db, env, typevar), + } + } +} + #[salsa::tracked( returns(ref), cycle_initial = |_, _, _|TypedDictSchema::default(), @@ -1626,7 +1672,7 @@ impl<'db> TypedDictKeyAssignment<'_, 'db, '_> { return true; } - if diagnostic::is_invalid_typed_dict_literal(db, item.declared_ty, self.value_node) { + if diagnostic::is_invalid_typed_dict_literal(db, env, item.declared_ty, self.value_node) { return false; } @@ -2063,6 +2109,7 @@ pub(crate) fn extract_unpacked_typed_dict_from_value_type<'db>( | Type::SpecialForm(_) | Type::KnownInstance(_) | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) | Type::AlwaysTruthy | Type::AlwaysFalsy | Type::LiteralValue(_) @@ -3305,14 +3352,14 @@ bitflags! { impl get_size2::GetSize for TypedDictFieldFlags {} -/// Yield all the key/val pairs where the same key is present in both `BTreeMap`s. Take advantage +/// Yield each shared key and its values from both `BTreeMap`s. Take advantage /// of the fact that keys are sorted to walk through each map once without doing any lookups. It /// would be nice if `BTreeMap` had something like `BTreeSet::intersection` that did this for us, /// but as far as I know we have to do it ourselves. Life is hard. -fn btreemap_values_with_same_key<'a, K, V1, V2>( +fn btreemap_items_with_same_key<'a, K, V1, V2>( left: &'a BTreeMap, right: &'a BTreeMap, -) -> impl Iterator +) -> impl Iterator where K: Ord, { @@ -3324,10 +3371,10 @@ where { match left_key.cmp(right_key) { Ordering::Equal => { - // Matching keys. Yield this pair of values and advance both iterators. + // Matching keys. Yield the key and both values, then advance both iterators. left_items.next(); right_items.next(); - return Some((left_val, right_val)); + return Some((left_key, left_val, right_val)); } Ordering::Less => { // `left_items` is behind `right_items` in key order. Advance `left_items`. @@ -3345,30 +3392,22 @@ where } #[test] -fn test_btreemap_overlapping_items() { +fn btreemap_overlapping_items() { // A case with partial overlap and gaps. let left = BTreeMap::from_iter([("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5)]); let right = BTreeMap::from_iter([("b", 2.0), ("d", 4.0), ("f", 6.0)]); assert_eq!( - btreemap_values_with_same_key(&left, &right).collect::>(), - vec![(&2, &2.0), (&4, &4.0)], + btreemap_items_with_same_key(&left, &right).collect::>(), + vec![(&"b", &2, &2.0), (&"d", &4, &4.0)], ); assert_eq!( - btreemap_values_with_same_key(&right, &left).collect::>(), - vec![(&2.0, &2), (&4.0, &4)], + btreemap_items_with_same_key(&right, &left).collect::>(), + vec![(&"b", &2.0, &2), (&"d", &4.0, &4)], ); // A case where one side is empty. let left = BTreeMap::::new(); let right = BTreeMap::::from_iter([(1, 1), (2, 2)]); - assert!( - btreemap_values_with_same_key(&left, &right) - .next() - .is_none() - ); - assert!( - btreemap_values_with_same_key(&right, &left) - .next() - .is_none() - ); + assert!(btreemap_items_with_same_key(&left, &right).next().is_none()); + assert!(btreemap_items_with_same_key(&right, &left).next().is_none()); } diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index d892be777f..8f56f0b54c 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -18,9 +18,9 @@ use crate::{ types::{ ApplySpecialization, ApplyTypeMappingVisitor, ClassLiteral, CycleDetector, DynamicType, GenericContext, InstanceProjection, IntersectionType, KnownClass, KnownInstanceType, - LintDiagnosticGuard, MaterializationKind, Parameter, Parameters, Type, TypeAliasType, - TypeContext, TypeMapping, TypeVarVariance, UnionBuilder, UnionType, any_over_type, - binding_type, + LintDiagnosticGuard, MaterializationKind, Parameter, Parameters, Specialization, Type, + TypeAliasType, TypeContext, TypeMapping, TypeVarVariance, UnionBuilder, UnionType, + any_over_type, any_over_type_including_alias_arguments, binding_type, constraints::ConstraintSetBuilder, definition_expression_type, tuple::Tuple, @@ -63,6 +63,32 @@ impl<'db> Type<'db> { any_over_type(db, env, self, false, |ty| matches!(ty, Type::TypeVar(_))) } + /// basedpython: whether this parameter type spells out a generic class's type argument + /// instead of naming it with a type variable. + /// + /// `items: list[T]` names it: `T` becomes whatever the argument's element type is, so + /// solving `T` reports what the argument holds. `container: Wrapper[Callable[Concatenate[object, P], R]]` + /// spells it out: the type argument has to be a callable of that shape, and the solved + /// `P` and `R` describe the parameter's own demand rather than the argument. + /// + /// A fluid specialization may adopt the first — the call is telling the binding what it + /// holds — but adopting the second would hand the argument the very type the parameter + /// asked for, so an invariant container would accept a type argument that does not match + /// it and nothing would report the mismatch. + pub(crate) fn prescribes_type_arguments( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + let Some((_, specialization)) = self.class_specialization(db, env) else { + return false; + }; + specialization + .types(db) + .iter() + .any(|argument| !argument.is_type_var() && argument.has_typevar(db, env)) + } + pub(crate) fn references_typevar( self, db: &'db dyn Db, @@ -78,6 +104,36 @@ impl<'db> Type<'db> { }) } + /// Returns whether this type might reference `typevar_id`, including type-alias arguments. + /// + /// Other non-lazy type-variable visitors stop at type aliases because inspecting an alias's + /// value can trigger lazy inference or expand a recursive definition. Receiver specialization + /// still needs to notice `T` in `Alias[T]`, so this visitor inspects the already-available + /// specialization arguments without evaluating the alias body. + /// + /// This deliberately over-approximates: `type Alias[T] = int` does not actually depend on + /// `T`, and specialization can also erase an argument. That can cause an unnecessary + /// receiver-specialization attempt, but actual receiver constraints are still solved before + /// changing the signature. Applying the same traversal to visitors that use type-variable + /// occurrences to drive inference or diagnostics can instead change behavior. + /// + /// TODO: Explore whether other type-variable visitors can safely inspect alias arguments, + /// accounting for unused parameters and arguments erased by specialization. + pub(crate) fn references_typevar_through_aliases( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar_id: TypeVarIdentity<'db>, + ) -> bool { + any_over_type_including_alias_arguments(db, env, self, |ty| match ty { + Type::TypeVar(typevar) => typevar_id == typevar.typevar(db).identity(db), + Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => { + typevar_id == typevar.identity(db) + } + _ => false, + }) + } + pub(crate) fn has_non_self_typevar( self, db: &'db dyn Db, @@ -154,6 +210,17 @@ impl<'db> Type<'db> { matches!(ty, Type::Dynamic(DynamicType::UnspecializedTypeVar)) }) } + + pub(crate) fn has_provisional_marker( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> bool { + any_over_type(db, env, self, false, |ty| { + ty.as_dynamic() + .is_some_and(DynamicType::is_provisional_marker) + }) + } } /// A specific instance of a type variable that has not been bound to a generic context yet. @@ -228,12 +295,16 @@ pub(super) fn walk_type_var_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( typevar.bound_or_constraints(db, visitor.program_environment()) } else { match typevar._bound_or_constraints(db) { - _ if visitor.should_visit_lazy_type_attributes() => { - typevar.bound_or_constraints(db, visitor.program_environment()) - } Some(TypeVarBoundOrConstraintsEvaluation::Eager(bound_or_constraints)) => { Some(bound_or_constraints) } + Some( + TypeVarBoundOrConstraintsEvaluation::LazyUpperBound + | TypeVarBoundOrConstraintsEvaluation::LazyConstraints, + ) => { + visitor.notify_skipped_lazy_type_attributes(); + None + } _ => None, } } { @@ -254,6 +325,10 @@ pub(super) fn walk_type_var_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } else { match typevar._default(db) { Some(TypeVarDefaultEvaluation::Eager(default_type)) => Some(default_type), + Some(TypeVarDefaultEvaluation::Lazy) => { + visitor.notify_skipped_lazy_type_attributes(); + None + } _ => None, } } { @@ -342,11 +417,7 @@ impl<'db> TypeVarInstance<'db> { /// /// [`bound_or_constraints`](Self::bound_or_constraints) therefore hides it, and this is the /// only way to reach it. - pub(crate) fn pack_bound( - self, - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - ) -> Option> { + fn pack_bound(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Option> { if !self.is_pack(db) { return None; } @@ -928,6 +999,7 @@ impl<'db> TypeVarInstance<'db> { | DynamicType::Unknown | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar + | DynamicType::UnknownLambdaParameter | DynamicType::InvalidConcatenateUnknown | DynamicType::AmbiguousOverload => Parameters::unknown(), }, @@ -1039,7 +1111,7 @@ impl<'db> TypeVarInstance<'db> { /// `0` is reserved for source-level, non-freshened typevars. Positive values identify fresh /// occurrences. #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct TypeVarNonce(u32); +pub(crate) struct TypeVarNonce(u32); // This type does not have any heap storage. impl get_size2::GetSize for TypeVarNonce {} @@ -1454,6 +1526,50 @@ impl<'db> BoundTypeVarInstance<'db> { Self::new(db, typevar, binding_context, None, TypeVarNonce::NONE) } + /// Applies a specialization to this occurrence's declared upper bound or constraints, if any. + fn apply_specialization_to_bound_or_constraints( + self, + db: &'db dyn Db, + specialization: Specialization<'db>, + env: &ProgramEnvironment<'db>, + ) -> Self { + self.map_bound_or_constraints(db, |original| { + let original = original?; + let mapping = TypeMapping::ApplySpecialization(ApplySpecialization::specialization( + specialization, + )); + let visitor = ApplyTypeMappingVisitor::new(env); + let bound = original.apply_type_mapping_impl(db, env, &mapping, &visitor); + // basedpython: substituting into `C[T]` rebuilds the specialization from the + // arguments alone, which loses the use-site projection the receiver was written + // with. `Self` stands for that receiver, so it has to keep the same view of it — + // otherwise the receiver fails its own `Self` bound at every call. + let projections = specialization.projections(db); + Some(match bound { + TypeVarBoundOrConstraints::UpperBound(bound) => { + TypeVarBoundOrConstraints::UpperBound(bound.with_use_site_projections( + db, + env, + projections, + )) + } + TypeVarBoundOrConstraints::Constraints(constraints) => { + let projected: Vec<_> = constraints + .elements(db) + .iter() + .map(|constraint| { + constraint.with_use_site_projections(db, env, projections) + }) + .collect(); + TypeVarBoundOrConstraints::Constraints(TypeVarConstraints::new( + db, + projected.as_slice(), + )) + } + }) + }) + } + /// Returns an identical type variable with its `TypeVarBoundOrConstraints` mapped by the /// provided closure. pub(crate) fn map_bound_or_constraints( @@ -1505,7 +1621,10 @@ impl<'db> BoundTypeVarInstance<'db> { if self.reifies_on(db, binding_ty) { return TypeVarVariance::Invariant; } - match binding_ty.variance_of(db, &env, self.identity(db)) { + match binding_ty + .variance_of(db, &env, self.identity(db)) + .evaluate(db) + { // When both directions are valid, the typing spec selects covariance. It // says so of a parameter the class never mentions; basedpython also infers // bivariance for one that only a private member mentions, and that @@ -1547,13 +1666,64 @@ impl<'db> BoundTypeVarInstance<'db> { return TypeVarVariance::Invariant; } let env = ProgramEnvironment::from_definition(definition); - binding_ty.variance_of(db, &env, self.identity(db)) + binding_ty + .variance_of(db, &env, self.identity(db)) + .evaluate(db) } BindingContext::Synthetic(_) => TypeVarVariance::Invariant, }, } } + /// basedpython: whether this parameter is bivariant only because nothing but a private + /// member mentions it. + /// + /// Inference reads this to tell the two sources of bivariance apart. A parameter declared + /// `in out`, and one no member mentions at all, are bivariant for good — there is no argument + /// hiding behind them to recover. A privately used one is different: the class really was + /// given an argument, and a solve that has to read it back gets nothing if the position is + /// skipped. Inference for a parameter no member mentions never reaches this: the spec's rule + /// reports it as covariant, so [`Self::variance`] never answers `Bivariant` for it. + pub(crate) fn is_bivariant_by_privacy(self, db: &'db dyn Db) -> bool { + self.typevar(db).explicit_variance(db).is_none() + && self.variance(db) == TypeVarVariance::Bivariant + } + + /// basedpython: the variance a *solve* should read at this parameter's position. + /// + /// A bivariant position relates nothing, so descending into one recovers no type argument. + /// That is the right answer for a parameter that really is bivariant, and the wrong one for a + /// parameter that is bivariant only [by privacy](Self::is_bivariant_by_privacy): the class + /// was given an argument there, and reading it covariantly is what recovers it. + /// + /// Reading it is only free while it cannot make the call stricter, and it stops being free as + /// soon as the variable being solved has a domain. `C[str]` and `C[int]` are mutually + /// assignable when only a private member mentions `C`'s parameter, so every argument is a + /// valid solution — but recovering `str` for a `U: int` and then measuring it against that + /// bound rejects a call the checker elsewhere says is fine. A bounded or constrained target + /// therefore keeps the bivariant reading and is left to ordinary inference. + pub(crate) fn solving_variance_with_polarity( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + polarity: TypeVarVariance, + target: Type<'db>, + ) -> TypeVarVariance { + let variance = self.variance_with_polarity(db, polarity); + if variance == TypeVarVariance::Bivariant + && self.is_bivariant_by_privacy(db) + && !any_over_type(db, env, target, false, |ty| { + matches!(ty, Type::TypeVar(target_typevar) + if target_typevar.typevar(db).bound_or_constraints(db, env).is_some()) + }) + { + // composing the polarity with covariance leaves the polarity itself + polarity + } else { + variance + } + } + /// basedpython: whether this is a type parameter of a class that reifies it, /// so the type argument is a runtime property of every instance rather than /// something erased at construction. @@ -1597,7 +1767,8 @@ impl<'db> BoundTypeVarInstance<'db> { if binding_ty.is_function_literal() { return binding_ty .with_polarity(TypeVarVariance::Covariant) - .variance_of(db, env, self.identity(db)); + .variance_of(db, env, self.identity(db)) + .evaluate(db); } self.variance(db) } @@ -1638,6 +1809,7 @@ impl<'db> BoundTypeVarInstance<'db> { binding_type(db, definition) .with_polarity(TypeVarVariance::Covariant) .variance_of(db, env, self.identity(db)) + .evaluate(db) .is_covariant() } @@ -1677,9 +1849,25 @@ impl<'db> BoundTypeVarInstance<'db> { }) }; + let possibly_apply_to_self = |specialization: &ApplySpecialization<'a, 'db>| { + if self.typevar(db).is_self(db) + && specialization.specialize_self_domain() + && let Some(specialization) = specialization.as_specialization(db) + { + Type::TypeVar(self.apply_specialization_to_bound_or_constraints( + db, + specialization, + visitor.env, + )) + } else { + Type::TypeVar(self) + } + }; + match type_mapping { TypeMapping::ApplySpecialization(specialization) => { - mapped_specialization_type(specialization).unwrap_or(Type::TypeVar(self)) + mapped_specialization_type(specialization) + .unwrap_or_else(|| possibly_apply_to_self(specialization)) } TypeMapping::ProjectUseSiteVariance { specialization, @@ -1689,14 +1877,13 @@ impl<'db> BoundTypeVarInstance<'db> { use ruff_python_ast::helpers::UseSiteVariance; let Some(value) = mapped_specialization_type(specialization) else { - return Type::TypeVar(self); - }; - let projection = match specialization { - ApplySpecialization::Specialization(specialization) => { - specialization.projection_for(db, self) - } - _ => None, + // a projected mapping still crosses the member boundary, so a retained `Self` + // has to have its domain rewritten here exactly as it would without projections + return possibly_apply_to_self(specialization); }; + let projection = specialization + .as_specialization(db) + .and_then(|specialization| specialization.projection_for(db, self)); match projection { None | Some(UseSiteVariance::InOut) => value, // an invariant or bivariant occurrence (e.g. the element of a @@ -1765,7 +1952,7 @@ impl<'db> BoundTypeVarInstance<'db> { } } }) - .unwrap_or(Type::TypeVar(self)), + .unwrap_or_else(|| possibly_apply_to_self(specialization)), TypeMapping::BindSelf(binding) => { if binding.should_bind(db, visitor.env, self) { binding.self_type() @@ -1817,7 +2004,11 @@ impl<'db> BoundTypeVarInstance<'db> { | TypeMapping::RescopeReturnCallables(_) | TypeMapping::AttachRegexGroups(_) => Type::TypeVar(self), TypeMapping::Materialize(materialization_kind) => { - Type::TypeVar(self.materialize_impl(db, env, *materialization_kind, visitor)) + if visitor.materialize_typevar_bounds_and_defaults { + Type::TypeVar(self.materialize_impl(db, env, *materialization_kind, visitor)) + } else { + Type::TypeVar(self) + } } } } @@ -2057,11 +2248,11 @@ impl TypeVarKind { } } - pub(super) const fn is_paramspec(self) -> bool { + const fn is_paramspec(self) -> bool { matches!(self, Self::LegacyParamSpec | Self::Pep695ParamSpec) } - pub const fn is_keyword_variadic(self) -> bool { + pub(crate) const fn is_keyword_variadic(self) -> bool { matches!(self, Self::Pep695KeywordVariadic) } @@ -2080,7 +2271,7 @@ impl TypeVarKind { matches!(self, Self::LegacyTypeVarTuple | Self::Pep695TypeVarTuple) } - pub const fn is_typing_self(self) -> bool { + const fn is_typing_self(self) -> bool { matches!(self, Self::TypingSelf) } } @@ -2225,7 +2416,7 @@ impl<'db> BindingContext<'db> { } } - pub(crate) fn program(self, db: &'db dyn Db) -> Program<'db> { + fn program(self, db: &'db dyn Db) -> Program<'db> { match self { Self::Definition(definition) => definition.program(db), Self::Synthetic(program) => program, @@ -2238,7 +2429,7 @@ impl<'db> BindingContext<'db> { } #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, get_size2::GetSize)] -pub enum ParamSpecAttrKind { +pub(crate) enum ParamSpecAttrKind { Args, Kwargs, } @@ -2290,7 +2481,7 @@ impl<'db> BoundTypeVarIdentity<'db> { self.kind(db).is_paramspec() } - pub(crate) fn is_parameter_pack(self, db: &'db dyn Db) -> bool { + fn is_parameter_pack(self, db: &'db dyn Db) -> bool { self.kind(db).is_parameter_pack() } @@ -2693,7 +2884,7 @@ pub enum TypeVarBoundOrConstraints<'db> { Constraints(TypeVarConstraints<'db>), } -pub(super) fn walk_type_var_bounds<'db, V: visitor::TypeVisitor<'db> + ?Sized>( +fn walk_type_var_bounds<'db, V: visitor::TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, bounds: TypeVarBoundOrConstraints<'db>, visitor: &V, @@ -2731,7 +2922,7 @@ impl<'db> TypeVarBoundOrConstraints<'db> { } } - fn apply_type_mapping_impl( + pub(crate) fn apply_type_mapping_impl( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, diff --git a/crates/ty_python_semantic/src/types/unpacker.rs b/crates/ty_python_semantic/src/types/unpacker.rs index d5c9e21d47..31d67de51d 100644 --- a/crates/ty_python_semantic/src/types/unpacker.rs +++ b/crates/ty_python_semantic/src/types/unpacker.rs @@ -1,5 +1,6 @@ use crate::ProgramEnvironment; use std::borrow::Cow; +use std::debug_assert_matches; use ruff_db::parsed::ParsedModuleRef; @@ -7,13 +8,18 @@ use rustc_hash::FxHashMap; use ruff_python_ast::visitor::{self, Visitor}; use ruff_python_ast::{self as ast, AnyNodeRef}; +use ruff_text_size::Ranged; use crate::Db; use crate::types::infer::{ExpressionInference, FrozenMap}; -use crate::types::tuple::{ResizeTupleError, TupleLength, TupleSpec, TupleUnpacker}; +use crate::types::tuple::promotion::TupleSizePromotionConstraints; +use crate::types::tuple::{ + ResizeTupleError, Tuple, TupleBuilder, TupleElement, TupleLength, TupleSpec, + VariableLengthTuple, +}; use crate::types::{ - Type, TypeCheckDiagnostics, TypeContext, infer_expression_types, - report_iteration_over_character, + KnownClass, Type, TypeCheckDiagnostics, TypeContext, UnionBuilder, UnionType, + infer_expression_types, report_iteration_over_character, }; use ty_python_core::ExpressionNodeKey; use ty_python_core::ProgramFile; @@ -75,8 +81,9 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { /// Unpack the value to the target expression. pub(crate) fn unpack(&mut self, target: &ast::Expr, value: UnpackValue<'db>) { let db = self.db(); - debug_assert!( - matches!(target, ast::Expr::List(_) | ast::Expr::Tuple(_)), + debug_assert_matches!( + target, + ast::Expr::List(_) | ast::Expr::Tuple(_), "Unpacking target must be a list or tuple expression" ); @@ -87,12 +94,6 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { ); let value_expr = value.expression().node_ref(self.db()).node(self.module()); - if matches!(value.kind(), UnpackKind::Assign) - && self.unpack_assignment_sequence_from_inference(target, value_expr, value_inference) - { - return; - } - let value_type = value_inference.expression_type(value_expr); let value_type = match value.kind() { @@ -137,12 +138,29 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { } }; - self.unpack_inner(target, value_expr.into(), value_type); + self.unpack_inner( + target, + value_expr.into(), + UnpackElement { + ty: value_type, + expression: matches!(value.kind(), UnpackKind::Assign).then_some(value_expr), + promote_literals: false, + }, + value_inference, + ); + } + + /// Records `Unknown` for a malformed unpack target and all of its descendant expressions. + fn record_unknown_target_subtree(&mut self, target: &ast::Expr) { + UnknownTargetCollector { + targets: &mut self.targets, + } + .visit_expr(target); } - /// In regular tuple assignments like `a, b = 1, 2` {or even `a, (b, c) = 1, (2, 3)`}, map each - /// expression on the left individually to the corresponding element type on the right, rather - /// than trying to walk the tuple type of the entire RHS. + /// In assignments from tuple or list literals, map each target to the corresponding element + /// types on the right, including the elements collected by a starred target. This preserves + /// element positions in list literals, whose inferred type combines all element types. /// /// We avoid infinitely growing types in cycle resolution by preserving only the /// topmost/outermost part of types that have `Divergent` components. For example, if the @@ -159,154 +177,196 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { /// This function avoids that problem by walking the AST on the RHS and looking directly at the /// individual element types. That gives us one more level of structure for those types, which /// is enough to resolve a lot of common cycles. - fn unpack_assignment_sequence_from_inference( - &mut self, - target: &ast::Expr, - value_expr: &ast::Expr, - value_inference: &ExpressionInference<'db>, - ) -> bool { - match target { - ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.targets - .insert(target.into(), value_inference.expression_type(value_expr)); - true - } - ast::Expr::List(ast::ExprList { elts, .. }) - | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { - let Some(values) = sequence_elts(value_expr) else { - return false; - }; - self.unpack_fixed_sequence_from_inference(elts, values, value_inference) - } - _ => false, - } - } - - fn unpack_fixed_sequence_from_inference( - &mut self, - targets: &[ast::Expr], - values: &[ast::Expr], - value_inference: &ExpressionInference<'db>, - ) -> bool { - if targets.len() != values.len() - || targets.iter().any(ast::Expr::is_starred_expr) - || values.iter().any(ast::Expr::is_starred_expr) - { - return false; - } - - // Even `a, b = 1, 2` recurses through this helper. `.all()` short-circuits, - // so in nested cases an earlier element may update `self.targets` before a - // later element falls back to the general unpacking path. That's harmless - // because the fallback recomputes the full unpacking and overwrites any - // partial entries. - targets.iter().zip(values).all(|(target, value_expr)| { - self.unpack_assignment_sequence_from_inference(target, value_expr, value_inference) - }) - } - - /// Records `Unknown` for a malformed unpack target and all of its descendant expressions. - fn record_unknown_target_subtree(&mut self, target: &ast::Expr) { - UnknownTargetCollector { - targets: &mut self.targets, - } - .visit_expr(target); - } - fn unpack_inner( &mut self, target: &ast::Expr, value_expr: AnyNodeRef<'_>, - value_ty: Type<'db>, + value: UnpackElement<'db, 'ast>, + value_inference: &ExpressionInference<'db>, ) { let db = self.db(); - match target { + let env = self.context.program_environment(); + let targets = match target { ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.targets.insert(target.into(), value_ty); + self.targets.insert(target.into(), value.ty); + return; } - ast::Expr::Starred(ast::ExprStarred { value, .. }) => { - self.unpack_inner(value, value_expr, value_ty); + ast::Expr::Starred(starred) => { + self.unpack_inner(&starred.value, value_expr, value, value_inference); + return; } ast::Expr::List(ast::ExprList { elts, .. }) - | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { - let target_len = match elts.iter().position(ast::Expr::is_starred_expr) { - Some(starred_index) => { - TupleLength::Variable(starred_index, elts.len() - (starred_index + 1)) + | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => elts, + _ => { + // Recovered syntax can still create assignment definitions for descendants of + // malformed targets. Give the whole subtree an unknown type so later lookups + // don't panic. + self.record_unknown_target_subtree(target); + return; + } + }; + let target_len = target_length(targets); + let literal = value.expression.and_then(|expression| { + literal_sequence( + expression, + value.promote_literals, + &|expression, promote| { + let ty = value_inference.expression_type(expression); + UnpackElement { + ty: if promote { ty.promote(db, env) } else { ty }, + expression: Some(expression), + promote_literals: promote, } - None => TupleLength::Fixed(elts.len()), - }; - let env = self.context.program_environment(); - let mut unpacker = TupleUnpacker::new(db, env, target_len); - - // N.B. `Type::try_iterate` internally handles unions, but in a lossy way. - // For our purposes here, we get better error messages and more precise inference - // if we manually map over the union and call `try_iterate` on each union element. - // See - // for more discussion. - let unpack_types = match value_ty { - Type::Union(union_ty) => union_ty.elements(self.db()), - _ => std::slice::from_ref(&value_ty), - }; - - for ty in unpack_types.iter().copied() { - report_iteration_over_character(&self.context, ty, value_expr); - let iterated = ty.try_iterate(self.db(), env); - // a value we could not iterate has already been reported, and the - // homogeneous fallback below says nothing about its real length + }, + &|expression, promote, known_length| { + // The starred expression's inference has already reported iteration errors. + // For `a, *rest = [1, *items]`, retain the shape of `items`' iterator even + // though the enclosing list's type has erased positions and length. + let ty = value_inference.expression_type(expression); + let ty = if promote { ty.promote(db, env) } else { ty }; + let mut tuple = ty.iterate(db, env); + if let Some(length) = known_length + && let Ok(resized) = tuple.resize(db, env, TupleLength::Fixed(length)) + { + tuple = Cow::Owned(resized); + } + sequence_from_type(db, &tuple) + }, + ) + }); + + // basedpython: `report_refutable_unpacking` needs the value as the program wrote it — + // its type and the tuple its iterator yields. A literal sequence is written out at the + // assignment, so its length is not in doubt and it carries no source here. + let mut refutable_sources: Vec>, Type<'db>)>> = Vec::new(); + let sequences = if let Some(literal) = literal { + refutable_sources.push(None); + vec![literal] + } else { + // N.B. `Type::try_iterate` internally handles unions, but in a lossy way. + // For our purposes here, we get better error messages and more precise inference + // if we manually map over the union and call `try_iterate` on each union element. + // See + // for more discussion. + let unpack_types = match value.ty { + Type::Union(union_ty) => union_ty.elements(db), + _ => std::slice::from_ref(&value.ty), + }; + unpack_types + .iter() + .map(|ty| { + // basedpython: unpacking a `Character` iterates it, which is what the rule is + // about — its code points are not what a reader means by its parts + report_iteration_over_character(&self.context, *ty, value_expr); + // a value that is not iterable at all has already been reported as such, and + // the homogeneous fallback below says nothing about its real length + let iterated = ty.try_iterate(db, env); let value_is_iterable = iterated.is_ok(); let tuple = iterated.unwrap_or_else(|err| { - err.report_diagnostic(&self.context, ty, value_expr); + err.report_diagnostic(&self.context, *ty, value_expr); Cow::Owned(TupleSpec::homogeneous(err.fallback_element_type(db, env))) }); + let sequence = sequence_from_type(db, &tuple); + refutable_sources.push(value_is_iterable.then_some((tuple, *ty))); + sequence + }) + .collect() + }; - if let Err(err) = unpacker.unpack_tuple(tuple.as_ref()) { - unpacker - .unpack_tuple(&TupleSpec::homogeneous(Type::unknown())) - .expect("adding a homogeneous tuple should always succeed"); - if let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - match err { - ResizeTupleError::TooManyValues => { - let mut diag = - builder.into_diagnostic("Too many values to unpack"); - diag.set_primary_annotation_message(format_args!( - "Expected {}", - target_len.display_minimum(), - )); - diag.annotate(self.context.secondary(value_expr).message( - format_args!("Got {}", tuple.len().display_minimum()), - )); - } - ResizeTupleError::TooFewValues => { - let mut diag = - builder.into_diagnostic("Not enough values to unpack"); - diag.set_primary_annotation_message(format_args!( - "Expected {}", - target_len.display_minimum(), - )); - diag.annotate(self.context.secondary(value_expr).message( - format_args!("Got {}", tuple.len().display_maximum()), - )); - } + let mut inferred_targets: Vec<_> = targets + .iter() + .map(|_| { + ( + UnionBuilder::new(db, env).unpack_aliases(false), + None, + false, + ) + }) + .collect(); + for (sequence, refutable_source) in sequences.into_iter().zip(&refutable_sources) { + let matched = sequence.unpack(target_len, Clone::clone, |elements| { + UnpackElement::from_type(UnionType::from_elements_leave_aliases( + db, + env, + elements.iter().map(|element| element.ty), + )) + }); + match matched { + Ok(matched) => { + for ((inferred, expression, promote_literals), element) in inferred_targets + .iter_mut() + .zip(matched.into_all_elements_with_kind()) + { + let element = match element { + TupleElement::Fixed(value) + | TupleElement::Prefix(value) + | TupleElement::Suffix(value) => value, + TupleElement::Variable(values) => { + UnpackElement::from_type(collected_list_type( + db, + env, + values.into_iter().map(|value| (value.ty, value.expression)), + )) } - } - } else if value_is_iterable { - self.report_refutable_unpacking(target, target_len, tuple.as_ref(), ty); + }; + inferred.add_in_place(element.ty); + // Literal sources contribute exactly one sequence. Only the type-based + // path combines multiple union arms, and those have no source expressions. + *expression = element.expression; + *promote_literals = element.promote_literals; + } + if let Some((tuple, ty)) = refutable_source { + self.report_refutable_unpacking(target, target_len, tuple.as_ref(), *ty); } } - - // We constructed unpacker above using the length of elts, so the zip should - // consume the same number of elements from each. - for (target, value_ty) in elts.iter().zip(unpacker.into_types()) { - self.unpack_inner(target, value_expr, value_ty); + Err(err) => { + // A length mismatch has no valid correspondence, e.g. `a, *b, c = [1]`. + // Recover every target at this level, without discarding sibling literals + // handled by the enclosing recursive call. + for (target, (inferred, _, _)) in targets.iter().zip(&mut inferred_targets) { + inferred.add_in_place(if target.is_starred_expr() { + KnownClass::List.to_specialized_instance(db, env, &[Type::unknown()]) + } else { + Type::unknown() + }); + } + if let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) { + let (message, actual) = match err { + ResizeTupleError::TooManyValues => ( + "Too many values to unpack", + sequence.len().display_minimum(), + ), + ResizeTupleError::TooFewValues => ( + "Not enough values to unpack", + sequence.len().display_maximum(), + ), + }; + let mut diag = builder.into_diagnostic(message); + diag.set_primary_annotation_message(format_args!( + "Expected {}", + target_len.display_minimum() + )); + diag.annotate( + self.context + .secondary(value_expr) + .message(format_args!("Got {actual}")), + ); + } } } - _ => { - // Recovered syntax can still create assignment definitions for descendants of - // malformed targets. Give the whole subtree an unknown type so later lookups - // don't panic. - self.record_unknown_target_subtree(target); - } + } + + for (target, (ty, expression, promote_literals)) in targets.iter().zip(inferred_targets) { + self.unpack_inner( + target, + expression.map(AnyNodeRef::from).unwrap_or(value_expr), + UnpackElement { + ty: ty.build(), + expression, + promote_literals, + }, + value_inference, + ); } } @@ -425,6 +485,295 @@ impl<'db> UnpackResult<'db> { } } +/// Return a tuple or list's elements when they correspond exactly to a fixed-length sequence. +pub(super) fn fixed_sequence_elements( + expression: &ast::Expr, + expected_length: usize, +) -> Option<&[ast::Expr]> { + let elements = sequence_elts(expression)?; + + if elements.len() != expected_length { + return None; + } + + elements + .iter() + .all(|element| !element.is_starred_expr()) + .then_some(elements) +} + +/// Find the expression assigned to one target in a tuple or list unpacking. +/// +/// For `first, (second, third) = (0, (1, 2))`, this associates `second` with `1`. +/// Explicit values before or after a starred element remain unambiguous. Literal expansions +/// retain their source expressions too; values supplied by arbitrary iterables do not. +pub(super) fn unpacked_assignment_value<'ast>( + unpack_target: &ast::Expr, + value: &'ast ast::Expr, + requested_target: &ast::Expr, +) -> Option<&'ast ast::Expr> { + assignment_values_for_target(unpack_target, value, requested_target) + .and_then(UnpackedAssignmentValues::into_single) +} + +/// Return the explicit values collected by a starred assignment target. +/// +/// For `first, *middle, last = (0, 1, 2, 3)`, `middle` collects the expressions `1` and `2`. +/// Literal expansions are flattened; an unknown source in the collected portion makes the +/// correspondence ambiguous. +pub(super) fn starred_assignment_values<'ast>( + unpack_target: &ast::Expr, + value: &'ast ast::Expr, + requested_target: &ast::Expr, +) -> Option> { + assignment_values_for_target(unpack_target, value, requested_target) + .and_then(UnpackedAssignmentValues::into_collected) +} + +#[derive(Debug, Clone)] +enum UnpackedAssignmentValues<'ast> { + Single(&'ast ast::Expr), + Collected(Vec<&'ast ast::Expr>), +} + +impl<'ast> UnpackedAssignmentValues<'ast> { + fn into_single(self) -> Option<&'ast ast::Expr> { + match self { + Self::Single(value) => Some(value), + Self::Collected(_) => None, + } + } + + fn into_collected(self) -> Option> { + match self { + Self::Single(_) => None, + Self::Collected(values) => Some(values), + } + } +} + +fn assignment_values_for_target<'ast>( + unpack_target: &ast::Expr, + value: &'ast ast::Expr, + requested_target: &ast::Expr, +) -> Option> { + if ExpressionNodeKey::from(unpack_target) == ExpressionNodeKey::from(requested_target) { + return Some(UnpackedAssignmentValues::Single(value)); + } + + let targets = sequence_elts(unpack_target)?; + let values = literal_sequence( + value, + false, + &|expression, _| Some(expression), + &|_, _, known_length| { + if let Some(length) = known_length { + Tuple::heterogeneous(std::iter::repeat_n(None, length)) + } else { + VariableLengthTuple::mixed([], vec![None], []) + } + }, + )?; + let matched = values + .unpack(target_length(targets), Clone::clone, |_| None) + .ok()?; + let (target, source) = targets + .iter() + .zip(matched.into_all_elements_with_kind()) + .find(|(target, _)| target.range().contains_range(requested_target.range()))?; + match source { + TupleElement::Variable(values) => { + let ast::Expr::Starred(starred) = target else { + return None; + }; + if ExpressionNodeKey::from(starred.value.as_ref()) + != ExpressionNodeKey::from(requested_target) + { + return None; + } + Some(UnpackedAssignmentValues::Collected( + values.into_iter().collect::>>()?, + )) + } + TupleElement::Fixed(value) | TupleElement::Prefix(value) | TupleElement::Suffix(value) => { + assignment_values_for_target(target, value?, requested_target) + } + } +} + +fn target_length(targets: &[ast::Expr]) -> TupleLength { + match targets.iter().position(ast::Expr::is_starred_expr) { + Some(index) => TupleLength::Variable(index, targets.len() - index - 1), + None => TupleLength::Fixed(targets.len()), + } +} + +/// A source expression accompanies a type only when its position is unambiguous. We keep this +/// transient information while unpacking, without giving mutable lists fixed-length types. +#[derive(Clone, Copy)] +struct UnpackElement<'db, 'ast> { + ty: Type<'db>, + expression: Option<&'ast ast::Expr>, + /// Widening a large tuple also widens nested tuple elements. Do not undo that widening + /// when following the source expression during nested unpacking. + promote_literals: bool, +} + +impl<'db> UnpackElement<'db, '_> { + fn from_type(ty: Type<'db>) -> Self { + Self { + ty, + expression: None, + promote_literals: false, + } + } +} + +fn sequence_from_type<'db, 'ast>( + db: &'db dyn Db, + tuple: &TupleSpec<'db>, +) -> Tuple, Vec>> { + match tuple { + Tuple::Fixed(values) => { + Tuple::heterogeneous(values.iter_all_elements().map(UnpackElement::from_type)) + } + Tuple::Variable(values) => VariableLengthTuple::mixed( + values.iter_prefix_elements().map(UnpackElement::from_type), + vec![UnpackElement::from_type(values.variable().element_type(db))], + values.iter_suffix_elements().map(UnpackElement::from_type), + ), + } +} + +/// Infers the fresh list made by a starred assignment target or sequence-pattern capture. +/// Both `first, *rest = values` and `case [first, *rest]:` create a new list whose inferred +/// literal elements can widen without changing the type of the original sequence. +pub(super) fn collected_list_type<'db, 'ast>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + values: impl ExactSizeIterator, Option<&'ast ast::Expr>)>, +) -> Type<'db> { + let is_empty = values.len() == 0; + let mut elements = UnionBuilder::new(db, env).unpack_aliases(false); + let mut allow_tuple_size_promotion = true; + for (ty, expression) in values { + let ty = ty.promote(db, env); + allow_tuple_size_promotion &= + TupleSizePromotionConstraints::allows_expression(db, env, expression, ty); + elements.add_in_place(ty); + } + // `first, *rest = (1,)` constructs an empty list, just as `rest = []` does. + let ty = if is_empty { + Type::unknown() + } else { + elements.build() + }; + let ty = ty.promote_collection_element_type(db, env, allow_tuple_size_promotion, true); + KnownClass::List.to_specialized_instance(db, env, &[ty]) +} + +/// Describes literal positions for both inference and diagnostics. A starred literal is expanded +/// recursively; other starred expressions contribute the shape supplied by the caller. For +/// `first, *rest, last = [1, *items, 2]`, the unknown width of `items` leaves both ends intact. +fn literal_sequence<'ast, T: Clone>( + expression: &'ast ast::Expr, + promote: bool, + element: &impl Fn(&'ast ast::Expr, bool) -> T, + spread: &impl Fn(&'ast ast::Expr, bool, Option) -> Tuple>, +) -> Option>> { + let (values, promote) = literal_sequence_elements(expression, promote)?; + Some(sequence_from_literal_elements( + values, + promote, + element, + spread, + &|builder, unpacked| { + builder.concat_with(unpacked, |suffix, left, right, prefix| { + // For `[*a, *b, *c, ...]`, retain the accumulated elements instead of copying + // them again for every expansion. Positions within this segment are unknown. + left.extend(suffix.iter().chain(prefix).chain(right).cloned()); + }) + }, + )) +} + +fn literal_sequence_elements( + expression: &ast::Expr, + promote: bool, +) -> Option<(&[ast::Expr], bool)> { + // `a, *rest = (items := [1, "two"])` has the same elements as the list itself. + let expression = expression.expression_value(); + let values = sequence_elts(expression)?; + let promote = promote || (expression.is_tuple_expr() && tuple_literal_needs_promotion(values)); + Some((values, promote)) +} + +/// Applies the tuple precision limit after expanding literal elements. For `(*[1, 2], 3)`, +/// all three positions count, even though the outer tuple has only two AST elements. +/// Other starred iterables count as one item because their elements are not recovered from +/// literal syntax. Stop counting as soon as the limit is exceeded. +pub(super) fn tuple_literal_needs_promotion(values: &[ast::Expr]) -> bool { + /// Limit literal precision in large tuple expressions to avoid pathological inference costs. + const MAX_TUPLE_LENGTH_FOR_UNANNOTATED_LITERAL_INFERENCE: usize = 64; + + fn remaining_budget(values: &[ast::Expr], remaining: usize) -> Option { + values.iter().try_fold(remaining, |remaining, value| { + if let ast::Expr::Starred(starred) = value + && let Some(values) = sequence_elts(starred.value.expression_value()) + { + remaining_budget(values, remaining) + } else { + remaining.checked_sub(1) + } + }) + } + + remaining_budget(values, MAX_TUPLE_LENGTH_FOR_UNANNOTATED_LITERAL_INFERENCE).is_none() +} + +/// Builds a literal's sequence shape from already-inferred elements and iterable shapes. +/// In `source = (*[1, "two"],)`, expanding the list syntax preserves both tuple positions. +/// The caller chooses the variable-segment representation and how to concatenate it: tuple +/// inference retains symbolic `TypeVarTuple` segments, while unpacking retains source expressions. +pub(super) fn sequence_from_literal_elements<'ast, T, V>( + values: &'ast [ast::Expr], + promote: bool, + element: &impl Fn(&'ast ast::Expr, bool) -> T, + spread: &impl Fn(&'ast ast::Expr, bool, Option) -> Tuple, + concat: &impl Fn(TupleBuilder, &Tuple) -> TupleBuilder, +) -> Tuple { + let mut builder = TupleBuilder::with_capacity(values.len()); + for value in values { + if let ast::Expr::Starred(starred) = value { + let unpacked = literal_sequence_elements(&starred.value, promote) + .map(|(values, promote)| { + sequence_from_literal_elements(values, promote, element, spread, concat) + }) + .unwrap_or_else(|| spread(value, promote, literal_iterable_length(&starred.value))); + builder = concat(builder, &unpacked); + } else { + builder.push(element(value, promote)); + } + } + builder.build() +} + +/// The literal element count used when inferring expansions such as `(*{"key": 1},)`. +/// An expansion within a set or dictionary, as in `{*items}` or `{**items}`, makes that count unknown. +fn literal_iterable_length(expression: &ast::Expr) -> Option { + match expression { + ast::Expr::Set(ast::ExprSet { elts, .. }) => elts + .iter() + .all(|element| !element.is_starred_expr()) + .then_some(elts.len()), + ast::Expr::Dict(ast::ExprDict { items, .. }) => items + .iter() + .all(|item| item.key.is_some()) + .then_some(items.len()), + _ => None, + } +} + /// Extract the element slice from a list or tuple expression. fn sequence_elts(expr: &ast::Expr) -> Option<&[ast::Expr]> { match expr { diff --git a/crates/ty_python_semantic/src/types/unsafe_union.rs b/crates/ty_python_semantic/src/types/unsafe_union.rs index 75e1d57003..727c690ad7 100644 --- a/crates/ty_python_semantic/src/types/unsafe_union.rs +++ b/crates/ty_python_semantic/src/types/unsafe_union.rs @@ -28,7 +28,7 @@ use crate::place::{ }; use crate::types::ProgramEnvironment; use crate::types::set_theoretic::UnionType; -use crate::types::variance::VarianceInferable; +use crate::types::variance::{VarianceInferable, VarianceTerm}; use crate::types::{ BoundTypeVarIdentity, InstanceProjection, Type, TypeQualifiers, TypeVarVariance, visitor, }; @@ -335,12 +335,14 @@ impl<'db> VarianceInferable<'db> for UnsafeUnionType<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { - self.elements(db) - .iter() - .map(|element| { - TypeVarVariance::Invariant.compose(element.variance_of(db, env, typevar)) - }) - .collect() + ) -> VarianceTerm<'db> { + VarianceTerm::join( + db, + self.elements(db).iter().map(|element| { + element + .with_polarity(TypeVarVariance::Invariant) + .variance_of(db, env, typevar) + }), + ) } } diff --git a/crates/ty_python_semantic/src/types/variance.rs b/crates/ty_python_semantic/src/types/variance.rs index 4e5ca4432b..3fd7bb6bf2 100644 --- a/crates/ty_python_semantic/src/types/variance.rs +++ b/crates/ty_python_semantic/src/types/variance.rs @@ -1,6 +1,10 @@ use crate::Db; use crate::{ProgramEnvironment, types::BoundTypeVarIdentity}; +mod equations; + +pub(super) use equations::{VarianceOrigin, VarianceTerm, infer_protocol_variance}; + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize)] pub enum TypeVarVariance { Invariant, @@ -126,27 +130,24 @@ impl std::iter::FromIterator for TypeVarVariance { } pub(crate) trait VarianceInferable<'db>: Sized { - /// The variance of `typevar` in `self` - /// - /// Generally, one will implement this by traversing any types within `self` - /// in which `typevar` could occur, and calling `variance_of` recursively on - /// them. + /// Builds a variance expression without choosing how protocol declarations are evaluated. /// - /// Sometimes the recursive calls will be in positions where you need to - /// specify a non-covariant polarity. See `with_polarity` for more details. + /// Recursive definitions contribute named variables instead of expanding their bodies. + /// Evaluation and dependency discovery operate on the resulting expression, so both use + /// the same member-selection and variance-composition rules. fn variance_of( self, db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance; + ) -> VarianceTerm<'db>; /// Creates a `VarianceInferable` that applies `polarity` (see /// `TypeVarVariance::compose`) to the result of variance inference on the /// underlying value. /// /// In some cases, we need to apply a polarity to the recursive call. - /// You can do this with `ty.with_polarity(polarity).variance_of(typevar)`. + /// You can do this with `ty.with_polarity(polarity).variance_of(db, env, typevar)`. /// Generally, this will be whenever the type occurs in argument-position, /// in which case you will want `TypeVarVariance::Contravariant`, or /// `TypeVarVariance::Invariant` if the value(s) being annotated is known to @@ -176,12 +177,13 @@ where db: &'db dyn Db, env: &ProgramEnvironment<'db>, typevar: BoundTypeVarIdentity<'db>, - ) -> TypeVarVariance { + ) -> VarianceTerm<'db> { let WithPolarity { variance_inferable, polarity, } = self; - polarity.compose_thunk(|| variance_inferable.variance_of(db, env, typevar)) + VarianceTerm::from(polarity) + .compose_thunk(db, || variance_inferable.variance_of(db, env, typevar)) } } diff --git a/crates/ty_python_semantic/src/types/variance/equations.rs b/crates/ty_python_semantic/src/types/variance/equations.rs new file mode 100644 index 0000000000..ba186e58b9 --- /dev/null +++ b/crates/ty_python_semantic/src/types/variance/equations.rs @@ -0,0 +1,354 @@ +//! Variance inference separates constructing equations from evaluating them. Recursive types +//! contribute named variables, so references such as `P[list[T]]` do not expand indefinitely. +//! +//! Ordinary evaluation honors explicit protocol declarations. To validate a declaration, we +//! instead solve the equations in that parameter's strongly connected component, starting at +//! bivariance. Declarations outside the component still apply: referencing an independent +//! protocol does not make its declared variance part of the validation problem. +//! +//! Dependencies follow variance composition, not just syntax. An argument erased by a bivariant +//! parameter cannot connect two components. Conversely, reaching invariance does not erase +//! later dependencies, even though ordinary evaluation can stop there. + +use std::collections::VecDeque; + +use rustc_hash::{FxHashMap, FxHashSet}; +use salsa::plumbing::AsId; + +use crate::types::{ + BoundTypeVarIdentity, ClassType, FunctionType, GenericAlias, StaticClassLiteral, TypeAliasType, + TypeVarVariance, TypedDictType, +}; +use crate::{Db, ProgramEnvironment}; + +/// A variance expression whose recursive references name equations rather than expand types. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum VarianceTerm<'db> { + Constant(TypeVarVariance), + Variable(VarianceVariable<'db>), + Join(VarianceSum<'db>), + Compose(VarianceProduct<'db>), +} + +impl<'db> VarianceTerm<'db> { + pub(crate) const BIVARIANT: Self = Self::Constant(TypeVarVariance::Bivariant); + + pub(crate) fn variable( + db: &'db dyn Db, + origin: VarianceOrigin<'db>, + typevar: BoundTypeVarIdentity<'db>, + ) -> Self { + Self::Variable(VarianceVariable::new(db, origin, typevar)) + } + + /// Combine occurrences without discarding symbolic dependencies at `Invariant`. + /// Only evaluation can short-circuit there: later terms can still connect a recursive group. + pub(crate) fn join(db: &'db dyn Db, terms: impl IntoIterator) -> Self { + let mut constant = TypeVarVariance::Bivariant; + let mut symbolic = Vec::new(); + for term in terms { + match term { + Self::Constant(variance) => constant = constant.join(variance), + _ => symbolic.push(term), + } + } + if constant != TypeVarVariance::Bivariant { + symbolic.push(Self::Constant(constant)); + } + match symbolic.as_slice() { + [] => Self::BIVARIANT, + [term] => *term, + _ => Self::Join(VarianceSum::new(db, symbolic.into_boxed_slice())), + } + } + + /// Compose definition-site and use-site variance, preserving erasure in either position. + pub(crate) fn compose_thunk(self, db: &'db dyn Db, other: impl FnOnce() -> Self) -> Self { + if self == Self::BIVARIANT { + return self; + } + let other = other(); + match (self, other) { + (Self::Constant(left), Self::Constant(right)) => left.compose(right).into(), + (_, Self::Constant(TypeVarVariance::Bivariant)) => Self::BIVARIANT, + (Self::Constant(TypeVarVariance::Covariant), _) => other, + (_, Self::Constant(TypeVarVariance::Covariant)) => self, + _ => Self::Compose(VarianceProduct::new(db, self, other)), + } + } + + /// Evaluate an expression using declared variance at protocol-parameter references. + pub(crate) fn evaluate(self, db: &'db dyn Db) -> TypeVarVariance { + self.evaluate_with(db, &|variable| variable.effective_variance(db)) + } + + /// Substitute the supplied variable values; the component solver supplies its current + /// approximations for members and effective variance for references outside the component. + fn evaluate_with( + self, + db: &'db dyn Db, + lookup: &impl Fn(VarianceVariable<'db>) -> TypeVarVariance, + ) -> TypeVarVariance { + match self { + Self::Constant(variance) => variance, + Self::Variable(variable) => lookup(variable), + Self::Join(sum) => sum + .terms(db) + .iter() + .map(|term| term.evaluate_with(db, lookup)) + .collect(), + Self::Compose(product) => product + .left(db) + .evaluate_with(db, lookup) + .compose_thunk(|| product.right(db).evaluate_with(db, lookup)), + } + } + + /// Visit only references that survive composition. For example, the equation for the + /// parameter in `type Ignore[T] = int` is bivariant, so `Ignore[P[T]]` adds no edge to `P`. + fn visit_live_variables(self, db: &'db dyn Db, mut visit: impl FnMut(VarianceVariable<'db>)) { + let mut pending = vec![self]; + let mut visited = FxHashSet::default(); + while let Some(term) = pending.pop() { + if !visited.insert(term) || term.evaluate(db) == TypeVarVariance::Bivariant { + continue; + } + match term { + Self::Constant(_) => {} + Self::Variable(variable) => visit(variable), + Self::Join(sum) => pending.extend(sum.terms(db).iter().copied()), + Self::Compose(product) => pending.extend([product.left(db), product.right(db)]), + } + } + } +} + +impl From for VarianceTerm<'_> { + fn from(variance: TypeVarVariance) -> Self { + Self::Constant(variance) + } +} + +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub(crate) struct VarianceSum<'db> { + #[returns(ref)] + terms: Box<[VarianceTerm<'db>]>, +} + +impl get_size2::GetSize for VarianceSum<'_> {} + +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub(crate) struct VarianceProduct<'db> { + #[returns(copy)] + left: VarianceTerm<'db>, + #[returns(copy)] + right: VarianceTerm<'db>, +} + +impl get_size2::GetSize for VarianceProduct<'_> {} + +/// Definition bodies that can occur recursively in variance expressions. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum VarianceOrigin<'db> { + Class(StaticClassLiteral<'db>), + /// A use of an explicitly declared protocol parameter, distinct from inferring its body. + ProtocolParameter(StaticClassLiteral<'db>, TypeVarVariance), + GenericAlias(GenericAlias<'db>), + TypeAlias(TypeAliasType<'db>), + Function(FunctionType<'db>), + TypedDict(ClassType<'db>), +} + +/// One unknown in the equation graph. Generic references use the origin's formal parameters; +/// specialization arguments contribute separate terms instead of expanding definition bodies. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub(crate) struct VarianceVariable<'db> { + #[returns(copy)] + origin: VarianceOrigin<'db>, + #[returns(copy)] + typevar: BoundTypeVarIdentity<'db>, +} + +impl get_size2::GetSize for VarianceVariable<'_> {} + +#[salsa::tracked] +impl<'db> VarianceVariable<'db> { + /// Honor the protocol declaration attached to this reference even when its equation infers + /// a different variance. References without a declaration are evaluated from their equations. + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] + fn effective_variance(self, db: &'db dyn Db) -> TypeVarVariance { + if let VarianceOrigin::ProtocolParameter(_, declared) = self.origin(db) { + declared + } else { + self.equation(db).evaluate(db) + } + } + + /// Return the defining expression, or declared variance for an unsupported protocol. + /// Recursive references remain symbolic, allowing the same equation to serve + /// ordinary evaluation and declaration validation. + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| VarianceTerm::BIVARIANT, heap_size=ruff_memory_usage::heap_size)] + fn equation(self, db: &'db dyn Db) -> VarianceTerm<'db> { + let typevar = self.typevar(db); + match self.origin(db) { + // Checking structural support opens the protocol's interface. Defer that work until + // validation needs the equation; ordinary evaluation only needs the declaration. + VarianceOrigin::ProtocolParameter(class, declared) + if class + .into_protocol_class(db) + .is_none_or(|protocol| !protocol.supports_variance_inference(db)) => + { + declared.into() + } + VarianceOrigin::Class(class) | VarianceOrigin::ProtocolParameter(class, _) => { + class.variance_equation(db, typevar) + } + VarianceOrigin::GenericAlias(alias) => alias.variance_equation(db, typevar), + VarianceOrigin::TypeAlias(alias) => alias.variance_equation(db, typevar), + VarianceOrigin::Function(function) => function.variance_equation(db, typevar), + VarianceOrigin::TypedDict(class) => { + let env = ProgramEnvironment::from_file(class.class_literal(db).program_file(db)); + TypedDictType::new(class).variance_of_items(db, &env, typevar) + } + } + } + + /// Return unique references that survive composition under ordinary, declaration-honoring + /// evaluation. Component discovery and the solver's work queue share these cached edges. + #[salsa::tracked(returns(ref), cycle_initial=|_, _, _| Box::default(), heap_size=ruff_memory_usage::heap_size)] + fn dependencies(self, db: &'db dyn Db) -> Box<[Self]> { + let mut dependencies = Vec::new(); + self.equation(db) + .visit_live_variables(db, |dependency| dependencies.push(dependency)); + dependencies.into_boxed_slice() + } +} + +/// A recursive component, ordered by variable ID so every member uses the same cached solution. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct VarianceComponent<'db> { + #[returns(ref)] + variables: Box<[VarianceVariable<'db>]>, +} + +impl get_size2::GetSize for VarianceComponent<'_> {} + +#[salsa::tracked] +impl<'db> VarianceComponent<'db> { + /// Solve all equations together, revisiting only the dependents of a changed value. + /// + /// Results follow the component's canonical variable order. The empty Salsa cycle seed + /// represents bivariance for every member until the solution is available. + #[salsa::tracked(returns(ref), cycle_initial=|_, _, _| Box::default(), heap_size=ruff_memory_usage::heap_size)] + fn solution(self, db: &'db dyn Db) -> Box<[TypeVarVariance]> { + let variables = self.variables(db); + let indices: FxHashMap<_, _> = variables + .iter() + .enumerate() + .map(|(index, variable)| (*variable, index)) + .collect(); + let equations: Vec<_> = variables + .iter() + .map(|variable| variable.equation(db)) + .collect(); + let mut dependents = vec![Vec::new(); variables.len()]; + for (index, variable) in variables.iter().enumerate() { + for dependency in variable.dependencies(db) { + if let Some(&dependency_index) = indices.get(dependency) { + dependents[dependency_index].push(index); + } + } + } + + let mut values = vec![TypeVarVariance::Bivariant; variables.len()]; + let mut pending: VecDeque<_> = (0..variables.len()).collect(); + let mut queued = vec![true; variables.len()]; + // Join and composition are monotone. Each value can move from bivariance to a polarity + // and then to invariance, so even negative cycles need only finitely many updates. + while let Some(index) = pending.pop_front() { + queued[index] = false; + let variance = equations[index].evaluate_with(db, &|dependency| { + indices.get(&dependency).map_or_else( + || dependency.effective_variance(db), + |&dependency_index| values[dependency_index], + ) + }); + if values[index] != variance { + values[index] = variance; + for &dependent in &dependents[index] { + if !queued[dependent] { + queued[dependent] = true; + pending.push_back(dependent); + } + } + } + } + values.into_boxed_slice() + } +} + +/// Infer the root protocol parameter together with its mutually dependent parameters. +/// Declarations outside that component remain authoritative. Equations and effective values are +/// cached separately, so dependency discovery does not repeat the semantic type traversal. +/// +/// For example, this infers contravariance for `T_co` despite its declaration: +/// +/// ```python +/// from typing import Protocol, TypeVar +/// +/// T_co = TypeVar("T_co", covariant=True) +/// class Sink(Protocol[T_co]): +/// def write(self, value: T_co) -> None: ... +/// def next(self) -> "Sink[T_co]": ... +/// ``` +/// +/// Callers select supported protocol parameters and normalize bivariance to covariance only +/// after inference, so unused parameters do not introduce constraints into a recursive component. +#[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn infer_protocol_variance<'db>( + db: &'db dyn Db, + class: StaticClassLiteral<'db>, + typevar: BoundTypeVarIdentity<'db>, + declared: TypeVarVariance, +) -> TypeVarVariance { + let root = VarianceVariable::new( + db, + VarianceOrigin::ProtocolParameter(class, declared), + typevar, + ); + let mut pending = vec![root]; + let mut visited = FxHashSet::default(); + let mut incoming: FxHashMap<_, Vec<_>> = FxHashMap::default(); + let mut variables = Vec::new(); + + while let Some(variable) = pending.pop() { + if !visited.insert(variable) { + continue; + } + for &dependency in variable.dependencies(db) { + incoming.entry(dependency).or_default().push(variable); + pending.push(dependency); + } + variables.push(variable); + } + + // Every visited variable is reachable from the root. Those with a path back to the root + // therefore form exactly its strongly connected component. + let mut component = FxHashSet::default(); + pending.push(root); + while let Some(variable) = pending.pop() { + if component.insert(variable) + && let Some(predecessors) = incoming.get(&variable) + { + pending.extend(predecessors.iter().copied()); + } + } + variables.retain(|variable| component.contains(variable)); + variables.sort_unstable_by_key(AsId::as_id); + let root_index = variables.binary_search_by_key(&root.as_id(), AsId::as_id); + let component = VarianceComponent::new(db, variables.into_boxed_slice()); + root_index + .ok() + .and_then(|index| component.solution(db).get(index).copied()) + .unwrap_or(TypeVarVariance::Bivariant) +} diff --git a/crates/ty_python_semantic/src/types/visibility.rs b/crates/ty_python_semantic/src/types/visibility.rs index 38665ef3e6..ba0a4182e1 100644 --- a/crates/ty_python_semantic/src/types/visibility.rs +++ b/crates/ty_python_semantic/src/types/visibility.rs @@ -125,7 +125,7 @@ fn class_member<'db>( /// /// The rule is python's: leading underscores are stripped from the class name, /// and a class named only with underscores mangles nothing. -pub(crate) fn mangled_private_name(class: &str, member: &str) -> String { +fn mangled_private_name(class: &str, member: &str) -> String { let class = class.trim_start_matches('_'); if class.is_empty() { return format!("__{member}"); diff --git a/crates/ty_python_semantic/src/types/visitor.rs b/crates/ty_python_semantic/src/types/visitor.rs index 29c42f6c47..3bdc2dcec4 100644 --- a/crates/ty_python_semantic/src/types/visitor.rs +++ b/crates/ty_python_semantic/src/types/visitor.rs @@ -11,14 +11,15 @@ use crate::types::{ BoundMethodType, BoundSuperType, BoundTypeVarInstance, CallableType, DeferredType, EnumComplementType, GenericAlias, IntersectionType, KnownBoundMethodType, KnownInstanceType, NominalInstanceType, OverlappingType, PropertyInstanceType, ProtocolInstanceType, - RestrictedType, StaticClassLiteral, SubclassOfType, Type, TypeAliasType, TypeFormType, - TypeGuardType, TypeIsType, TypedDictType, UnionType, UnsafeUnionType, + RestrictedType, SlotDescriptorType, StaticClassLiteral, SubclassOfType, Type, TypeAliasType, + TypeFormType, TypeGuardType, TypeIsType, TypedDictType, UnionType, UnsafeUnionType, bound_super::walk_bound_super_type, callable::walk_callable_type, class::walk_generic_alias, - cyclic::ActiveRecursionDetector, + cyclic::{ActiveRecursionDetector, TypeIdentity}, deferred::walk_deferred_type, function::{FunctionType, walk_function_type}, + generics::walk_specialization_types, instance::{walk_nominal_instance_type, walk_protocol_instance_type}, known_instance::walk_known_instance_type, method::{walk_bound_method_type, walk_method_wrapper_type}, @@ -48,6 +49,9 @@ pub(crate) trait TypeVisitor<'db> { /// Should the visitor trigger inference of and visit lazily-inferred type attributes? fn should_visit_lazy_type_attributes(&self) -> bool; + /// Notify the visitor that lazily-inferred type attributes were not visited. + fn notify_skipped_lazy_type_attributes(&self) {} + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>); fn visit_union_type(&self, db: &'db dyn Db, union: UnionType<'db>) { @@ -76,6 +80,10 @@ pub(crate) trait TypeVisitor<'db> { walk_property_instance_type(db, property, self); } + fn visit_slot_descriptor_type(&self, db: &'db dyn Db, descriptor: SlotDescriptorType<'db>) { + self.visit_type(db, descriptor.value_type(db)); + } + fn visit_typeis_type(&self, db: &'db dyn Db, type_is: TypeIsType<'db>) { walk_typeis_type(db, type_is, self); } @@ -182,6 +190,7 @@ pub(super) enum NonAtomicType<'db> { SubclassOf(SubclassOfType<'db>), NominalInstance(NominalInstanceType<'db>), PropertyInstance(PropertyInstanceType<'db>), + SlotDescriptor(SlotDescriptorType<'db>), TypeIs(TypeIsType<'db>), TypeGuard(TypeGuardType<'db>), TypeForm(TypeFormType<'db>), @@ -261,6 +270,9 @@ impl<'db> From> for TypeKind<'db> { Type::PropertyInstance(property) => { TypeKind::NonAtomic(NonAtomicType::PropertyInstance(property)) } + Type::SlotDescriptor(descriptor) => { + TypeKind::NonAtomic(NonAtomicType::SlotDescriptor(descriptor)) + } Type::TypeVar(bound_typevar) => { TypeKind::NonAtomic(NonAtomicType::TypeVar(bound_typevar)) } @@ -329,6 +341,9 @@ pub(super) fn walk_non_atomic_type<'db, V: TypeVisitor<'db> + ?Sized>( NonAtomicType::PropertyInstance(property) => { visitor.visit_property_instance_type(db, property); } + NonAtomicType::SlotDescriptor(descriptor) => { + visitor.visit_slot_descriptor_type(db, descriptor); + } NonAtomicType::TypeIs(type_is) => visitor.visit_typeis_type(db, type_is), NonAtomicType::TypeGuard(type_guard) => visitor.visit_typeguard_type(db, type_guard), NonAtomicType::TypeForm(typeform) => visitor.visit_typeform_type(db, typeform), @@ -362,7 +377,7 @@ pub(super) fn walk_non_atomic_type<'db, V: TypeVisitor<'db> + ?Sized>( } } -pub(crate) fn walk_template_literal_type<'db, V: TypeVisitor<'db> + ?Sized>( +fn walk_template_literal_type<'db, V: TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, template: TemplateLiteralType<'db>, visitor: &V, @@ -464,7 +479,7 @@ pub(super) enum DynamicContent { Absent, /// The type contains a matching dynamic type. Present, - /// Recursive specialization prevented the type from being fully inspected. + /// Lazy type information or recursive specialization prevented a complete inspection. Indeterminate, } @@ -474,13 +489,34 @@ impl DynamicContent { } } +#[derive(Clone, Copy)] +enum DynamicContentMode { + All, + NonAny, + /// Require enough information to prove that materialization preserves type requirements. + Materialization, +} + /// Determine whether `ty` contains any dynamic type. pub(super) fn dynamic_content<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>, ) -> DynamicContent { - dynamic_content_impl(db, env, ty, true) + dynamic_content_impl(db, env, ty, DynamicContentMode::All) +} + +/// Whether both materializations preserve the requirements described by `ty`. +/// +/// Unlike ordinary static-content checks, this proof cannot ignore lazy function signatures or +/// the wrapped callable of a partial. It does not compare metadata such as parameter-default types, +/// which do not affect whether one callable satisfies another's requirements. +pub(super) fn materialization_is_noop<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + dynamic_content_impl(db, env, ty, DynamicContentMode::Materialization).is_absent() } /// Determine whether `ty` contains a dynamic type other than `Any`. @@ -504,14 +540,14 @@ pub(super) fn non_any_dynamic_content<'db>( env: &ProgramEnvironment<'db>, ty: Type<'db>, ) -> DynamicContent { - dynamic_content_impl(db, env, ty, false) + dynamic_content_impl(db, env, ty, DynamicContentMode::NonAny) } fn dynamic_content_impl<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>, - include_any: bool, + mode: DynamicContentMode, ) -> DynamicContent { struct DynamicContentVisitor<'a, 'db> { env: &'a ProgramEnvironment<'db>, @@ -520,7 +556,7 @@ fn dynamic_content_impl<'db>( active_class_typed_dicts: ActiveRecursionDetector>, active_type_aliases: ActiveRecursionDetector>, content: Cell, - include_any: bool, + mode: DynamicContentMode, } impl DynamicContentVisitor<'_, '_> { @@ -545,8 +581,13 @@ fn dynamic_content_impl<'db>( return; } + if matches!(self.mode, DynamicContentMode::Materialization) && ty.is_divergent() { + self.record(DynamicContent::Indeterminate); + return; + } + if ty.is_dynamic() - && (self.include_any + && (!matches!(self.mode, DynamicContentMode::NonAny) || !matches!(ty, Type::Dynamic(crate::types::DynamicType::Any))) { self.record(DynamicContent::Present); @@ -556,6 +597,42 @@ fn dynamic_content_impl<'db>( walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); } + fn visit_function_type(&self, db: &'db dyn Db, function: FunctionType<'db>) { + if !self.content.get().is_absent() { + return; + } + + if matches!(self.mode, DynamicContentMode::Materialization) { + // The ordinary walker only visits updated signatures. Inferring an original + // signature here could re-enter recursive `TypeOf` evaluation, so do not claim + // that materialization leaves this function's requirements unchanged. + self.record(DynamicContent::Indeterminate); + } else { + walk_function_type(db, function, self); + } + } + + fn visit_known_instance_type(&self, db: &'db dyn Db, known: KnownInstanceType<'db>) { + if matches!(self.mode, DynamicContentMode::Materialization) + && let KnownInstanceType::FunctoolsPartial(partial) + | KnownInstanceType::FunctoolsPartialCall(partial) = known + { + // Materialization maps both the reduced signature and the wrapped callable. + self.visit_type(db, partial.wrapped(db).inner(db)); + } + if self.content.get().is_absent() { + walk_known_instance_type(db, known, self); + } + } + + fn visit_generic_alias_type(&self, db: &'db dyn Db, alias: GenericAlias<'db>) { + // Use `walk_specialization_types` rather than `walk_specialization` to avoid walking + // the bounds/constraints/defaults of the generic context. + // Only the types the class was actually specialized with are relevant to whether + // the `GenericAlias` contains a dynamic type. + walk_specialization_types(db, alias.specialization(db), self); + } + fn visit_type_alias_type(&self, db: &'db dyn Db, alias: TypeAliasType<'db>) { self.active_type_aliases.visit( &alias.definition(db), @@ -624,18 +701,139 @@ fn dynamic_content_impl<'db>( active_class_typed_dicts: ActiveRecursionDetector::default(), active_type_aliases: ActiveRecursionDetector::default(), content: Cell::new(DynamicContent::Absent), - include_any, + mode, }; visitor.visit_type(db, ty); visitor.content.get() } -/// Implementation for `any_over_type` and `find_over_type`. +/// Whether inspecting `ty` can encounter recursive types with changing specializations. +/// +/// Exact recursive types are safe to inspect once. For protocol methods, conservatively treat +/// a new specialization of an active protocol definition as potentially growing: their signatures +/// are not included in the specialization-flow analysis used by [`TypeIdentity`]. +pub(super) fn contains_growing_type<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + struct GrowingTypeVisitor<'a, 'db> { + env: &'a ProgramEnvironment<'db>, + recursion_guard: TypeCollector<'db>, + active_class_protocols: ActiveRecursionDetector>, + found: Cell, + } + + impl<'db> TypeVisitor<'db> for GrowingTypeVisitor<'_, 'db> { + fn program_environment(&self) -> &ProgramEnvironment<'db> { + self.env + } + + fn should_visit_lazy_type_attributes(&self) -> bool { + true + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if self.found.get() { + return; + } + + let is_generic = match ty { + Type::TypeAlias(alias) => alias.generic_context(db).is_some(), + Type::ProtocolInstance(protocol) => protocol + .class_origin(db) + .and_then(|class| class.class_literal(db).generic_context(db)) + .is_some(), + Type::TypedDict(typed_dict) => typed_dict + .defining_class() + .and_then(|class| class.class_literal(db).generic_context(db)) + .is_some(), + _ => false, + }; + if is_generic + && matches!( + ty.to_type_identity(db), + TypeIdentity::GrowingTypeAlias(_) + | TypeIdentity::GrowingProtocol(_) + | TypeIdentity::GrowingTypedDict(_) + ) + { + self.found.set(true); + return; + } + + walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); + } + + fn visit_protocol_instance_type( + &self, + db: &'db dyn Db, + protocol: ProtocolInstanceType<'db>, + ) { + let protocol_ty = Type::ProtocolInstance(protocol); + let Some((origin, specialization)) = protocol + .class_origin(db) + .and_then(|class| class.static_class_literal(db)) + else { + walk_protocol_instance_interface(db, protocol.interface(db), protocol_ty, self); + return; + }; + + if let Some(specialization) = specialization { + // Inspect arguments before activating the definition so finite nesting such as + // `P[P[int]]` does not look like an expanding recursive declaration. + walk_specialization_types(db, specialization, self); + if self.found.get() { + return; + } + } + + self.active_class_protocols.visit( + &origin, + || self.found.set(true), + || { + // Bind implicit receivers so they do not introduce recursive edges of their + // own. Explicitly recursive method signatures still need to be inspected. + walk_protocol_instance_interface(db, protocol.interface(db), protocol_ty, self); + }, + ); + } + } + + let visitor = GrowingTypeVisitor { + env, + recursion_guard: TypeCollector::default(), + active_class_protocols: ActiveRecursionDetector::default(), + found: Cell::new(false), + }; + visitor.visit_type(db, ty); + visitor.found.get() +} + +#[derive(Clone, Copy)] +enum TypeSearchMode { + SkipLazyAttributes, + IncludeLazyAttributes, + /// Visit alias arguments without evaluating alias bodies or other lazy attributes. + IncludeAliasArguments, +} + +impl TypeSearchMode { + const fn should_visit_lazy_type_attributes(self) -> bool { + matches!(self, Self::IncludeLazyAttributes) + } + + const fn should_visit_alias_arguments(self) -> bool { + matches!(self, Self::IncludeAliasArguments) + } +} + +/// Shared implementation for type searches. fn any_over_type_impl<'db, F, T>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>, - should_visit_lazy_type_attributes: bool, + mode: TypeSearchMode, query: F, ) -> T where @@ -647,7 +845,7 @@ where query: &'a dyn Fn(Type<'db>) -> U, recursion_guard: TypeCollector<'db>, found_matching_type: Cell, - should_visit_lazy_type_attributes: bool, + mode: TypeSearchMode, } impl<'db, U> TypeVisitor<'db> for AnyOverTypeVisitor<'db, '_, U> @@ -659,7 +857,7 @@ where } fn should_visit_lazy_type_attributes(&self) -> bool { - self.should_visit_lazy_type_attributes + self.mode.should_visit_lazy_type_attributes() } fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { @@ -673,7 +871,17 @@ where if new_value != default_value { return; } - walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); + if self.mode.should_visit_alias_arguments() + && let Type::TypeAlias(alias) = ty + { + if !self.recursion_guard.type_was_already_seen(ty) + && let Some(specialization) = alias.specialization(db) + { + walk_specialization_types(db, specialization, self); + } + } else { + walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); + } } } @@ -682,7 +890,7 @@ where query: &query, recursion_guard: TypeCollector::default(), found_matching_type: Cell::default(), - should_visit_lazy_type_attributes, + mode, }; visitor.visit_type(db, ty); visitor.found_matching_type.get() @@ -703,7 +911,56 @@ pub(super) fn any_over_type<'db>( should_visit_lazy_type_attributes: bool, query: impl Fn(Type<'db>) -> bool, ) -> bool { - any_over_type_impl(db, env, ty, should_visit_lazy_type_attributes, query) + let mode = if should_visit_lazy_type_attributes { + TypeSearchMode::IncludeLazyAttributes + } else { + TypeSearchMode::SkipLazyAttributes + }; + any_over_type_impl(db, env, ty, mode, query) +} + +/// Searches through the arguments of [`Type::TypeAlias`] without evaluating alias bodies or other +/// lazy attributes. +/// This also visits arguments that the alias's value does not use. +/// Shared arguments use the same recursion guard, so their descendants are not visited repeatedly. +pub(super) fn any_over_type_including_alias_arguments<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + query: impl Fn(Type<'db>) -> bool, +) -> bool { + any_over_type_impl(db, env, ty, TypeSearchMode::IncludeAliasArguments, query) +} + +/// Searches through type aliases without forcing other lazily inferred type attributes. +/// +/// Revisiting a recursive alias counts as a match because its specialization can grow on each +/// visit. Distinct specializations of a nonrecursive alias remain separate, so nested uses such as +/// `Identity[Identity[int]]` are still considered finite. +pub(super) fn any_over_type_expanding_aliases<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + query: impl Fn(Type<'db>) -> bool, +) -> bool { + fn search<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + query: &impl Fn(Type<'db>) -> bool, + active_aliases: &ActiveRecursionDetector>, + ) -> bool { + any_over_type(db, env, ty, false, |nested| { + query(nested) + || matches!(nested, Type::TypeAlias(alias) if active_aliases.visit( + &Type::TypeAlias(alias).to_type_identity(db), + || true, + || search(db, env, alias.value_type(db), query, active_aliases), + )) + }) + } + + search(db, env, ty, &query, &ActiveRecursionDetector::default()) } /// Like [`any_over_type`], but treats `Self` as an atom: `query` still sees it, but its upper @@ -783,14 +1040,97 @@ pub(super) fn find_over_type<'db, T>( where T: Copy + PartialEq, { - any_over_type_impl(db, env, ty, should_visit_lazy_type_attributes, query) + let mode = if should_visit_lazy_type_attributes { + TypeSearchMode::IncludeLazyAttributes + } else { + TypeSearchMode::SkipLazyAttributes + }; + any_over_type_impl(db, env, ty, mode, query) } #[cfg(test)] mod tests { - use crate::types::{DynamicType, SpecialFormType, Type}; + use ruff_db::files::system_path_to_file; + use ruff_db::system::DbWithWritableSystem as _; + use ty_python_core::ProgramFile; - use super::CollectedTypes; + use crate::db::tests::setup_db; + use crate::place::global_symbol; + use crate::types::{DynamicType, Parameter, Parameters, SpecialFormType, Type}; + + use super::{CollectedTypes, dynamic_content, materialization_is_noop}; + + #[test] + fn fully_static_paramspec_value_has_no_dynamic_content() { + let db = setup_db(); + let env = db.program_environment(); + let paramspec_value = Type::paramspec_value_callable( + &db, + Parameters::standard([ + Parameter::positional_only(None).with_annotated_type(Type::object()) + ]), + ); + assert!(dynamic_content(&db, &env, paramspec_value).is_absent()); + } + + #[test] + fn materialization_noop_checks_hidden_function_types() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" + from __future__ import annotations + from functools import partial + from typing import Any, Protocol + from ty_extensions._internal import TypeOf + + def gradual_callback(value: Any) -> None: ... + + class Callbacks(Protocol): + @property + def callback(self) -> TypeOf[gradual_callback]: ... + + class Recursive[T](Protocol): + @property + def value(self) -> T: ... + @property + def child(self) -> Recursive[T]: ... + + plain: Recursive[int] + callbacks: Recursive[Callbacks] + partial_callback = partial(gradual_callback, 0) + partial_call = partial_callback.__call__ + "#, + )?; + let env = db.program_environment(); + let file = system_path_to_file(&db, "/src/a.py")?; + let module = ProgramFile::new(&db, file, env.program(&db)); + for (name, expected) in [ + ("plain", true), + ("callbacks", false), + ("partial_callback", false), + ("partial_call", false), + ] { + let ty = global_symbol(&db, module, name).place.expect_type(); + assert_eq!(materialization_is_noop(&db, &env, ty), expected, "{name}"); + } + Ok(()) + } + + #[test] + fn materialization_noop_rejects_divergent_markers() { + let db = setup_db(); + let env = db.program_environment(); + let divergent = Type::divergent(salsa::plumbing::Id::from_bits(1)); + + for ty in [ + divergent, + divergent.top_materialization(&db, &env), + divergent.bottom_materialization(&db, &env), + ] { + assert!(!materialization_is_noop(&db, &env, ty)); + } + } #[test] fn collected_types_spills_without_losing_deduplication() { diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index de873f8b83..0389d30cb8 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -6,6 +6,7 @@ use ruff_db::system::{DbWithTestSystem, System, SystemPath, SystemPathBuf, TestS use ruff_db::vendored::VendoredFileSystem; use ty_python_core::program::ProgramSettings; +use ty_python_semantic::dependency::DependencyMetadata; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::pull_types::pull_types; use ty_python_semantic::{ @@ -260,6 +261,10 @@ impl ty_python_semantic::Db for CorpusDb { &self.experimental_settings } + fn dependency_metadata(&self, _file: File) -> Option<&DependencyMetadata> { + None + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } diff --git a/crates/ty_python_semantic/tests/mdtest.rs b/crates/ty_python_semantic/tests/mdtest.rs index 3bfeca82c6..a895f99321 100644 --- a/crates/ty_python_semantic/tests/mdtest.rs +++ b/crates/ty_python_semantic/tests/mdtest.rs @@ -5,8 +5,15 @@ thread_local! { // Restrict each fixture's Rayon work to one thread so concurrent tests do not compete for the // same resources. When fixtures share a process, the harness reuses worker threads, so cache // one pool per worker. + // + // the whole of `ty_test::run` happens on this pool's worker, so it is the thread type + // inference recurses on and it needs the stack ty gives its own workers. left at the + // platform default, checking a real installed sqlalchemy overflowed it in an + // unoptimised build, where frames are far larger than in the optimised one developers + // usually run static RAYON_POOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new() .num_threads(1) + .stack_size(ruff_db::STACK_SIZE) .build() .unwrap(); } diff --git a/crates/ty_server/Cargo.toml b/crates/ty_server/Cargo.toml index d17268c2d8..bbd5988940 100644 --- a/crates/ty_server/Cargo.toml +++ b/crates/ty_server/Cargo.toml @@ -53,11 +53,16 @@ tracing-subscriber = { workspace = true, features = ["chrono"] } libc = { workspace = true } [dev-dependencies] +ruff_python_trivia = { workspace = true } + dunce = { workspace = true } insta = { workspace = true, features = ["filters", "json"] } regex = { workspace = true } smallvec = { workspace = true } tempfile = { workspace = true } +[features] +test-uv = ["ty_project/test-uv"] + [lints] workspace = true diff --git a/crates/ty_server/src/capabilities.rs b/crates/ty_server/src/capabilities.rs index e8ddfab7f8..247eaf7105 100644 --- a/crates/ty_server/src/capabilities.rs +++ b/crates/ty_server/src/capabilities.rs @@ -39,6 +39,7 @@ bitflags::bitflags! { const IMPLEMENTATION_LINK_SUPPORT = 1 << 21; const TRIGGER_SIGNATURE_HELP_COMMAND = 1 << 22; const LANGUAGE_INJECTION = 1 << 23; + const SEMANTIC_TOKENS_REFRESH = 1 << 24; } } @@ -117,6 +118,11 @@ impl ResolvedClientCapabilities { self.contains(Self::INLAY_HINT_REFRESH) } + /// Returns `true` if the client supports refreshing semantic tokens. + pub(crate) const fn supports_semantic_tokens_refresh(self) -> bool { + self.contains(Self::SEMANTIC_TOKENS_REFRESH) + } + /// Returns `true` if the client supports pull diagnostics. pub(crate) const fn supports_pull_diagnostics(self) -> bool { self.contains(Self::PULL_DIAGNOSTICS) @@ -259,6 +265,13 @@ impl ResolvedClientCapabilities { flags |= Self::INLAY_HINT_REFRESH; } + if workspace + .and_then(|workspace| workspace.semantic_tokens.as_ref()?.refresh_support) + .unwrap_or_default() + { + flags |= Self::SEMANTIC_TOKENS_REFRESH; + } + if let Some(capabilities) = workspace.and_then(|workspace| workspace.did_change_watched_files.as_ref()) { diff --git a/crates/ty_server/src/document/text_document.rs b/crates/ty_server/src/document/text_document.rs index 1ff775a6c9..7bf65a229e 100644 --- a/crates/ty_server/src/document/text_document.rs +++ b/crates/ty_server/src/document/text_document.rs @@ -68,7 +68,7 @@ impl LanguageId { /// `.py` file with no diagnostics and every service reading the file as last /// saved rather than as the client has it. So a path python owns is python, /// exactly as a path django owns is a template. - pub(crate) fn new(language_id: &LanguageKind, path: &AnySystemPath) -> Self { + fn new(language_id: &LanguageKind, path: &AnySystemPath) -> Self { match language_id.as_str() { "python" | "by" | "basedpython" => Self::Python, "django-html" | "django-txt" | "htmldjango" | "django" => Self::DjangoTemplate, diff --git a/crates/ty_server/src/server.rs b/crates/ty_server/src/server.rs index fa31a5bc97..10930cebec 100644 --- a/crates/ty_server/src/server.rs +++ b/crates/ty_server/src/server.rs @@ -5,7 +5,7 @@ use crate::PositionEncoding; use crate::capabilities::{ResolvedClientCapabilities, server_capabilities}; use crate::session::{ClientName, InitializationOptions, Session, warn_about_unknown_options}; use anyhow::Context; -use lsp_server::Connection; +use lsp_server::{Connection, ErrorCode, Message, Response}; use lsp_types::{ ClientCapabilities, InitializeParams, MessageType, Uri, WorkspaceFolders, WorkspaceFoldersInitializeParams, @@ -19,13 +19,18 @@ mod api; mod lazy_work_done_progress; mod main_loop; mod schedule; +mod script_progress; use crate::session::client::Client; pub(crate) use api::Error; -pub(crate) use api::publish_settings_diagnostics; +pub(crate) use api::{ + publish_all_document_diagnostics, publish_diagnostics_if_needed, publish_settings_diagnostics, +}; +pub(crate) use lazy_work_done_progress::LazyWorkDoneProgress; pub(crate) use main_loop::{ Action, ConnectionSender, Event, MainLoopReceiver, MainLoopSender, SendRequest, }; +pub(crate) use script_progress::ScriptProgress; pub(crate) type Result = std::result::Result; pub struct Server { @@ -56,7 +61,17 @@ impl Server { .context("Failed to deserialize initialization parameters")?; let (initialization_options, deserialization_error) = - InitializationOptions::from_value(initialization_options); + match InitializationOptions::from_value(initialization_options) { + Ok(options) => options, + Err(error) => { + connection.sender.send(Message::Response(Response::new_err( + id, + ErrorCode::InvalidParams as i32, + format!("Invalid initialization options: {error:#}"), + )))?; + return Err(error).context("Failed to deserialize initialization options"); + } + }; if !in_test { crate::logging::init_logging( diff --git a/crates/ty_server/src/server/api.rs b/crates/ty_server/src/server/api.rs index 988f6d9126..85bc29b7e9 100644 --- a/crates/ty_server/src/server/api.rs +++ b/crates/ty_server/src/server/api.rs @@ -19,7 +19,9 @@ mod type_hierarchy; use self::traits::{NotificationHandler, RequestHandler}; use super::{Result, schedule::BackgroundSchedule}; use crate::session::client::Client; -pub(crate) use diagnostics::publish_settings_diagnostics; +pub(crate) use diagnostics::{ + publish_all_document_diagnostics, publish_diagnostics_if_needed, publish_settings_diagnostics, +}; use ruff_db::panic::PanicError; /// Processes a request from the client to the server. @@ -581,7 +583,7 @@ fn respond( /// shows that message where the user asked. A popup on top of it saying the server /// hit a problem would be a lie. This is the same line the request-routing path /// draws for the errors it handles. -pub(super) fn report_unexpected_failure(client: &Client, error: &Error, log_guidance: &str) { +fn report_unexpected_failure(client: &Client, error: &Error, log_guidance: &str) { if matches!(error.code, ErrorCode::InternalError) { client.show_error_message(format!("ty encountered a problem. {log_guidance}")); } diff --git a/crates/ty_server/src/server/api/changes.rs b/crates/ty_server/src/server/api/changes.rs index 10ccf1ac00..b2d2c510d9 100644 --- a/crates/ty_server/src/server/api/changes.rs +++ b/crates/ty_server/src/server/api/changes.rs @@ -6,11 +6,10 @@ //! and both leave the same work behind, which is what lives here. use lsp_types as types; -use ty_project::Db as _; use ty_project::watch::ChangeEvent; use crate::server::api::diagnostics::{ - publish_diagnostics_if_needed, publish_settings_diagnostics, + publish_all_document_diagnostics, publish_settings_diagnostics, }; use crate::session::Session; use crate::session::client::Client; @@ -22,26 +21,24 @@ pub(crate) fn apply(session: &mut Session, client: &Client, changes: &[ChangeEve return; } + let client_capabilities = session.client_capabilities(); let roots: Vec<_> = session - .project_dbs() - .map(|db| db.project().root(db).to_owned()) + .workspaces() + .into_iter() + .map(|(root, _)| root.clone()) .collect(); for root in roots { tracing::debug!("Applying changes to `{root}`"); - session.apply_changes(&AnySystemPath::System(root.clone()), changes); + session.apply_changes(client, &AnySystemPath::System(root.clone()), changes); publish_settings_diagnostics(session, client, root); } - let client_capabilities = session.client_capabilities(); - if client_capabilities.supports_workspace_diagnostic_refresh() { client.send_request::(session, (), |_, ()| {}); } else { - for document in session.file_document_handles() { - publish_diagnostics_if_needed(&document, session, client); - } + publish_all_document_diagnostics(session, client); } if client_capabilities.supports_inlay_hint_refresh() { diff --git a/crates/ty_server/src/server/api/diagnostics.rs b/crates/ty_server/src/server/api/diagnostics.rs index 4472592957..5288e09897 100644 --- a/crates/ty_server/src/server/api/diagnostics.rs +++ b/crates/ty_server/src/server/api/diagnostics.rs @@ -19,7 +19,7 @@ use ruff_db::files::{File, FileRange}; use ruff_db::source::source_text; use ruff_db::system::SystemPathBuf; use serde::{Deserialize, Serialize}; -use ty_project::{Db as _, ProjectDatabase, SemanticDb as _}; +use ty_project::{Db as _, ProjectDatabase}; use crate::capabilities::ResolvedClientCapabilities; use crate::document::{FileRangeExt, ToRangeExt}; @@ -197,21 +197,24 @@ pub(super) enum LspDiagnostics { } impl LspDiagnostics { - /// Returns the diagnostics for a text document. - /// - /// # Panics - /// - /// Panics if the diagnostics are for a notebook document. - pub(super) fn expect_text_document(self) -> Vec { + /// Returns the diagnostics for the text document or notebook cell at `uri`. + pub(super) fn into_document_diagnostics(self, uri: &Uri) -> Vec { match self { LspDiagnostics::TextDocument(diagnostics) => diagnostics, - LspDiagnostics::NotebookDocument(_) => { - panic!("Expected a text document diagnostics, but got notebook diagnostics") + LspDiagnostics::NotebookDocument(mut diagnostics) => { + diagnostics.remove(uri).unwrap_or_default() } } } } +/// Publishes diagnostics for all open files that need push diagnostics. +pub(crate) fn publish_all_document_diagnostics(session: &Session, client: &Client) { + for document in session.file_document_handles() { + publish_diagnostics_if_needed(&document, session, client); + } +} + /// Publishes the diagnostics for the given document snapshot using the [publish diagnostics /// notification] . /// @@ -220,7 +223,7 @@ impl LspDiagnostics { /// does not support pull diagnostics for notebooks or cells (as of 2025-11-12). /// /// [publish diagnostics notification]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics -pub(super) fn publish_diagnostics_if_needed( +pub(crate) fn publish_diagnostics_if_needed( document: &DocumentHandle, session: &Session, client: &Client, @@ -400,6 +403,13 @@ pub(super) fn compute_diagnostics( return None; }; + // The first uv result supplies the module paths needed for correct diagnostics. Do not analyze + // the script until that result is available. Waiting would not help: publishing the environment + // advances the database revision and cancels this snapshot, so the request must retry anyway. + if db.uv_environments().is_initialization_pending(db, file) { + return None; + } + // A django template is not python. It is never handed to the type checker or // to the hints — either would parse an html file as python — and what it does // report is the template language's own. @@ -418,7 +428,7 @@ pub(super) fn compute_diagnostics( // one `by check` registers too let diagnostics = db.check_file(file); - let unnecessary_hints = hints(db, db.program_file(file)); + let unnecessary_hints = hints(db, file); Some(Diagnostics { items: diagnostics, @@ -664,6 +674,7 @@ pub(crate) struct FullDiagnosticData { pub(crate) struct DiagnosticFixData { pub(crate) fix_title: String, pub(crate) edits: HashMap>, + pub(crate) preferred: bool, } #[derive(Serialize, Deserialize)] @@ -739,6 +750,7 @@ impl DiagnosticData { .map(ToString::to_string) .unwrap_or_else(|| format!("Fix {}", diagnostic.id())), edits: lsp_edits, + preferred: fix.applies(Applicability::Safe), }) } } diff --git a/crates/ty_server/src/server/api/notifications/did_change.rs b/crates/ty_server/src/server/api/notifications/did_change.rs index 84daa070cc..d942eb1045 100644 --- a/crates/ty_server/src/server/api/notifications/did_change.rs +++ b/crates/ty_server/src/server/api/notifications/did_change.rs @@ -37,7 +37,7 @@ impl SyncNotificationHandler for DidChangeTextDocumentHandler { .with_failure_code(ErrorCode::InternalError)?; document - .update_text_document(session, content_changes, version) + .update_text_document(session, client, content_changes, version) .with_failure_code(ErrorCode::InternalError)?; publish_diagnostics_if_needed(&document, session, client); diff --git a/crates/ty_server/src/server/api/notifications/did_change_notebook.rs b/crates/ty_server/src/server/api/notifications/did_change_notebook.rs index 9dba97ae08..61cd710119 100644 --- a/crates/ty_server/src/server/api/notifications/did_change_notebook.rs +++ b/crates/ty_server/src/server/api/notifications/did_change_notebook.rs @@ -28,7 +28,7 @@ impl SyncNotificationHandler for DidChangeNotebookHandler { .with_failure_code(ErrorCode::InternalError)?; document - .update_notebook_document(session, cells, metadata, version) + .update_notebook_document(session, client, cells, metadata, version) .with_failure_code(ErrorCode::InternalError)?; // Always publish diagnostics because notebooks only support publish diagnostics. diff --git a/crates/ty_server/src/server/api/notifications/did_close.rs b/crates/ty_server/src/server/api/notifications/did_close.rs index 0804ac9282..e67e309c72 100644 --- a/crates/ty_server/src/server/api/notifications/did_close.rs +++ b/crates/ty_server/src/server/api/notifications/did_close.rs @@ -29,7 +29,7 @@ impl SyncNotificationHandler for DidCloseTextDocumentHandler { .with_failure_code(ErrorCode::InternalError)?; let should_clear_diagnostics = document - .close(session) + .close(session, client) .with_failure_code(ErrorCode::InternalError)?; if should_clear_diagnostics { diff --git a/crates/ty_server/src/server/api/notifications/did_close_notebook.rs b/crates/ty_server/src/server/api/notifications/did_close_notebook.rs index 9d9838d691..07fc6d14ff 100644 --- a/crates/ty_server/src/server/api/notifications/did_close_notebook.rs +++ b/crates/ty_server/src/server/api/notifications/did_close_notebook.rs @@ -18,7 +18,7 @@ impl NotificationHandler for DidCloseNotebookHandler { impl SyncNotificationHandler for DidCloseNotebookHandler { fn run( session: &mut Session, - _client: &Client, + client: &Client, params: DidCloseNotebookDocumentParams, ) -> Result<()> { let DidCloseNotebookDocumentParams { @@ -33,7 +33,7 @@ impl SyncNotificationHandler for DidCloseNotebookHandler { // We don't need to call publish any diagnostics because we clear // the diagnostics when closing the corresponding cell documents. let _ = document - .close(session) + .close(session, client) .with_failure_code(lsp_server::ErrorCode::InternalError)?; Ok(()) diff --git a/crates/ty_server/src/server/api/notifications/did_open.rs b/crates/ty_server/src/server/api/notifications/did_open.rs index 55b2adf502..65f620d7c8 100644 --- a/crates/ty_server/src/server/api/notifications/did_open.rs +++ b/crates/ty_server/src/server/api/notifications/did_open.rs @@ -30,7 +30,7 @@ impl SyncNotificationHandler for DidOpenTextDocumentHandler { } = params; let text_doc = TextDocument::new(uri, text, version, &language_id); - let document = session.open_text_document(text_doc); + let document = session.open_text_document(client, text_doc); publish_diagnostics_if_needed(&document, session, client); Ok(()) diff --git a/crates/ty_server/src/server/api/notifications/did_open_notebook.rs b/crates/ty_server/src/server/api/notifications/did_open_notebook.rs index 8f2607479f..d93b4191d6 100644 --- a/crates/ty_server/src/server/api/notifications/did_open_notebook.rs +++ b/crates/ty_server/src/server/api/notifications/did_open_notebook.rs @@ -34,14 +34,14 @@ impl SyncNotificationHandler for DidOpenNotebookHandler { NotebookDocument::new(notebook_uri, version, cells, metadata.unwrap_or_default()) .with_failure_code(ErrorCode::InternalError)?; - let document = session.open_notebook_document(notebook); + let document = session.open_notebook_document(client, notebook); let notebook_path = document.notebook_or_file_path(); for cell in params.cell_text_documents { let cell_document = TextDocument::new(cell.uri, cell.text, cell.version, &cell.language_id) .with_notebook(notebook_path.clone()); - session.open_text_document(cell_document); + session.open_text_document(client, cell_document); } // Always publish diagnostics because notebooks only support publish diagnostics. diff --git a/crates/ty_server/src/server/api/notifications/did_save.rs b/crates/ty_server/src/server/api/notifications/did_save.rs index 2d8971f098..dd11a41a95 100644 --- a/crates/ty_server/src/server/api/notifications/did_save.rs +++ b/crates/ty_server/src/server/api/notifications/did_save.rs @@ -1,7 +1,8 @@ use lsp_types::{DidSaveTextDocumentNotification, DidSaveTextDocumentParams}; +use ty_project::ScriptEnvironmentAvailability; use crate::server::Result; -use crate::server::api::diagnostics::publish_diagnostics_if_needed; +use crate::server::api::diagnostics::publish_all_document_diagnostics; use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler}; use crate::session::Session; use crate::session::client::Client; @@ -16,12 +17,15 @@ impl SyncNotificationHandler for DidSaveTextDocumentHandler { fn run( session: &mut Session, client: &Client, - _params: DidSaveTextDocumentParams, + params: DidSaveTextDocumentParams, ) -> Result<()> { - for document in session.file_document_handles() { - publish_diagnostics_if_needed(&document, session, client); + if let Ok(document) = session.document_handle(¶ms.text_document.uri) { + // Keep diagnostics visible if unsaved edits first turned this file into a script. + document.synchronize_script(session, client, ScriptEnvironmentAvailability::Available); } + publish_all_document_diagnostics(session, client); + Ok(()) } } diff --git a/crates/ty_server/src/server/api/requests/alignment_groups.rs b/crates/ty_server/src/server/api/requests/alignment_groups.rs index 91bcdbb6ea..e57c97696e 100644 --- a/crates/ty_server/src/server/api/requests/alignment_groups.rs +++ b/crates/ty_server/src/server/api/requests/alignment_groups.rs @@ -52,15 +52,15 @@ impl Request for AlignmentGroupsRequest { #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct AlignmentGroupsParams { - pub(crate) text_document: TextDocumentIdentifier, - pub(crate) range: Range, + text_document: TextDocumentIdentifier, + range: Range, } /// assignments sharing one `=` column, which therefore have to be laid out together #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AlignmentGroup { - pub(crate) members: Vec, + members: Vec, } /// one assignment's contribution to the column @@ -72,10 +72,10 @@ pub(crate) struct AlignmentMember { /// this is not the one place a hint can land on this line. what displaces this line's column is /// every hint drawn on it at or before `gapEnd`, added together — `a, b = 1, 2` is hinted after /// `a` and again after `b`, and either alone understates how far the `=` moves - pub(crate) gap_start: Position, + gap_start: Position, /// the `=` - pub(crate) gap_end: Position, + gap_end: Position, } pub(crate) struct AlignmentGroupsRequestHandler; diff --git a/crates/ty_server/src/server/api/requests/code_action.rs b/crates/ty_server/src/server/api/requests/code_action.rs index 37046905ae..8404154f35 100644 --- a/crates/ty_server/src/server/api/requests/code_action.rs +++ b/crates/ty_server/src/server/api/requests/code_action.rs @@ -81,7 +81,7 @@ impl BackgroundDocumentRequestHandler for CodeActionRequestHandler { document_changes: None, change_annotations: None, }), - is_preferred: Some(true), + is_preferred: Some(fix.preferred), command: None, disabled: None, data: None, diff --git a/crates/ty_server/src/server/api/requests/data_flow.rs b/crates/ty_server/src/server/api/requests/data_flow.rs index 49c7e00718..90f21090a3 100644 --- a/crates/ty_server/src/server/api/requests/data_flow.rs +++ b/crates/ty_server/src/server/api/requests/data_flow.rs @@ -40,17 +40,17 @@ impl Request for DataFlowRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DataFlowParams { /// the file the program is stopped in - pub(crate) text_document: TextDocumentIdentifier, + text_document: TextDocumentIdentifier, /// the one-based line it is stopped on - pub(crate) line: u32, + line: u32, /// what the debugger observed, one entry per name /// /// only observations the client is willing to stand behind belong here. a debugger that /// reports how long a reading stays true — as `bpd` does — is the thing that decides which /// ones those are; the server takes what it is given - pub(crate) observations: Vec, + observations: Vec, } /// one observation, in the shape a client sends it @@ -66,11 +66,11 @@ pub(crate) struct DataFlowParams { #[serde(rename_all = "camelCase")] pub(crate) struct WireObservation { /// the name, or a dotted path such as `self.limit` - pub(crate) name: String, + name: String, /// what was seen. exactly one of these is set; anything else is refused #[serde(flatten)] - pub(crate) observed: WireObserved, + observed: WireObserved, } /// what was read off the value @@ -144,21 +144,21 @@ impl WireObservation { #[serde(rename_all = "camelCase")] pub(crate) struct DataFlowFinding { /// where in the document - pub(crate) range: lsp_types::Range, + range: lsp_types::Range, /// what kind of finding: `condition`, `unreachable` or `value` - pub(crate) kind: String, + kind: String, /// which way a condition goes. absent for anything else #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) taken: Option, + taken: Option, /// what a decided read will find, written the way a source writes it. absent for anything else /// /// carried beside [`label`](Self::label), which already spells it, because a client that wants /// to do anything but draw the label — colour by value, offer it for a copy — should not have /// to take a string written for a human back apart #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) value: Option, + value: Option, /// what to draw beside the source - pub(crate) label: String, + label: String, } pub(crate) struct DataFlowRequestHandler; diff --git a/crates/ty_server/src/server/api/requests/diagnostic.rs b/crates/ty_server/src/server/api/requests/diagnostic.rs index 1e91cadb58..10a96585a1 100644 --- a/crates/ty_server/src/server/api/requests/diagnostic.rs +++ b/crates/ty_server/src/server/api/requests/diagnostic.rs @@ -55,24 +55,22 @@ impl BackgroundDocumentRequestHandler for DocumentDiagnosticRequestHandler { } .into() } - new_id => { - RelatedFullDocumentDiagnosticReport { - related_documents: None, - full_document_diagnostic_report: FullDocumentDiagnosticReport { - result_id: new_id, - // SAFETY: Pull diagnostic requests are only called for text documents, not for - // notebook documents. - items: diagnostics - .to_lsp_diagnostics( - db, - snapshot.resolved_client_capabilities(), - snapshot.global_settings(), - ) - .expect_text_document(), - }, - } - .into() + new_id => RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: new_id, + // A notebook is checked as a whole, but a pull response only includes + // diagnostics for the requested cell. + items: diagnostics + .to_lsp_diagnostics( + db, + snapshot.resolved_client_capabilities(), + snapshot.global_settings(), + ) + .into_document_diagnostics(snapshot.uri()), + }, } + .into(), }; Ok(report) diff --git a/crates/ty_server/src/server/api/requests/execute_command.rs b/crates/ty_server/src/server/api/requests/execute_command.rs index 45794dc762..d0c2a7ad72 100644 --- a/crates/ty_server/src/server/api/requests/execute_command.rs +++ b/crates/ty_server/src/server/api/requests/execute_command.rs @@ -177,7 +177,7 @@ fn add_dependency( } let system = session.system(); - let uv = ty_project::metadata::uv::executable(system) + let uv = ty_project::uv::executable(system) .map_err(|error| anyhow::anyhow!("`uv add` cannot be run: {error}"))?; let executor = system .command_executor() diff --git a/crates/ty_server/src/server/api/requests/explain_rule.rs b/crates/ty_server/src/server/api/requests/explain_rule.rs index fda48b3ffd..8d0da8f553 100644 --- a/crates/ty_server/src/server/api/requests/explain_rule.rs +++ b/crates/ty_server/src/server/api/requests/explain_rule.rs @@ -32,7 +32,7 @@ impl Request for ExplainRuleRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExplainRuleParams { /// A lint name, e.g. `redundant-return-annotation` — what a diagnostic reports under. - pub(crate) name: String, + name: String, } /// What the rule is, ready to show. @@ -40,11 +40,11 @@ pub(crate) struct ExplainRuleParams { #[serde(rename_all = "camelCase")] pub(crate) struct RuleExplanation { /// The lint's own name. - pub(crate) name: String, + name: String, /// A one-line summary. - pub(crate) summary: String, + summary: String, /// The full explanation, in markdown. - pub(crate) documentation: String, + documentation: String, } pub(crate) struct ExplainRuleHandler; diff --git a/crates/ty_server/src/server/api/requests/explain_transpilation.rs b/crates/ty_server/src/server/api/requests/explain_transpilation.rs index 5855f1bdcf..35a0d73312 100644 --- a/crates/ty_server/src/server/api/requests/explain_transpilation.rs +++ b/crates/ty_server/src/server/api/requests/explain_transpilation.rs @@ -34,7 +34,7 @@ impl Request for ExplainTranspilationRequest { #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExplainTranspilationParams { - pub(crate) text_document: TextDocumentIdentifier, + text_document: TextDocumentIdentifier, } /// One construct found, and what the transpiler does with it. @@ -42,13 +42,13 @@ pub(crate) struct ExplainTranspilationParams { #[serde(rename_all = "camelCase")] pub(crate) struct TranspilationNote { /// A short, stable name for the construct, e.g. `null-safe access`. - pub(crate) construct: String, + construct: String, /// The source it was written as. - pub(crate) snippet: String, + snippet: String, /// What it lowers to, in a sentence. - pub(crate) explanation: String, + explanation: String, /// The one-based line it is on. - pub(crate) line: u32, + line: u32, } pub(crate) struct ExplainTranspilationHandler; @@ -82,7 +82,7 @@ impl RetriableRequestHandler for ExplainTranspilationHandler {} /// /// Parsed rather than scanned. A construct is only reported where the parser built the node for it, /// so the same characters inside a string, a comment or a type position are not mistaken for one. -pub(crate) fn notes_in(source: &str) -> Vec { +fn notes_in(source: &str) -> Vec { let parsed = ruff_python_parser::parse_unchecked_source( source, ruff_python_ast::PySourceType::BasedPython, diff --git a/crates/ty_server/src/server/api/requests/injections.rs b/crates/ty_server/src/server/api/requests/injections.rs index 216c44dd1e..b8474ac7fc 100644 --- a/crates/ty_server/src/server/api/requests/injections.rs +++ b/crates/ty_server/src/server/api/requests/injections.rs @@ -37,7 +37,7 @@ impl Request for InjectionsRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct InjectionsParams { /// The document to look in — its *buffer*, so a marker typed a moment ago counts. - pub(crate) text_document: TextDocumentIdentifier, + text_document: TextDocumentIdentifier, } /// Every fragment in the document, in source order. @@ -46,7 +46,7 @@ pub(crate) struct InjectionsParams { pub(crate) struct InjectionsResponse { /// The fragments. A client keys its own state on a fragment's position in this list, which is /// source order and so is stable while the fragments are. - pub(crate) injections: Vec, + injections: Vec, } /// One fragment of another language. @@ -55,19 +55,19 @@ pub(crate) struct InjectionsResponse { pub(crate) struct InjectionFragment { /// The language, as the marker spelled it. The server does not interpret it: matching it to a /// language the editor has is the client's, and an id it does not recognise is not an error. - pub(crate) language: String, + language: String, /// Where the fragment's text is, quotes excluded, one range per literal part. /// /// More than one means the fragment was written as several adjacent literals, and its text is /// their contents joined in this order. - pub(crate) ranges: Vec, + ranges: Vec, /// What decided the language: `comment`, `declared`, or `propagated`. /// /// A client shows this when a reader asks why a string is being treated as another language — /// `propagated` is the one whose reason is not visible at the string itself. - pub(crate) origin: String, + origin: String, } pub(crate) struct InjectionsRequestHandler; diff --git a/crates/ty_server/src/server/api/requests/transpile.rs b/crates/ty_server/src/server/api/requests/transpile.rs index e076f29edf..0f611f3981 100644 --- a/crates/ty_server/src/server/api/requests/transpile.rs +++ b/crates/ty_server/src/server/api/requests/transpile.rs @@ -37,11 +37,11 @@ impl Request for TranspileRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct TranspileParams { /// The document to transpile. Its *buffer* — what the editor holds, which is the point. - pub(crate) text_document: TextDocumentIdentifier, + text_document: TextDocumentIdentifier, /// When true, go the other way: python in, basedpython out. #[serde(default)] - pub(crate) reverse: bool, + reverse: bool, /// Text to transpile instead of the document's own. /// @@ -51,7 +51,7 @@ pub(crate) struct TranspileParams { /// document the fragment came from, because that is what routes the request to a server; the /// fragment is checked on its own, which is all a fragment can be. #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) source: Option, + source: Option, } /// What came out, or why nothing did. @@ -64,11 +64,11 @@ pub(crate) struct TranspileParams { pub(crate) struct TranspileResponse { /// The generated source, absent when the transpile failed. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) source: Option, + source: Option, /// Why it failed, absent when it did not. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) error: Option, + error: Option, } impl TranspileResponse { diff --git a/crates/ty_server/src/server/api/requests/transpile_for_build.rs b/crates/ty_server/src/server/api/requests/transpile_for_build.rs index 7d8d88db18..cd75ff21a6 100644 --- a/crates/ty_server/src/server/api/requests/transpile_for_build.rs +++ b/crates/ty_server/src/server/api/requests/transpile_for_build.rs @@ -66,7 +66,7 @@ pub(crate) struct TranspileForBuildParams { /// A client that has not saved yet gets the buffer transpiled and the `.by` digest taken over /// that same buffer, which is coherent — though a client doing this for a debugger should save /// first anyway, because the traceback rewriter reads the file from disk. - pub(crate) text_document: TextDocumentIdentifier, + text_document: TextDocumentIdentifier, /// the build tree the program is running out of /// @@ -74,7 +74,7 @@ pub(crate) struct TranspileForBuildParams { /// thing that sees the name is whatever started the program. It is not trusted on the strength /// of being sent — `_by_build.json` in it has to say it was written by this same `by`, or the /// answer is a refusal. - pub(crate) build_directory: std::path::PathBuf, + build_directory: std::path::PathBuf, } pub(crate) struct TranspileForBuildRequestHandler; diff --git a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs index 4bf6b9121f..1553e33069 100644 --- a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs +++ b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs @@ -17,7 +17,7 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use serde_json::json; use ty_ide::{Hint, hints}; -use ty_project::{ProgressReporter, ProjectDatabase, SemanticDb as _}; +use ty_project::{Db as _, ProgressReporter, ProjectDatabase}; use crate::PositionEncoding; use crate::capabilities::ResolvedClientCapabilities; @@ -98,6 +98,10 @@ use crate::system::file_to_uri; /// suspended workspace diagnostic request (if any) after every notification if the notification /// changed the [`Session`]'s state. /// +/// Workspace diagnostics also wait while a script's initial environment is unavailable. +/// Refreshing an available project or script environment does not block diagnostics. +/// The same long-polling mechanism resumes the request after the host applies the uv results. +/// /// [workspace-diagnostics](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_diagnostic) pub(crate) struct WorkspaceDiagnosticRequestHandler; @@ -116,6 +120,20 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { return Ok(WorkspaceDiagnosticReport { items: vec![] }); } + if snapshot + .projects() + .iter() + .any(|db| db.uv_environments().has_pending_initializations()) + { + tracing::debug!( + "Deferring workspace diagnostics until script initialization completes" + ); + // Returning an empty workspace report makes `handle_request` suspend the request. + // Skip the response writer: it would clear diagnostics for previous result IDs + // that we have not checked yet. Suspension retains the request, not this snapshot. + return Ok(WorkspaceDiagnosticReport { items: vec![] }); + } + let writer = ResponseWriter::new( params.partial_result_params.partial_result_token, params.previous_result_ids, @@ -239,7 +257,7 @@ impl ProgressReporter for WorkspaceDiagnosticsProgressReporter<'_> { } fn report_checked_file(&self, db: &ProjectDatabase, file: File, diagnostics: &[Diagnostic]) { - let unnecessary_hints = hints(db, db.program_file(file)); + let unnecessary_hints = hints(db, file); // Another thread might have panicked at this point because of a salsa cancellation which // poisoned the result. If the response is poisoned, just don't report and wait for our thread @@ -287,7 +305,7 @@ impl ProgressReporter for WorkspaceDiagnosticsProgressReporter<'_> { let response = &mut self.state.get_mut().unwrap().response; for (file, diagnostics) in by_file { - let unnecessary_hints = hints(db, db.program_file(file)); + let unnecessary_hints = hints(db, file); response.write_diagnostics_for_file(db, file, &diagnostics, &unnecessary_hints); } response.maybe_flush(); diff --git a/crates/ty_server/src/server/lazy_work_done_progress.rs b/crates/ty_server/src/server/lazy_work_done_progress.rs index 87d0c19d2e..01dbbb5bef 100644 --- a/crates/ty_server/src/server/lazy_work_done_progress.rs +++ b/crates/ty_server/src/server/lazy_work_done_progress.rs @@ -42,57 +42,108 @@ static SERVER_WORK_DONE_TOKENS: AtomicUsize = AtomicUsize::new(0); /// /// [work-done-progress]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workDoneProgress #[derive(Clone)] -pub(super) struct LazyWorkDoneProgress { +pub(crate) struct LazyWorkDoneProgress { inner: Arc, } impl LazyWorkDoneProgress { - pub(super) fn new( + pub(crate) fn new( client: &Client, request_token: Option, title: &str, capabilities: ResolvedClientCapabilities, ) -> Self { - if let Some(token) = &request_token { - Self::send_begin(client, token.clone(), title.to_string()); - } - - let is_server_initiated = request_token.is_none(); + Self::new_inner( + client, + request_token, + WorkDoneProgressBegin { + title: title.to_string(), + cancellable: Some(false), + message: None, + percentage: Some(0), + }, + capabilities, + ProgressCreation::Queue, + ) + } - let once_token = std::sync::OnceLock::new(); - if let Some(token) = request_token { - // SAFETY: The token is guaranteed to be not set yet because we only created it above. - once_token.set(token).unwrap(); - } + /// Returns a progress reporter that displays an indicator if the main-loop action queue has + /// capacity. + /// + /// Server-initiated progress requires sending a request to the client through the main loop. + /// If the queue is full, the progress indicator is not shown. Waiting for capacity would + /// deadlock because the main loop cannot drain its own queue until this call returns. + pub(crate) fn new_on_main_loop( + client: &Client, + begin: WorkDoneProgressBegin, + capabilities: ResolvedClientCapabilities, + ) -> Self { + Self::new_inner( + client, + None, + begin, + capabilities, + ProgressCreation::TryQueue, + ) + } + fn new_inner( + client: &Client, + request_token: Option, + begin: WorkDoneProgressBegin, + capabilities: ResolvedClientCapabilities, + creation: ProgressCreation, + ) -> Self { let work_done = Self { inner: Arc::new(Inner { - token: once_token, + token: std::sync::OnceLock::new(), finish_message: std::sync::Mutex::default(), client: client.clone(), }), }; - if is_server_initiated && capabilities.supports_work_done_progress() { + if let Some(token) = request_token { + if Self::send_begin(client, token.clone(), begin) { + work_done + .inner + .token + .set(token) + .expect("progress token should only be set once"); + } + } else if capabilities.supports_work_done_progress() { // Use a string token because Zed does not support numeric tokens let token = ProgressToken::String(format!( "ty-{}", SERVER_WORK_DONE_TOKENS.fetch_add(1, Ordering::Relaxed) )); let work_done = work_done.clone(); - let title = title.to_string(); - - client.send_deferred_request::( - WorkDoneProgressCreateParams { - token: token.clone(), - }, - move |client, ()| { - Self::send_begin(client, token.clone(), title); - // SAFETY: We only take this branch if `request_token` was `None` - // and we only issue a single request (without retry). - work_done.inner.token.set(token).unwrap(); - }, - ); + + let params = WorkDoneProgressCreateParams { + token: token.clone(), + }; + let response_handler = move |client: &Client, ()| { + if Self::send_begin(client, token.clone(), begin) { + work_done + .inner + .token + .set(token) + .expect("progress token should only be set once"); + } + }; + + match creation { + ProgressCreation::Queue => client + .send_deferred_request::( + params, + response_handler, + ), + ProgressCreation::TryQueue => { + client.try_send_deferred_request::( + params, + response_handler, + ); + } + } } work_done @@ -104,20 +155,9 @@ impl LazyWorkDoneProgress { *finish_message = Some(message); } - fn send_begin(client: &Client, token: ProgressToken, title: String) { - client.send_notification::(ProgressParams { - token, - value: serde_json::to_value(WorkDoneProgressBegin { - title, - cancellable: Some(false), - message: None, - percentage: Some(0), - }) - .expect("Failed to serialize work done progress begin"), - }); - } - /// Sends a progress report with the given message and optional percentage. + /// + /// Reports sent before the client acknowledges progress creation are dropped. pub(super) fn report_progress(&self, message: impl Display, percentage: Option) { let Some(token) = self.inner.token.get() else { return; @@ -125,7 +165,7 @@ impl LazyWorkDoneProgress { self.inner .client - .send_notification::(ProgressParams { + .try_send_notification::(ProgressParams { token: token.clone(), value: serde_json::to_value(WorkDoneProgressReport { cancellable: Some(false), @@ -135,8 +175,24 @@ impl LazyWorkDoneProgress { .expect("Failed to serialize work done progress report"), }); } + + fn send_begin(client: &Client, token: ProgressToken, begin: WorkDoneProgressBegin) -> bool { + client.try_send_notification::(ProgressParams { + token, + value: serde_json::to_value(begin) + .expect("Failed to serialize work done progress begin"), + }) + } +} + +#[derive(Clone, Copy)] +enum ProgressCreation { + Queue, + TryQueue, } +impl ty_project::UvSyncProgress for LazyWorkDoneProgress {} + struct Inner { token: std::sync::OnceLock, finish_message: std::sync::Mutex>, diff --git a/crates/ty_server/src/server/main_loop.rs b/crates/ty_server/src/server/main_loop.rs index fb977673d9..814eecd39d 100644 --- a/crates/ty_server/src/server/main_loop.rs +++ b/crates/ty_server/src/server/main_loop.rs @@ -3,10 +3,10 @@ use crate::server::{Server, api}; use crate::session::client::{Client, ClientResponseHandler}; use crate::session::{ClientOptions, SuspendedWorkspaceDiagnosticRequest}; use anyhow::anyhow; -use crossbeam::select; use lsp_server::Message; use lsp_types::Notification; use lsp_types::Uri; +use ruff_db::system::SystemPathBuf; pub(crate) type ConnectionSender = crossbeam::channel::Sender; pub(crate) type MainLoopSender = crossbeam::channel::Sender; @@ -165,6 +165,9 @@ impl Server { // self.try_register_file_watcher(&client); } }, + Event::PollUvEnvironments { project_root } => { + self.session.poll_uv_sync(&client, &project_root); + } } } @@ -193,13 +196,8 @@ impl Server { return Ok(Some(Event::Message(deferred))); } - select!( - recv(self.connection.receiver) -> msg => { - // Ignore disconnect errors, they're handled by the main loop (it will exit). - Ok(msg.ok().map(Event::Message)) - }, - recv(self.main_loop_receiver) -> event => event.map(Some), - ) + let uv_sync = UvSyncWakeups(self.session.uv_sync_wakeups()); + uv_sync.select(&self.connection.receiver, &self.main_loop_receiver) } fn initialize(&mut self, client: &Client) { @@ -236,6 +234,10 @@ pub(crate) enum Event { Message(lsp_server::Message), Action(Action), + + PollUvEnvironments { + project_root: SystemPathBuf, + }, } pub(crate) struct SendRequest { @@ -252,3 +254,40 @@ impl std::fmt::Debug for SendRequest { .finish_non_exhaustive() } } + +/// Uv-environment wakeups for the currently active project databases. +struct UvSyncWakeups(Vec<(SystemPathBuf, crossbeam::channel::Receiver<()>)>); + +impl UvSyncWakeups { + /// Waits for a project wakeup, client message, or main-loop action. + fn select( + &self, + connection: &crossbeam::channel::Receiver, + main_loop: &MainLoopReceiver, + ) -> Result, crossbeam::channel::RecvError> { + let mut select = crossbeam::channel::Select::new_biased(); + for (_, receiver) in &self.0 { + select.recv(receiver); + } + let connection_index = select.recv(connection); + let main_loop_index = select.recv(main_loop); + let operation = select.select(); + let index = operation.index(); + + if let Some((project_root, receiver)) = self.0.get(index) { + return operation.recv(receiver).map(|()| { + Some(Event::PollUvEnvironments { + project_root: project_root.clone(), + }) + }); + } + + if index == connection_index { + // Ignore disconnect errors, they're handled by the main loop (it will exit). + return Ok(operation.recv(connection).ok().map(Event::Message)); + } + + debug_assert_eq!(index, main_loop_index); + operation.recv(main_loop).map(Some) + } +} diff --git a/crates/ty_server/src/server/script_progress.rs b/crates/ty_server/src/server/script_progress.rs new file mode 100644 index 0000000000..00f2090195 --- /dev/null +++ b/crates/ty_server/src/server/script_progress.rs @@ -0,0 +1,204 @@ +use std::sync::{Arc, Mutex, Weak}; + +use lsp_types::WorkDoneProgressBegin; +use ty_project::UvSyncProgress; + +use crate::capabilities::ResolvedClientCapabilities; +use crate::session::client::Client; + +use super::LazyWorkDoneProgress; + +/// Shows completed/total script synchronizations and the last started script in one indicator. +/// +/// The indicator starts when the first request is scheduled, including time spent queued. +/// Only request guards own the shared state. The session keeps a weak reference so the indicator +/// ends when the last request is applied or dropped, including requests that have not started yet. +#[derive(Clone, Default)] +pub(crate) struct ScriptProgress { + current: Arc>>, +} + +impl ScriptProgress { + pub(crate) fn for_script( + &self, + client: &Client, + capabilities: ResolvedClientCapabilities, + display_path: String, + ) -> Option> { + if !capabilities.supports_work_done_progress() { + return None; + } + + let mut current = self.current.lock().ok()?; + let shared = current.upgrade().unwrap_or_else(|| { + let shared = Arc::new(SharedProgress { + work_done: LazyWorkDoneProgress::new_on_main_loop( + client, + WorkDoneProgressBegin { + title: "Synchronizing scripts".to_string(), + cancellable: Some(false), + message: Some("0/1".to_string()), + percentage: None, + }, + capabilities, + ), + state: Mutex::default(), + }); + *current = Arc::downgrade(&shared); + shared + }); + { + let mut state = shared.state.lock().ok()?; + state.total += 1; + state.report_progress(&shared.work_done); + } + + Some(Box::new(ScriptProgressGuard { + shared, + name: display_path, + })) + } +} + +struct ScriptProgressGuard { + shared: Arc, + name: String, +} + +impl UvSyncProgress for ScriptProgressGuard { + fn started(&mut self) { + let Ok(mut state) = self.shared.state.lock() else { + return; + }; + state.last_started.clone_from(&self.name); + state.report_progress(&self.shared.work_done); + } + + fn completed(self: Box) { + if let Ok(mut state) = self.shared.state.lock() { + state.completed += 1; + state.report_progress(&self.shared.work_done); + } + } +} + +struct SharedProgress { + work_done: LazyWorkDoneProgress, + state: Mutex, +} + +#[derive(Default)] +struct State { + completed: usize, + total: usize, + last_started: String, +} + +impl State { + fn report_progress(&self, progress: &LazyWorkDoneProgress) { + let mut message = format!("{}/{}", self.completed, self.total); + if !self.last_started.is_empty() { + message.push_str(": "); + message.push_str(&self.last_started); + } + progress.report_progress(message, None); + if self.completed == self.total { + progress.set_finish_message("Finished synchronizing scripts".to_string()); + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::{Context, Result, bail}; + use crossbeam::channel::unbounded; + use lsp_server::{Message, Response}; + use lsp_types::ProgressParams; + + use crate::capabilities::ResolvedClientCapabilities; + use crate::server::{Action, Event}; + use crate::session::client::Client; + + use super::ScriptProgress; + + #[test] + fn script_progress_counts_pending_scripts_and_shows_last_started() -> Result<()> { + let (main_loop, actions) = unbounded(); + let (sender, messages) = unbounded(); + let client = Client::new(main_loop, sender); + let progress = ScriptProgress::default(); + let capabilities = ResolvedClientCapabilities::WORK_DONE_PROGRESS; + let script = |name: &str| { + progress + .for_script(&client, capabilities, name.to_string()) + .context("progress is supported") + }; + let acknowledge_progress = || -> Result<()> { + let Event::Action(Action::SendRequest(request)) = actions.try_recv()? else { + bail!("expected progress creation request"); + }; + request + .response_handler + .handle_response(&client, Response::new_ok(0.into(), ())); + Ok(()) + }; + + // Queued requests show their count before any uv command starts. + let mut first = script("first.py")?; + acknowledge_progress()?; + let mut second = script("second.py")?; + first.started(); + second.started(); + + // A replacement run keeps the same count. Finishing it keeps the last started name. + second.finished(); + second.started(); + second.finished(); + second.completed(); + + // Failure to start uv still completes the request when its error is handled. + script("failed.py")?.completed(); + first.finished(); + first.completed(); + + // Abandoning a later request closes its indicator without reporting completion. + let abandoned = script("abandoned.py")?; + acknowledge_progress()?; + drop(abandoned); + + assert_eq!( + messages + .try_iter() + .map(progress_notification) + .collect::>>()?, + [ + "begin: 0/1", + "report: 0/2", + "report: 0/2: first.py", + "report: 0/2: second.py", + "report: 0/2: second.py", + "report: 1/2: second.py", + "report: 1/3: second.py", + "report: 2/3: second.py", + "report: 3/3: second.py", + "end: Finished synchronizing scripts", + "begin: 0/1", + "end: ", + ] + ); + assert!(actions.is_empty()); + Ok(()) + } + + fn progress_notification(message: Message) -> Result { + let Message::Notification(notification) = message else { + bail!("expected progress notification"); + }; + let params: ProgressParams = serde_json::from_value(notification.params)?; + let kind = params.value["kind"] + .as_str() + .context("missing progress kind")?; + let message = params.value["message"].as_str().unwrap_or_default(); + Ok(format!("{kind}: {message}")) + } +} diff --git a/crates/ty_server/src/session.rs b/crates/ty_server/src/session.rs index e9a58ae2a9..c4eb70d62d 100644 --- a/crates/ty_server/src/session.rs +++ b/crates/ty_server/src/session.rs @@ -11,6 +11,7 @@ use lsp_types::{ ClientInfo, DiagnosticProvider, DiagnosticRegistrationOptions, DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, Registration, RegistrationParams, TextDocumentContentChangeEvent, Unregistration, UnregistrationParams, Uri, + WorkDoneProgressBegin, }; use lsp_types::{DidChangeWatchedFilesNotification, ExitNotification, Notification}; use lsp_types::{ @@ -23,8 +24,11 @@ use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_python_ast::PySourceType; use ty_combine::Combine; use ty_project::metadata::Options; -use ty_project::watch::{ChangeEvent, CreatedKind}; -use ty_project::{ChangeResult, Db as _, ProjectDatabase, ProjectMetadata}; +use ty_project::watch::ChangeEvent; +use ty_project::{ + ChangeResult, Db as _, ProjectDatabase, ProjectMetadata, ProjectReloadResult, + ScriptEnvironmentAvailability, UseUv, UvSyncChanges, +}; use index::DocumentError; use ty_python_core::program::UseDefaultStrategy; @@ -33,8 +37,12 @@ pub(crate) use self::options::InitializationOptions; pub use self::options::{ClientOptions, DiagnosticMode, GlobalOptions, WorkspaceOptions}; pub(crate) use self::settings::{GlobalSettings, WorkspaceSettings}; use crate::capabilities::{ResolvedClientCapabilities, server_diagnostic_options}; +use crate::db::Db as _; use crate::document::{DocumentKey, DocumentVersion, LanguageId, NotebookDocument}; -use crate::server::{Action, publish_settings_diagnostics}; +use crate::server::{ + Action, LazyWorkDoneProgress, ScriptProgress, publish_all_document_diagnostics, + publish_diagnostics_if_needed, publish_settings_diagnostics, +}; use crate::session::client::Client; use crate::session::index::Document; use crate::session::request_queue::RequestQueue; @@ -71,6 +79,12 @@ pub(crate) struct Session { /// Initialization options that were provided by the client during server initialization. initialization_options: InitializationOptions, + /// The uv integrations enabled for the lifetime of this server. + use_uv: UseUv, + + /// Shares one progress indicator while script synchronization requests are outstanding. + script_progress: ScriptProgress, + /// Resolved global settings that are shared across all workspaces. global_settings: Arc, @@ -155,6 +169,8 @@ impl Session { workspaces.register(uri)?; } + let use_uv = initialization_options.use_uv(&*native_system); + Ok(Self { native_system, position_encoding, @@ -162,6 +178,8 @@ impl Session { deferred_messages: VecDeque::new(), index: Some(index), initialization_options, + use_uv, + script_progress: ScriptProgress::default(), global_settings: Arc::new(GlobalSettings::default()), projects: BTreeMap::new(), resolved_client_capabilities, @@ -242,6 +260,132 @@ impl Session { }); } + /// Returns each project's background uv synchronization wakeups. + pub(crate) fn uv_sync_wakeups(&self) -> Vec<(SystemPathBuf, crossbeam::channel::Receiver<()>)> { + self.projects + .iter() + .map(|(root, state)| (root.clone(), state.db.uv_environments().sync_wakeups())) + .collect() + } + + /// Gives one project's uv environments an opportunity to make progress. + pub(crate) fn poll_uv_sync(&mut self, client: &Client, project_root: &SystemPath) { + let Some(project) = self.projects.get_mut(project_root) else { + tracing::debug!( + "Ignored uv synchronization wakeup for removed project `{project_root}`" + ); + return; + }; + let db = &mut project.db; + let environments = db.uv_environments().clone(); + let changes = environments.poll_sync(db); + if matches!( + changes.project, + Some(ProjectReloadResult::Changed { + files_changed: true, + }) + ) { + let scripts: Vec<_> = db.project().script_files(db).iter().collect(); + Self::synchronize_closed_scripts( + db, + &scripts, + client, + self.resolved_client_capabilities, + &self.script_progress, + ); + } + self.uv_environments_changed(client, project_root, changes); + } + + fn uv_environments_changed( + &mut self, + client: &Client, + project_root: &SystemPath, + changes: UvSyncChanges, + ) { + if changes.is_empty() { + return; + } + + if changes.project.is_some() { + publish_settings_diagnostics(self, client, project_root.to_path_buf()); + } + + self.bump_revision(); + + self.resume_suspended_workspace_diagnostic_request(client); + + let capabilities = self.client_capabilities(); + if capabilities.supports_workspace_diagnostic_refresh() { + client.send_request::(self, (), |_, ()| {}); + } else if changes.project.is_some() { + publish_all_document_diagnostics(self, client); + } else if let Some(project) = self.projects.get(project_root) { + for file in changes.scripts { + if let Some(document) = project.db.document(file) { + let document = DocumentHandle::from_document(document); + publish_diagnostics_if_needed(&document, self, client); + } + } + } + + if capabilities.supports_semantic_tokens_refresh() { + client.send_request::(self, (), |_, ()| {}); + } + + if capabilities.supports_inlay_hint_refresh() { + client.send_request::(self, (), |_, ()| {}); + } + } + + /// Requests synchronization using the scripts' saved metadata. + fn synchronize_closed_scripts( + db: &mut ProjectDatabase, + scripts: &[File], + client: &Client, + capabilities: ResolvedClientCapabilities, + progress: &ScriptProgress, + ) { + for &file in scripts { + // Open and save handle editor documents separately. Their metadata may contain + // unsaved changes, including overlays that are not in the diagnostic open-file set. + if db.document(file).is_some() { + continue; + } + Self::request_script_sync( + db, + file, + client, + capabilities, + ScriptEnvironmentAvailability::Pending, + progress, + ); + } + } + + fn request_script_sync( + db: &mut ProjectDatabase, + file: File, + client: &Client, + capabilities: ResolvedClientCapabilities, + availability: ScriptEnvironmentAvailability, + progress: &ScriptProgress, + ) { + let environments = db.uv_environments().clone(); + environments.request_sync(db, file, availability, &|db, file| { + let file_path = file.path(db); + let display_path = file_path.as_system_path().map_or_else( + || file_path.to_string(), + |path| { + path.strip_prefix(db.project().root(db)) + .unwrap_or(path) + .to_string() + }, + ); + progress.for_script(client, capabilities, display_path) + }); + } + /// Bumps the revision. /// /// The revision is used to track when workspace diagnostics may have changed and need to be re-run. @@ -411,12 +555,34 @@ impl Session { pub(crate) fn apply_changes( &mut self, + client: &Client, path: &AnySystemPath, changes: &[ChangeEvent], ) -> ChangeResult { self.bump_revision(); - self.project_db_mut(path).apply_changes(changes) + let capabilities = self.resolved_client_capabilities; + let script_progress = self.script_progress.clone(); + let db = self.project_db_mut(path); + let result = db.apply_changes(changes); + if let Some(project_path) = result.project_sync_path() { + db.uv_environments() + .request_project_sync(db, project_path, &|db, project| { + Some(Box::new(LazyWorkDoneProgress::new_on_main_loop( + client, + WorkDoneProgressBegin { + title: format!("Refreshing {} metadata", project.name(db)), + cancellable: Some(false), + message: None, + percentage: None, + }, + capabilities, + ))) + }); + } + let scripts = result.scripts_to_synchronize(db); + Self::synchronize_closed_scripts(db, &scripts, client, capabilities, &script_progress); + result } /// Returns a mutable iterator over all project databases. @@ -564,7 +730,18 @@ impl Session { } }; - let settings = options.into_settings(&root, client, &*self.native_system); + // Zed sends a single file as a workspace folder. Preserve that path as the + // workspace's identity, but resolve configuration and imports from its parent directory. + // https://github.com/zed-industries/zed/issues/40627 + let workspace_directory = if self.native_system.is_file(&root) + && let Some(parent) = root.parent() + { + parent + } else { + root.as_path() + }; + + let settings = options.into_settings(workspace_directory, client, &*self.native_system); let Some(workspace) = self.workspaces.workspaces.get_mut(&root) else { tracing::debug!("Ignoring workspace `{uri}` since it was not registered"); return; @@ -584,14 +761,20 @@ impl Session { let system = LSPSystem::new( self.index.as_ref().unwrap().clone(), self.native_system.clone(), + self.initialization_options.workspace_trust, ); let configuration_file = workspace.settings.configuration_file(); let metadata = if let Some(configuration_file) = configuration_file { - ProjectMetadata::from_config_file(configuration_file.clone(), &root, &system) + ProjectMetadata::from_config_file_with_uv( + configuration_file.clone(), + workspace_directory, + &system, + self.use_uv, + ) } else { - ProjectMetadata::discover(&root, &system) + ProjectMetadata::discover_with_uv(workspace_directory, &system, self.use_uv) }; let project = metadata @@ -612,8 +795,8 @@ impl Session { ProjectDatabase::fallible(metadata, system.clone()) }); - let (root, mut db) = match project { - Ok(db) => (root, db), + let mut db = match project { + Ok(db) => db, Err(err) => { tracing::error!( "Failed to create project for workspace `{uri}`: {err:#}. \ @@ -627,17 +810,12 @@ impl Session { let Ok(metadata) = ProjectMetadata::from_options( Options::default(), - root, + workspace_directory.to_path_buf(), None, &UseDefaultStrategy, - ); - let db_with_default_settings = ProjectDatabase::use_defaults(metadata, system); - let default_root = db_with_default_settings - .project() - .root(&db_with_default_settings) - .to_path_buf(); - - (default_root, db_with_default_settings) + ) + .map(|metadata| metadata.with_use_uv(self.use_uv)); + ProjectDatabase::use_defaults(metadata, system) } }; @@ -651,6 +829,14 @@ impl Session { let untracked = previous .map(|state| state.untracked_files_with_pushed_diagnostics) .unwrap_or_default(); + let scripts: Vec<_> = db.project().script_files(&db).iter().collect(); + Self::synchronize_closed_scripts( + &mut db, + &scripts, + client, + self.resolved_client_capabilities, + &self.script_progress, + ); self.projects.insert( root.clone(), ProjectState { @@ -1199,30 +1385,73 @@ impl Session { /// If a document is already open here, it will be overwritten. /// /// Returns a handle to the opened document. - pub(crate) fn open_notebook_document(&mut self, document: NotebookDocument) -> DocumentHandle { + pub(crate) fn open_notebook_document( + &mut self, + client: &Client, + document: NotebookDocument, + ) -> DocumentHandle { let handle = self.index_mut().open_notebook_document(document); - self.open_document_in_db(&handle, None); + self.open_document_in_db(client, &handle, None); handle } /// Registers a text document at the provided `path`. /// If a document is already open here, it will be overwritten. /// + /// Starts script synchronization from the backing file before installing the editor contents. + /// /// Returns a handle to the opened document. - pub(crate) fn open_text_document(&mut self, document: TextDocument) -> DocumentHandle { + pub(crate) fn open_text_document( + &mut self, + client: &Client, + document: TextDocument, + ) -> DocumentHandle { let language_id = document.language_id(); + + // Request synchronization before installing the editor contents because uv reads the + // script from disk. This ensures both use the saved metadata, so saving changed metadata + // requests another synchronization. + if self.use_uv != UseUv::Off + && language_id == LanguageId::Python + && document.notebook().is_none() + && let DocumentKey::File(system_path) = DocumentKey::from_uri(document.uri()) + { + let capabilities = self.resolved_client_capabilities; + let script_progress = self.script_progress.clone(); + let db = self.project_db_mut(&AnySystemPath::System(system_path.clone())); + + // Refresh any cached disk revision before reading the script tag. A filesystem + // change may not have reached the watcher yet, and the later open event will + // refresh the file from the editor contents instead. + File::sync_path(db, &system_path); + if let Ok(file) = system_path_to_file(db, &system_path) { + Self::request_script_sync( + db, + file, + client, + capabilities, + ScriptEnvironmentAvailability::Pending, + &script_progress, + ); + } + } let handle = self.index_mut().open_text_document(document); - self.open_document_in_db(&handle, Some(language_id)); + self.open_document_in_db(client, &handle, Some(language_id)); handle } - fn open_document_in_db(&mut self, document: &DocumentHandle, language_id: Option) { + fn open_document_in_db( + &mut self, + client: &Client, + document: &DocumentHandle, + language_id: Option, + ) { let path = document.notebook_or_file_path(); // This is a "maybe" because the `File` might've not been interned yet i.e., the // `try_system` call will return `None` which doesn't mean that the file is new, it's just // that the server didn't need the file yet. - let is_maybe_new_system_file = path.as_system().is_some_and(|system_path| { + let _is_maybe_new_system_file = path.as_system().is_some_and(|system_path| { let db = self.project_db(path); db.files() .try_system(db, system_path) @@ -1241,15 +1470,7 @@ impl Session { match path { AnySystemPath::System(system_path) => { - let event = if is_maybe_new_system_file { - ChangeEvent::Created { - path: system_path.clone(), - kind: CreatedKind::File, - } - } else { - ChangeEvent::Opened(system_path.clone()) - }; - self.apply_changes(path, &[event]); + self.apply_changes(client, path, &[ChangeEvent::Opened(system_path.clone())]); if is_unsupported { return; @@ -1826,7 +2047,7 @@ impl DocumentHandle { /// /// A notebook and its cells are always python; only a plain text document /// can be anything else. - pub(crate) fn language_id(&self) -> LanguageId { + fn language_id(&self) -> LanguageId { match self { Self::Text { language_id, .. } => *language_id, Self::Notebook { .. } | Self::Cell { .. } => LanguageId::Python, @@ -1845,9 +2066,37 @@ impl DocumentHandle { matches!(self, Self::Cell { .. } | Self::Notebook { .. }) } + /// Synchronizes an open script, using `availability` until its first synchronization completes. + pub(crate) fn synchronize_script( + &self, + session: &mut Session, + client: &Client, + availability: ScriptEnvironmentAvailability, + ) { + let path = self.notebook_or_file_path(); + let Some(system_path) = path.as_system() else { + return; + }; + let capabilities = session.resolved_client_capabilities; + let script_progress = session.script_progress.clone(); + let db = session.project_db_mut(path); + let Some(file) = db.files().try_system(db, system_path) else { + return; + }; + Session::request_script_sync( + db, + file, + client, + capabilities, + availability, + &script_progress, + ); + } + pub(crate) fn update_text_document( &mut self, session: &mut Session, + client: &Client, content_changes: Vec, new_version: DocumentVersion, ) -> crate::Result<()> { @@ -1870,7 +2119,7 @@ impl DocumentHandle { self.set_version(document.version()); } - self.update_in_db(session); + self.update_in_db(session, client); Ok(()) } @@ -1878,6 +2127,7 @@ impl DocumentHandle { pub(crate) fn update_notebook_document( &mut self, session: &mut Session, + client: &Client, cells: Option, metadata: Option, new_version: DocumentVersion, @@ -1897,11 +2147,11 @@ impl DocumentHandle { self.set_version(new_version); } - self.update_in_db(session); + self.update_in_db(session, client); Ok(()) } - fn update_in_db(&self, session: &mut Session) { + fn update_in_db(&self, session: &mut Session, client: &Client) { let path = self.notebook_or_file_path(); let changes = match path { AnySystemPath::System(system_path) => { @@ -1912,7 +2162,7 @@ impl DocumentHandle { } }; - session.apply_changes(path, &changes); + session.apply_changes(client, path, &changes); } fn set_version(&mut self, version: DocumentVersion) { @@ -1934,7 +2184,7 @@ impl DocumentHandle { /// /// This can return an error when the document does not exist in the /// session index. - pub(crate) fn close(&self, session: &mut Session) -> crate::Result { + pub(crate) fn close(&self, session: &mut Session, client: &Client) -> crate::Result { let is_cell = self.is_cell(); let path = self.notebook_or_file_path(); @@ -1965,8 +2215,11 @@ impl DocumentHandle { db.project().remove_file(db, file); } - // Bump the file's revision back to using the file system's revision. - file.sync(db); + // Restore file and script membership from the saved contents. Discarding + // unsaved script metadata can bring a file back into the project when + // `exclude-scripts` is enabled. Also request synchronization for saved + // metadata changes that were skipped while the editor overlay was present. + self.update_in_db(session, client); } else { // This can only fail when the path is a directory or it doesn't exists but the // file should exists for this handler in this branch. This is because every @@ -2037,12 +2290,14 @@ pub(super) fn warn_about_unknown_options( #[cfg(test)] mod tests { + use std::io::ErrorKind; use std::sync::Arc; - use ruff_db::system::{CommandExecutor, OsSystem, System as _}; + use anyhow::Context; + use ruff_db::system::{Command, CommandExecutor, OsSystem, System as _}; use super::Index; - use crate::system::LSPSystem; + use crate::system::{LSPSystem, WorkspaceTrust}; /// Mutating the document index requires exclusive ownership after Salsa cancels the current /// database snapshots. A background command executor must not retain an `LSPSystem`, because @@ -2050,7 +2305,11 @@ mod tests { #[test] fn detached_command_executor_does_not_retain_document_index() { let index = Arc::new(Index::new()); - let system = LSPSystem::new(index.clone(), Arc::new(OsSystem::default())); + let system = LSPSystem::new( + index.clone(), + Arc::new(OsSystem::default()), + WorkspaceTrust::default(), + ); let executor = system.command_executor().map(CommandExecutor::dyn_clone); assert!(executor.is_some()); drop(system); @@ -2059,4 +2318,27 @@ mod tests { drop(executor); } + + #[test] + fn detached_untrusted_executor_rejects_commands() -> anyhow::Result<()> { + let system = LSPSystem::new( + Arc::new(Index::new()), + Arc::new(OsSystem::default()), + WorkspaceTrust::Untrusted, + ) + .dyn_clone(); + let executor = system + .command_executor() + .context("Expected an executor for the untrusted workspace")? + .dyn_clone(); + drop(system); + + let error = executor.execute(Command::new("must-not-run")).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::PermissionDenied); + assert_eq!( + error.to_string(), + "external commands are disabled in an untrusted workspace", + ); + Ok(()) + } } diff --git a/crates/ty_server/src/session/client.rs b/crates/ty_server/src/session/client.rs index 154f893795..e81c791e93 100644 --- a/crates/ty_server/src/session/client.rs +++ b/crates/ty_server/src/session/client.rs @@ -90,6 +90,26 @@ impl Client { } } + /// Attempts to queue a request without blocking the main loop. + /// + /// Returns `false` if the main-loop queue is full or disconnected. + pub(crate) fn try_send_deferred_request( + &self, + params: R::Params, + response_handler: impl FnOnce(&Client, R::Result) + Send + 'static, + ) -> bool + where + R: lsp_types::Request, + { + self.main_loop_sender + .try_send(Event::Action(Action::SendRequest(SendRequest { + method: R::METHOD.to_string(), + params: serde_json::to_value(params).expect("Params to be serializable"), + response_handler: ClientResponseHandler::for_request::(response_handler), + }))) + .is_ok() + } + pub(crate) fn send_request_raw(&self, session: &Session, request: SendRequest) { let id = session .request_queue() @@ -130,6 +150,21 @@ impl Client { } } + /// Attempts to send a notification without waiting for the client channel. + /// + /// Returns whether the notification was queued. + pub(crate) fn try_send_notification(&self, params: N::Params) -> bool + where + N: lsp_types::Notification, + { + self.client_sender + .try_send(lsp_server::Message::Notification(Notification::new( + N::METHOD.to_string(), + params, + ))) + .is_ok() + } + /// Sends a notification without any parameters to the client. /// /// This is useful for notifications that don't require any data. diff --git a/crates/ty_server/src/session/index.rs b/crates/ty_server/src/session/index.rs index 107b978d8a..81815a929a 100644 --- a/crates/ty_server/src/session/index.rs +++ b/crates/ty_server/src/session/index.rs @@ -109,8 +109,8 @@ impl Index { ) }); - tracing::info!( - "version: {}, new_version: {}", + tracing::debug!( + "Updating notebook document from version {} to version {}", notebook.version(), new_version ); diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index d472c71796..12ccb15df2 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use anyhow::Context; use lsp_types::Uri; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_macros::Combine; @@ -10,15 +11,16 @@ use serde_json::{Map, Value}; use strum::IntoEnumIterator; use ty_combine::Combine; use ty_ide::{CompletionSettings, InlayHintSettings}; -use ty_project::CheckMode; use ty_project::metadata::Options as TyOptions; use ty_project::metadata::options::EnvironmentOptions; use ty_project::metadata::python_version::SupportedPythonVersion; use ty_project::metadata::value::RelativePathBuf; +use ty_project::{CheckMode, UseUv}; use super::settings::{ExperimentalSettings, GlobalSettings, WorkspaceSettings}; use crate::logging::LogLevel; use crate::session::client::Client; +use crate::system::WorkspaceTrust; /// Initialization options that are set once at server startup that never change. /// @@ -46,7 +48,20 @@ pub(crate) struct InitializationOptions { /// Tildes (`~`) and environment variables (e.g., `$HOME`) are expanded. pub(crate) log_file: Option, - /// The remaining options that are dynamic and can change during the runtime of the server. + /// Whether the client trusts the files in this workspace. + /// + /// This corresponds to VS Code's [Workspace Trust]. `untrustedWorkspace: true` + /// means Restricted Mode. The default is `false` (trusted). + /// Restart the server to change this setting. + /// + /// [Workspace Trust]: https://code.visualstudio.com/docs/editing/workspaces/workspace-trust + #[serde(default, rename = "untrustedWorkspace")] + pub(crate) workspace_trust: WorkspaceTrust, + + /// The remaining client options. + /// + /// Most of these options are dynamic and can change while the server is running. Static + /// experimental options are resolved during initialization. #[serde(flatten)] pub(crate) options: ClientOptions, } @@ -55,20 +70,47 @@ impl InitializationOptions { /// Create the initialization options from the given JSON value that corresponds to the /// initialization options sent by the client. /// - /// It returns a tuple of the initialization options and an optional error if the JSON value - /// could not be deserialized into the initialization options. In case of an error, the default - /// initialization options are returned. + /// Invalid settings fall back to defaults, except that the workspace trust setting is + /// preserved. An invalid trust setting fails initialization instead of granting trust. pub(crate) fn from_value( options: Option, - ) -> (InitializationOptions, Option) { + ) -> anyhow::Result<(Self, Option)> { let Some(options) = options else { - return (InitializationOptions::default(), None); + return Ok((Self::default(), None)); }; + + // Parse trust separately so an unrelated deserialization error cannot turn an + // untrusted workspace into a trusted one. + let workspace_trust = match options.get("untrustedWorkspace") { + Some(value) => WorkspaceTrust::deserialize(value) + .context("Invalid `untrustedWorkspace` setting")?, + None => WorkspaceTrust::default(), + }; + match serde_json::from_value(options) { - Ok(options) => (options, None), - Err(err) => (InitializationOptions::default(), Some(err)), + Ok(options) => Ok((options, None)), + Err(error) => Ok(( + Self { + workspace_trust, + ..Self::default() + }, + Some(error), + )), } } + + pub(crate) fn use_uv(&self, system: &dyn System) -> UseUv { + if self.workspace_trust == WorkspaceTrust::Untrusted { + return UseUv::Off; + } + + self.options + .global + .experimental + .as_ref() + .and_then(|experimental| experimental.use_uv) + .unwrap_or_else(|| UseUv::from_system(system)) + } } /// Options that configure the behavior of the language server. @@ -493,15 +535,19 @@ impl Combine for DiagnosticMode { #[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] -#[expect( - clippy::empty_structs_with_brackets, - reason = "The LSP fails to deserialize the options when this is a unit type" -)] -pub struct Experimental {} +pub struct Experimental { + /// Controls which uv integrations ty uses. + /// + /// This setting is resolved during initialization. Changing it requires restarting the server. + /// All uv integrations are disabled in untrusted workspaces. + pub use_uv: Option, +} impl Experimental { - #[expect(clippy::unused_self)] fn into_settings(self) -> ExperimentalSettings { + // `use_uv` is resolved separately before project discovery because changing it requires + // rebuilding every project database. + let Self { use_uv: _ } = self; ExperimentalSettings {} } } diff --git a/crates/ty_server/src/system.rs b/crates/ty_server/src/system.rs index aaa156f156..b5fd544d30 100644 --- a/crates/ty_server/src/system.rs +++ b/crates/ty_server/src/system.rs @@ -3,6 +3,7 @@ use std::fmt; use std::fmt::Display; use std::hash::{DefaultHasher, Hash, Hasher as _}; use std::panic::RefUnwindSafe; +use std::process::Output; use std::sync::Arc; use crate::Db; @@ -13,11 +14,12 @@ use ruff_db::file_revision::FileRevision; use ruff_db::files::{File, FilePath}; use ruff_db::system::walk_directory::WalkDirectoryBuilder; use ruff_db::system::{ - CommandExecutor, DirectoryEntry, FileType, Metadata, Result, System, SystemPath, SystemPathBuf, - SystemVirtualPath, SystemVirtualPathBuf, WhichResult, WritableSystem, + Command, CommandExecutor, DirectoryEntry, FileType, Metadata, Result, System, SystemPath, + SystemPathBuf, SystemVirtualPath, SystemVirtualPathBuf, WhichResult, WritableSystem, }; use ruff_notebook::{Notebook, NotebookError}; use ruff_python_ast::PySourceType; +use serde::{Deserialize, Deserializer}; use ty_ide::cached_vendored_path; /// Returns a [`Uri`] for the given [`File`]. @@ -82,16 +84,20 @@ pub(crate) struct LSPSystem { /// This is used to delegate method calls that are not handled by the LSP system. It is also /// used as a fallback when the documents are not found in the LSP index. native_system: Arc, + + workspace_trust: WorkspaceTrust, } impl LSPSystem { pub(crate) fn new( index: Arc, native_system: Arc, + workspace_trust: WorkspaceTrust, ) -> Self { Self { index: Some(index), native_system, + workspace_trust, } } @@ -282,7 +288,10 @@ impl System for LSPSystem { } fn command_executor(&self) -> Option<&dyn CommandExecutor> { - self.native_system.command_executor() + match self.workspace_trust { + WorkspaceTrust::Trusted => self.native_system.command_executor(), + WorkspaceTrust::Untrusted => Some(&UntrustedWorkspaceExecutor), + } } fn dyn_clone(&self) -> Box { @@ -290,6 +299,43 @@ impl System for LSPSystem { } } +/// Whether the client trusts the files in the workspace. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum WorkspaceTrust { + #[default] + Trusted, + Untrusted, +} + +impl<'de> Deserialize<'de> for WorkspaceTrust { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + // The LSP option is `untrustedWorkspace`, so `true` means untrusted. + Ok(match Option::::deserialize(deserializer)? { + Some(true) => Self::Untrusted, + Some(false) | None => Self::Trusted, + }) + } +} + +/// Rejects commands without retaining the LSP document index. +struct UntrustedWorkspaceExecutor; + +impl CommandExecutor for UntrustedWorkspaceExecutor { + fn execute(&self, _command: Command) -> Result { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "external commands are disabled in an untrusted workspace", + )) + } + + fn dyn_clone(&self) -> Box { + Box::new(Self) + } +} + fn not_a_text_document(path: impl Display) -> std::io::Error { std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/crates/ty_server/tests/e2e/code_actions.rs b/crates/ty_server/tests/e2e/code_actions.rs index 845469ff77..68bdcef8b9 100644 --- a/crates/ty_server/tests/e2e/code_actions.rs +++ b/crates/ty_server/tests/e2e/code_actions.rs @@ -82,6 +82,41 @@ unused-ignore-comment = \"warn\" Ok(()) } +#[test] +fn code_action_unsafe_fix() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let foo = SystemPath::new("src/foo.py"); + // Removing the suppression is unsafe because it would activate `fmt: off`. + let foo_content = "\ +# ty: ignore[division-by-zero] # fmt: off +x = 20 / 2 +"; + + let ty_toml = SystemPath::new("ty.toml"); + let ty_toml_content = "\ +[rules] +unused-ignore-comment = \"warn\" +"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(ty_toml, ty_toml_content)? + .with_file(foo, foo_content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(foo, foo_content, 1); + + let diagnostics = server.document_diagnostic_request(foo, None); + let code_action_params = code_actions_at(&server, diagnostics, foo, full_range(foo_content)); + let code_action_id = server.send_request::(code_action_params); + let code_actions = server.await_response::(&code_action_id); + + insta::assert_json_snapshot!(code_actions); + + Ok(()) +} + #[test] fn no_code_action_for_non_overlapping_range_on_same_line() -> Result<()> { let workspace_root = SystemPath::new("src"); diff --git a/crates/ty_server/tests/e2e/commands.rs b/crates/ty_server/tests/e2e/commands.rs index fad94a7f0e..666b4ee268 100644 --- a/crates/ty_server/tests/e2e/commands.rs +++ b/crates/ty_server/tests/e2e/commands.rs @@ -56,16 +56,26 @@ python-platform = \"linux\" let (before_structs, salsa_structs) = response .split_once("=======SALSA STRUCTS=======\n") .context("debug response missing Salsa structs section")?; - let (salsa_structs, after_structs) = salsa_structs + let (salsa_structs, salsa_queries) = salsa_structs .split_once("=======SALSA QUERIES=======\n") .context("debug response missing Salsa queries section")?; + let (salsa_queries, summary) = salsa_queries + .split_once("=======SALSA SUMMARY=======\n") + .context("debug response missing Salsa summary section")?; - // The production report orders structs by memory usage, which varies between platforms. + // Memory usage varies between platforms and build profiles. Sort entries by name instead. let mut salsa_structs = salsa_structs.lines().collect::>(); salsa_structs.sort_unstable(); + let query_lines = salsa_queries.lines().collect::>(); + let mut salsa_queries = query_lines + .chunks(2) + .map(|query| query.join("\n")) + .collect::>(); + salsa_queries.sort_unstable(); let response = format!( - "{before_structs}=======SALSA STRUCTS=======\n{}\n=======SALSA QUERIES=======\n{after_structs}", - salsa_structs.join("\n") + "{before_structs}=======SALSA STRUCTS=======\n{}\n=======SALSA QUERIES=======\n{}\n=======SALSA SUMMARY=======\n{summary}", + salsa_structs.join("\n"), + salsa_queries.join("\n") ); let mut settings = insta::Settings::clone_current(); diff --git a/crates/ty_server/tests/e2e/completions.rs b/crates/ty_server/tests/e2e/completions.rs index b0910dcdd4..2d88904748 100644 --- a/crates/ty_server/tests/e2e/completions.rs +++ b/crates/ty_server/tests/e2e/completions.rs @@ -15,7 +15,7 @@ walktr "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -64,7 +64,7 @@ walktr "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -93,7 +93,7 @@ complete_parenth "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .enable_completion_snippets(true) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? @@ -131,7 +131,7 @@ complete_parenth let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_complete_function_parentheses(true), + &ClientOptions::default().with_complete_function_parentheses(true), ) .enable_completion_snippets(true) .with_trigger_parameter_hints_command() @@ -178,7 +178,7 @@ complete_parenth let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_complete_function_parentheses(true), + &ClientOptions::default().with_complete_function_parentheses(true), ) .enable_completion_snippets(true) .with_workspace(workspace_root, None)? @@ -217,7 +217,7 @@ complete_parenth let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_complete_function_parentheses(true), + &ClientOptions::default().with_complete_function_parentheses(true), ) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? @@ -254,7 +254,7 @@ is_typedd let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_complete_function_parentheses(true), + &ClientOptions::default().with_complete_function_parentheses(true), ) .enable_completion_snippets(true) .with_trigger_parameter_hints_command() @@ -326,7 +326,7 @@ TypedDi "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -494,7 +494,7 @@ TypedDi "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -692,7 +692,7 @@ re.match('', '', fla "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -765,7 +765,7 @@ x: Literal[\"apple\"] = \"app\" "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -805,7 +805,7 @@ zqzqzq = Thing() "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -870,7 +870,7 @@ x: Literal[\"apple\"] = \"app\" "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -913,7 +913,7 @@ take({\"\"}) "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_auto_import(false)) + .with_initialization_options(&ClientOptions::default().with_auto_import(false)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() diff --git a/crates/ty_server/tests/e2e/data_flow.rs b/crates/ty_server/tests/e2e/data_flow.rs index b2f348e8ac..6f18968f4c 100644 --- a/crates/ty_server/tests/e2e/data_flow.rs +++ b/crates/ty_server/tests/e2e/data_flow.rs @@ -43,7 +43,7 @@ fn an_observation_sent_as_json_settles_a_branch() -> Result<()> { let foo = SystemPath::new("src/foo.py"); let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, CONTENT)? .build() @@ -88,7 +88,7 @@ fn the_same_request_with_nothing_observed_settles_nothing() -> Result<()> { let foo = SystemPath::new("src/foo.py"); let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, CONTENT)? .build() @@ -133,7 +133,7 @@ fn the_value_a_name_will_hold_crosses_the_wire_with_its_own_kind() -> Result<()> let foo = SystemPath::new("src/foo.by"); let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, PRICE)? .build() diff --git a/crates/ty_server/tests/e2e/goto_definition.rs b/crates/ty_server/tests/e2e/goto_definition.rs new file mode 100644 index 0000000000..f457a84835 --- /dev/null +++ b/crates/ty_server/tests/e2e/goto_definition.rs @@ -0,0 +1,510 @@ +use anyhow::Result; +use lsp_types::Position; +use ruff_db::system::SystemPath; + +use crate::TestServerBuilder; + +#[test] +fn script_search_paths_resolve_imported_symbols() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let dependency = SystemPath::new("src/dependencies/dependency.py"); + + let script = SystemPath::new("src/script.py"); + let script_content = r#"# /// script +# [tool.ty.environment] +# extra-paths = ["./dependencies"] +# /// + +from dependency import script_only +"#; + + let ordinary = SystemPath::new("src/ordinary.py"); + let ordinary_content = "from dependency import script_only\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(dependency, "def script_only() -> None: ...\n")? + .with_file(script, script_content)? + .with_file(ordinary, ordinary_content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, script_content, 1); + server.open_text_document(ordinary, ordinary_content, 1); + + let script_definition = server.goto_definition_request(script, Position::new(5, 24)); + insta::assert_json_snapshot!(script_definition, @r#" + [ + { + "uri": "file:///src/dependencies/dependency.py", + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 15 + } + } + } + ] + "#); + + let ordinary_definition = server.goto_definition_request(ordinary, Position::new(0, 24)); + insta::assert_json_snapshot!(ordinary_definition, @"null"); + + Ok(()) +} + +#[cfg(feature = "test-uv")] +mod uv_metadata { + use anyhow::{Context, Result, anyhow}; + use lsp_types::{ + Code, Definition, DefinitionResponse, FileChangeType, FileEvent, Position, + TextDocumentContentChangeEvent, TextDocumentContentChangeWholeDocument, + WorkspaceDocumentDiagnosticReport, + }; + use ruff_db::system::{SystemPath, SystemPathBuf}; + use ty_project::UseUv; + use ty_server::{ClientOptions, DiagnosticMode}; + + use crate::TestServerBuilder; + + #[test] + fn synchronization_reports_progress_before_resolving_dependency_definitions() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +from attrs import define +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .with_real_uv(UseUv::Scripts)? + .enable_work_done_progress(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + let end = server.assert_work_done_progress("Synchronizing scripts")?; + assert_eq!( + end.message.as_deref(), + Some("Finished synchronizing scripts") + ); + + let definition = server + .goto_definition_request(script, Position::new(4, 18)) + .context("expected attrs.define to resolve")?; + let DefinitionResponse::Definition(Definition::LocationList(locations)) = definition else { + return Err(anyhow!("expected dependency definition locations")); + }; + let location = locations + .first() + .context("expected attrs.define definition location")?; + let path = location + .uri + .to_file_path() + .map_err(|()| anyhow!("expected dependency definition to have a file URI"))?; + let dependency = SystemPathBuf::from_path_buf(path) + .map_err(|path| anyhow!("dependency path is not valid UTF-8: {}", path.display()))?; + let source = std::fs::read_to_string(dependency.as_std_path())?; + let (line, import) = source + .lines() + .enumerate() + .find(|(_, line)| line.starts_with("from attr import Attribute ")) + .with_context(|| { + format!("expected attrs to import attr.Attribute in `{dependency}`") + })?; + let character = import + .find("Attribute") + .context("expected attrs to import Attribute")?; + let position = Position::new(u32::try_from(line)?, u32::try_from(character)?); + + server.open_text_document(&dependency, &source, 1); + assert!( + server + .goto_definition_request(&dependency, position) + .is_some(), + "expected imports inside the dependency to use the script's environment" + ); + + Ok(()) + } + + #[test] + fn multiple_workspaces_synchronize_independently() -> Result<()> { + let first_workspace = SystemPath::new("first"); + let first_script = SystemPath::new("first/script.py"); + let first_source = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +from attrs import define +from idna import encode +"#; + let second_workspace = SystemPath::new("second"); + let second_script = SystemPath::new("second/script.py"); + let second_source = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['idna==3.10'] +# /// +from idna import encode +from attrs import define +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(first_workspace, None)? + .with_workspace(second_workspace, None)? + .with_file(first_script, first_source)? + .with_file(second_script, second_source)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(first_script, first_source, 1); + server.open_text_document(second_script, second_source, 1); + server.await_diagnostic_refresh(); + server.await_diagnostic_refresh(); + + assert!( + server + .goto_definition_request(first_script, Position::new(4, 18)) + .is_some(), + "expected attrs.define to resolve in the first workspace" + ); + assert!( + server + .goto_definition_request(second_script, Position::new(4, 18)) + .is_some(), + "expected idna.encode to resolve in the second workspace" + ); + assert!( + server + .goto_definition_request(first_script, Position::new(5, 18)) + .is_none(), + "the first workspace must not resolve the second workspace's dependencies" + ); + assert!( + server + .goto_definition_request(second_script, Position::new(5, 18)) + .is_none(), + "the second workspace must not resolve the first workspace's dependencies" + ); + + Ok(()) + } + + #[test] + fn dependencies_resynchronize_after_save() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let initial = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +value = 1 +"#; + let updated = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0', 'idna==3.10'] +# /// +from idna import encode +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, initial)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, initial, 1); + server.await_diagnostic_refresh(); + + server.change_text_document( + script, + vec![ + TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: updated.to_string(), + }, + ), + ], + 2, + ); + + assert!( + server + .goto_definition_request(script, Position::new(4, 19)) + .is_none(), + "unsaved dependency changes must keep the previous environment" + ); + + server.write_file(script, updated)?; + + // A watcher notification can arrive after the file is written but before `didSave`. + // Synchronization must still wait for the save notification. + server.did_change_watched_files(vec![FileEvent { + uri: server.file_uri(script), + kind: FileChangeType::Changed, + }]); + server.await_diagnostic_refresh(); + + assert!( + server + .goto_definition_request(script, Position::new(4, 19)) + .is_none(), + "watcher events must not synchronize open scripts before they are saved" + ); + + server.save_text_document(script); + + server.await_diagnostic_refresh(); + + assert!( + server + .goto_definition_request(script, Position::new(4, 19)) + .is_some(), + "saving must synchronize newly declared script dependencies" + ); + + Ok(()) + } + + #[test] + fn saving_unsaved_open_metadata_repeats_synchronization() -> Result<()> { + let script = SystemPath::new("src/script.py"); + let initial = r#"# /// script +# requires-python = '>=3.12' +# dependencies = [] +# /// +"#; + let updated = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +from attrs import define +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(SystemPath::new("src"), None)? + .with_file(script, initial)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + // Opening synchronizes the backing file, not the unsaved metadata. + server.open_text_document(script, updated, 1); + server.await_diagnostic_refresh(); + assert!( + server + .goto_definition_request(script, Position::new(4, 18)) + .is_none(), + "unsaved dependencies must not be installed" + ); + + server.write_file(script, updated)?; + server.save_text_document(script); + server.await_diagnostic_refresh(); + assert!( + server + .goto_definition_request(script, Position::new(4, 18)) + .is_some(), + "saving must install attrs even though the editor's metadata is unchanged" + ); + + Ok(()) + } + + #[test] + fn workspace_check_does_not_synchronize_unsaved_script_metadata() -> Result<()> { + let script = SystemPath::new("src/script.py"); + let ordinary = "from attrs import define\nprint(define)\n"; + let source = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +from attrs import define +print(define) +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace( + SystemPath::new("src"), + Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)), + )? + .with_file(script, ordinary)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + // A workspace check must not initialize uv from metadata that exists only in the editor. + let diagnostics = server.workspace_diagnostic_request(None, None); + let [WorkspaceDocumentDiagnosticReport::WorkspaceFullDocumentDiagnosticReport(report)] = + diagnostics.items.as_slice() + else { + return Err(anyhow!("expected diagnostics for the unsaved script")); + }; + let [diagnostic] = report.full_document_diagnostic_report.items.as_slice() else { + return Err(anyhow!( + "expected only the unresolved attrs import: {report:?}" + )); + }; + assert_eq!( + diagnostic.code, + Some(Code::String("unresolved-import".to_string())) + ); + + server.write_file(script, source)?; + server.save_text_document(script); + server.await_diagnostic_refresh(); + assert!( + server + .goto_definition_request(script, Position::new(4, 18)) + .is_some(), + "the first save must synchronize the script's dependencies" + ); + + Ok(()) + } + + #[test] + fn dependencies_resolve_after_invalid_metadata_is_corrected() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let initial = r#"# /// script +# requires-python = '>=3.12' +# dependencies = [] +# /// +from attrs import define +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, initial)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, initial, 1); + server.await_diagnostic_refresh(); + + assert!( + server + .goto_definition_request(script, Position::new(4, 18)) + .is_none(), + "attrs should not resolve before it is declared as a dependency" + ); + + let updates = [ + ( + 2, + r#"# /// script +# requires-python = '>=3.12' +# dependencies = [''] +# /// +from attrs import define +"#, + ), + ( + 3, + r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +from attrs import define +"#, + ), + ]; + + for (version, updated) in updates { + server.change_text_document( + script, + vec![ + TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: updated.to_string(), + }, + ), + ], + version, + ); + server.write_file(script, updated)?; + server.save_text_document(script); + server.await_diagnostic_refresh(); + } + + assert!( + server + .goto_definition_request(script, Position::new(4, 18)) + .is_some(), + "correcting invalid script metadata must make newly installed dependencies available" + ); + + Ok(()) + } + + #[test] + fn watched_directory_changes_resynchronize_closed_scripts() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let directory = SystemPath::new("src/scripts"); + let script = SystemPath::new("src/scripts/script.py"); + let initial = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0'] +# /// +value = 1 +"#; + let updated = r#"# /// script +# requires-python = '>=3.12' +# dependencies = ['attrs==25.4.0', 'idna==3.10'] +# /// +from idna import encode +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, initial)? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, initial, 1); + server.await_diagnostic_refresh(); + server.close_text_document(script); + server.write_file(script, updated)?; + + server.did_change_watched_files(vec![FileEvent { + uri: server.file_uri(directory), + kind: FileChangeType::Created, + }]); + + // Watched-file changes refresh diagnostics immediately and again after uv finishes. + server.await_diagnostic_refresh(); + server.await_diagnostic_refresh(); + + server.open_text_document(script, updated, 2); + assert!( + server + .goto_definition_request(script, Position::new(4, 19)) + .is_some(), + "watched changes must update environments even while scripts are closed" + ); + + Ok(()) + } +} diff --git a/crates/ty_server/tests/e2e/hover.rs b/crates/ty_server/tests/e2e/hover.rs index 65669cf9a3..ecf8c6ef7d 100644 --- a/crates/ty_server/tests/e2e/hover.rs +++ b/crates/ty_server/tests/e2e/hover.rs @@ -40,6 +40,202 @@ fn supports_only_plain_text() -> Result<()> { Ok(()) } +#[test] +fn invalid_script_environment_retains_configured_platform_for_hover() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let content = r#"# /// script +# [tool.ty.environment] +# python = "./missing-environment" +# python-version = "3.12" +# python-platform = "win32" +# /// + +import sys +sys.platform +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file( + "src/pyproject.toml", + r#"[tool.ty.environment] +python-platform = "linux" +"#, + )? + .with_file(script, content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, content, 1); + + let hover = server.hover_request(script, Position::new(8, 5)); + insta::assert_json_snapshot!(hover, @r#" + { + "contents": { + "kind": "plaintext", + "value": "Literal[\"win32\"]" + }, + "range": { + "start": { + "line": 8, + "character": 4 + }, + "end": { + "line": 8, + "character": 12 + } + } + } + "#); + + Ok(()) +} + +#[test] +fn invalid_script_retains_valid_environment_settings_for_hover() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let content = r#"# /// script +# requires-python = "<3.12" +# [tool.ty.environment] +# python-platform = "win32" +# /// + +import sys +sys.platform +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file( + "src/pyproject.toml", + r#"[tool.ty.environment] +python-platform = "linux" +"#, + )? + .with_file(script, content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, content, 1); + + let hover = server.hover_request(script, Position::new(7, 5)); + insta::assert_json_snapshot!(hover, @r#" + { + "contents": { + "kind": "plaintext", + "value": "Literal[\"win32\"]" + }, + "range": { + "start": { + "line": 7, + "character": 4 + }, + "end": { + "line": 7, + "character": 12 + } + } + } + "#); + + Ok(()) +} + +#[test] +fn shared_import_hover_uses_each_script_python_version() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let shared = SystemPath::new("src/shared.py"); + let older = SystemPath::new("src/older.py"); + let newer = SystemPath::new("src/newer.py"); + let shared_content = "\ +import sys + +if sys.version_info >= (3, 13): + value = 13 +else: + value = 12 +"; + let older_content = r#"# /// script +# requires-python = ">=3.12" +# [tool.ty.environment] +# extra-paths = ["."] +# /// + +from shared import value +value +"#; + let newer_content = r#"# /// script +# requires-python = ">=3.13" +# [tool.ty.environment] +# extra-paths = ["."] +# /// + +from shared import value +value +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file( + "src/pyproject.toml", + r#"[tool.ty.environment] +python-version = "3.12" +"#, + )? + .with_file(shared, shared_content)? + .with_file(older, older_content)? + .with_file(newer, newer_content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(older, older_content, 1); + server.open_text_document(newer, newer_content, 1); + + let older_hover = server.hover_request(older, Position::new(7, 1)); + insta::assert_json_snapshot!(older_hover, @r#" + { + "contents": { + "kind": "plaintext", + "value": "Literal[12]" + }, + "range": { + "start": { + "line": 7, + "character": 0 + }, + "end": { + "line": 7, + "character": 5 + } + } + } + "#); + + let newer_hover = server.hover_request(newer, Position::new(7, 1)); + insta::assert_json_snapshot!(newer_hover, @r#" + { + "contents": { + "kind": "plaintext", + "value": "Literal[13]" + }, + "range": { + "start": { + "line": 7, + "character": 0 + }, + "end": { + "line": 7, + "character": 5 + } + } + } + "#); + + Ok(()) +} + fn hover_content_format(formats: Vec) -> Result { let workspace_root = SystemPath::new("src"); let document_path = SystemPath::new("src/foo.py"); diff --git a/crates/ty_server/tests/e2e/initialize.rs b/crates/ty_server/tests/e2e/initialize.rs index 8e5600e608..803070e93f 100644 --- a/crates/ty_server/tests/e2e/initialize.rs +++ b/crates/ty_server/tests/e2e/initialize.rs @@ -1,12 +1,59 @@ use anyhow::Result; -use lsp_types::ShowMessageNotification; -use lsp_types::{Position, RegistrationRequest}; +use lsp_types::{ + Code, Position, PublishDiagnosticsNotification, RegistrationRequest, ShowMessageNotification, +}; use ruff_db::system::SystemPath; -use serde_json::Value; +use serde_json::{Value, json}; use ty_server::{ClientOptions, DiagnosticMode}; use crate::TestServerBuilder; +#[test] +#[should_panic(expected = "Invalid initialization options: Invalid `untrustedWorkspace` setting")] +fn malformed_trust_rejects_initialization() { + TestServerBuilder::new() + .expect("Failed to create test server builder") + .with_raw_initialization_options(json!({ + "untrustedWorkspace": "true", + "logLevel": "invalid", + })) + .build(); +} + +#[test] +fn malformed_initialization_options_preserve_workspace_trust() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = "# /// script\n# dependencies = []\n# ///\nmissing\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .with_raw_initialization_options(json!({ + "untrustedWorkspace": true, + "diagnosticMode": "invalid", + })) + .with_env_var("TY_UV", "true") + .with_env_var("UV", "missing-ty-script-uv-executable") + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + let diagnostics = server.await_notification::(); + assert_eq!(diagnostics.uri, server.file_uri(script)); + assert_eq!( + diagnostics + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_ref()) + .collect::>(), + [Some(&Code::String("unresolved-reference".to_string()))], + ); + Ok(()) +} + #[test] fn empty_workspace_folders() -> Result<()> { let server = TestServerBuilder::new()? @@ -43,7 +90,7 @@ fn workspace_diagnostic_registration_without_configuration() -> Result<()> { let workspace_root = SystemPath::new("foo"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_workspace(workspace_root, None)? .enable_workspace_configuration(false) @@ -86,7 +133,7 @@ fn open_files_diagnostic_registration_without_configuration() -> Result<()> { let workspace_root = SystemPath::new("foo"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::OpenFilesOnly), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::OpenFilesOnly), ) .with_workspace(workspace_root, None)? .enable_workspace_configuration(false) @@ -128,7 +175,7 @@ fn workspace_diagnostic_registration_via_initialization() -> Result<()> { let workspace_root = SystemPath::new("foo"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_workspace(workspace_root, None)? .enable_diagnostic_dynamic_registration(true) @@ -167,7 +214,7 @@ fn open_files_diagnostic_registration_via_initialization() -> Result<()> { let workspace_root = SystemPath::new("foo"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::OpenFilesOnly), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::OpenFilesOnly), ) .with_workspace(workspace_root, None)? .enable_diagnostic_dynamic_registration(true) @@ -288,7 +335,7 @@ def foo() -> str: "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default().with_disable_language_services(true)) + .with_initialization_options(&ClientOptions::default().with_disable_language_services(true)) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() @@ -390,7 +437,7 @@ fn unknown_initialization_options() -> Result<()> { let mut server = TestServerBuilder::new()? .with_workspace(workspace_root, None)? .with_initialization_options( - ClientOptions::default().with_unknown([("bar".to_string(), Value::Null)].into()), + &ClientOptions::default().with_unknown([("bar".to_string(), Value::Null)].into()), ) .build() .wait_until_workspaces_are_initialized(); @@ -445,7 +492,7 @@ fn register_multiple_capabilities() -> Result<()> { let mut server = TestServerBuilder::new()? .with_workspace(workspace_root, None)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .enable_diagnostic_dynamic_registration(true) .build() diff --git a/crates/ty_server/tests/e2e/injections.rs b/crates/ty_server/tests/e2e/injections.rs index 0b3ae7b892..2e72a0da3c 100644 --- a/crates/ty_server/tests/e2e/injections.rs +++ b/crates/ty_server/tests/e2e/injections.rs @@ -77,7 +77,7 @@ fn injections_in(content: &str) -> Result> { let main = SystemPath::new("src/main.by"); let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(main, content)? .build() @@ -215,7 +215,7 @@ fn a_fragment_opened_as_its_own_document_is_checked_like_any_other() -> Result<( let host = "# language=basedpython\nsnippet = \"\"\"\nx: int = \"no\"\n\"\"\"\n"; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(main, host)? .build() @@ -337,7 +337,7 @@ def build(): "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(main, host)? .build() diff --git a/crates/ty_server/tests/e2e/inlay_hints.rs b/crates/ty_server/tests/e2e/inlay_hints.rs index c2c5961dec..6216106ffe 100644 --- a/crates/ty_server/tests/e2e/inlay_hints.rs +++ b/crates/ty_server/tests/e2e/inlay_hints.rs @@ -27,7 +27,7 @@ y = foo(Thing()) "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .enable_inlay_hints(true) @@ -147,7 +147,7 @@ def f(x: int) -> None: "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .enable_inlay_hints(true) @@ -191,7 +191,7 @@ fn variable_inlay_hints_disabled() -> Result<()> { let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_variable_types_inlay_hints(false), + &ClientOptions::default().with_variable_types_inlay_hints(false), ) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? @@ -277,7 +277,7 @@ def get_a() -> A: "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .with_file(bar, bar_content)? diff --git a/crates/ty_server/tests/e2e/main.rs b/crates/ty_server/tests/e2e/main.rs index 87e9539bbe..9fd0f83d54 100644 --- a/crates/ty_server/tests/e2e/main.rs +++ b/crates/ty_server/tests/e2e/main.rs @@ -35,6 +35,7 @@ mod configuration; mod data_flow; mod django_templates; mod folding_range; +mod goto_definition; mod hover; mod implementation; mod initialize; @@ -44,6 +45,7 @@ mod notebook; mod publish_diagnostics; mod pull_diagnostics; mod rename; +mod script_preparation; mod semantic_tokens; mod signature_help; mod transpile; @@ -64,7 +66,8 @@ use insta::internals::SettingsBindDropGuard; use lsp_server::{Connection, Message, RequestId, Response, ResponseError}; use lsp_types::{ ClientCapabilities, CompletionItem, CompletionParams, CompletionRequest, CompletionResponse, - CompletionTriggerKind, ConfigurationParams, ConfigurationRequest, DiagnosticClientCapabilities, + CompletionTriggerKind, ConfigurationParams, ConfigurationRequest, DefinitionParams, + DefinitionRequest, DefinitionResponse, DiagnosticClientCapabilities, DidChangeTextDocumentNotification, DidChangeTextDocumentParams, DidChangeWatchedFilesClientCapabilities, DidChangeWatchedFilesNotification, DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersNotification, @@ -84,9 +87,13 @@ use lsp_types::{ WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticRequest, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceFoldersInitializeParams, }; -use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, TestSystem}; +#[cfg(feature = "test-uv")] +use ruff_db::system::System as _; +use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, SystemVirtualPath, TestSystem}; use rustc_hash::FxHashMap; +use serde_json::{Value, json}; use tempfile::TempDir; +use ty_project::UseUv; use ty_server::{ClientOptions, LogLevel, Server, init_logging}; /// Number of times to retry receiving a message before giving up @@ -217,7 +224,7 @@ impl TestServer { workspaces: Vec<(WorkspaceFolder, Option)>, test_context: TestContext, capabilities: ClientCapabilities, - initialization_options: Option, + initialization_options: Option, env_vars: Vec<(String, Option)>, ) -> Self { setup_tracing(); @@ -283,26 +290,18 @@ impl TestServer { } /// Perform LSP initialization handshake - /// - /// # Panics - /// - /// If the `initialization_options` cannot be serialized to JSON fn initialize( mut self, workspace_folders: Vec, capabilities: ClientCapabilities, - initialization_options: Option, + initialization_options: Option, ) -> Self { let init_params = InitializeParams { capabilities, workspace_folders_initialize_params: WorkspaceFoldersInitializeParams { workspace_folders: Some(workspace_folders.into()), }, - initialization_options: initialization_options.map(|options| { - serde_json::to_value(options) - .context("Failed to serialize initialization options to `ClientOptions`") - .unwrap() - }), + initialization_options, ..Default::default() }; @@ -605,6 +604,42 @@ impl TestServer { } } + /// Wait for and acknowledge a server-requested diagnostic refresh. + pub(crate) fn await_diagnostic_refresh(&mut self) { + let (id, ()) = self.await_request::(); + self.send(Message::Response(Response::new_ok(id, ()))); + } + + /// Checks server-created progress with matching begin, report, and end notifications. + #[cfg(feature = "test-uv")] + #[track_caller] + pub(crate) fn assert_work_done_progress( + &mut self, + expected_title: &str, + ) -> Result { + let (request_id, progress) = + self.await_request::(); + self.send(Message::Response(Response::new_ok(request_id, ()))); + + let begin = self.await_notification::(); + assert_eq!(begin.token, progress.token); + assert_eq!(begin.value["kind"], "begin"); + let begin: lsp_types::WorkDoneProgressBegin = serde_json::from_value(begin.value)?; + assert_eq!(begin.title, expected_title); + + loop { + let notification = self.await_notification::(); + assert_eq!(notification.token, progress.token); + if notification.value["kind"] == "report" { + let _: lsp_types::WorkDoneProgressReport = + serde_json::from_value(notification.value)?; + } else { + assert_eq!(notification.value["kind"], "end"); + return Ok(serde_json::from_value(notification.value)?); + } + } + } + /// Wait for a request of the specified type from the server and return the request ID and /// parameters. /// @@ -813,10 +848,32 @@ impl TestServer { content: impl AsRef, version: i32, language_id: LanguageKind, + ) { + self.open_text_document_with_uri(self.file_uri(path), content, version, language_id); + } + + /// Send a `textDocument/didOpen` notification for an unsaved virtual document. + pub(crate) fn open_virtual_text_document( + &mut self, + path: impl AsRef, + content: impl AsRef, + version: i32, + ) -> Result<()> { + let uri = Uri::parse(path.as_ref().as_str())?; + self.open_text_document_with_uri(uri, content, version, LanguageKind::Python); + Ok(()) + } + + fn open_text_document_with_uri( + &mut self, + uri: Uri, + content: impl AsRef, + version: i32, + language_id: LanguageKind, ) { let params = DidOpenTextDocumentParams { text_document: TextDocumentItem { - uri: self.file_uri(path), + uri, language_id, version, text: content.as_ref().to_string(), @@ -971,6 +1028,26 @@ impl TestServer { self.await_response::(&id) } + /// Send a `textDocument/definition` request for the document at the given path and position. + pub(crate) fn goto_definition_request( + &mut self, + path: impl AsRef, + position: Position, + ) -> Option { + let params = DefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: self.file_uri(path), + }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let id = self.send_request::(params); + self.await_response::(&id) + } + /// Send a `textDocument/hover` request for the document at the given path and position. pub(crate) fn hover_request( &mut self, @@ -1125,6 +1202,12 @@ impl fmt::Debug for TestServer { impl Drop for TestServer { fn drop(&mut self) { + // If initialization panicked, there is no running session to shut down. Trying to send + // another request could panic again while the test is already unwinding. + if self.initialize_response.is_none() { + return; + } + self.drain_messages(); // Follow the LSP protocol to shutdown the server gracefully. @@ -1193,7 +1276,7 @@ impl Drop for TestServer { pub(crate) struct TestServerBuilder { test_context: TestContext, workspaces: Vec<(WorkspaceFolder, Option)>, - initialization_options: Option, + initialization_options: Option, client_capabilities: ClientCapabilities, env_vars: Vec<(String, Option)>, } @@ -1236,12 +1319,31 @@ impl TestServerBuilder { }) } - /// Set the initial client options for the test server - pub(crate) fn with_initialization_options(mut self, options: ClientOptions) -> Self { + /// Set the initial client options for the test server. + pub(crate) fn with_initialization_options(self, options: &ClientOptions) -> Self { + self.with_raw_initialization_options(json!(options)) + } + + /// Set raw initialization JSON for malformed or startup-only settings. + pub(crate) fn with_raw_initialization_options(mut self, options: Value) -> Self { self.initialization_options = Some(options); self } + /// Enable uv integration using the uv executable on the test process's PATH. + #[cfg(feature = "test-uv")] + pub(crate) fn with_real_uv(self, use_uv: UseUv) -> Result { + let uv = OsSystem::default().which("uv")?; + Ok(self.with_use_uv(use_uv).with_env_var("UV", uv.as_str())) + } + + /// Configure which uv integrations the test server enables. + pub(crate) fn with_use_uv(mut self, use_uv: UseUv) -> Self { + self.initialization_options.get_or_insert_with(|| json!({}))["experimental"]["useUv"] = + json!(use_uv); + self + } + /// Set an environment variable for the test server's system. pub(crate) fn with_env_var( mut self, @@ -1253,6 +1355,7 @@ impl TestServerBuilder { } /// Add a workspace to the test server with the given root path and options. + /// An existing file can also be used to model clients that send file-valued workspace roots. /// /// This option will be used to respond to the `workspace/configuration` request that the /// server will send to the client. @@ -1265,7 +1368,9 @@ impl TestServerBuilder { options: Option, ) -> Result { let workspace_path = self.test_context.root().join(workspace_root); - fs::create_dir_all(workspace_path.as_std_path())?; + if !workspace_path.as_std_path().is_file() { + fs::create_dir_all(workspace_path.as_std_path())?; + } self.workspaces.push(( WorkspaceFolder { @@ -1293,6 +1398,27 @@ impl TestServerBuilder { self } + /// Enable server-requested refreshes for pull diagnostics. + pub(crate) fn enable_workspace_diagnostic_refresh(mut self, enabled: bool) -> Self { + self.client_capabilities + .workspace + .get_or_insert_default() + .diagnostics + .get_or_insert_default() + .refresh_support = Some(enabled); + self + } + + /// Enable server-created work-done progress. + #[cfg(feature = "test-uv")] + pub(crate) fn enable_work_done_progress(mut self, enabled: bool) -> Self { + self.client_capabilities + .window + .get_or_insert_default() + .work_done_progress = Some(enabled); + self + } + /// Enable or disable dynamic registration of diagnostics capability pub(crate) fn enable_diagnostic_dynamic_registration(mut self, enabled: bool) -> Self { self.client_capabilities diff --git a/crates/ty_server/tests/e2e/notebook.rs b/crates/ty_server/tests/e2e/notebook.rs index 105af127f1..9289a1f786 100644 --- a/crates/ty_server/tests/e2e/notebook.rs +++ b/crates/ty_server/tests/e2e/notebook.rs @@ -59,6 +59,52 @@ type Style = Literal["italic", "bold", "underline"]"#, Ok(()) } +#[test] +fn pull_diagnostics_for_notebook_cells() -> anyhow::Result<()> { + let mut server = TestServerBuilder::new()? + .enable_pull_diagnostics(true) + .build() + .wait_until_workspaces_are_initialized(); + + let mut builder = NotebookBuilder::virtual_file("test.ipynb"); + let first_cell = builder.add_python_cell("value: str = 1\n"); + let second_cell = builder.add_python_cell("def example():\n unused = 1\n return 0\n"); + let third_cell = builder.add_python_cell("value.upper()\n"); + + builder.open(&mut server); + server.collect_publish_diagnostic_notifications(3); + + let diagnostics = [first_cell, second_cell, third_cell] + .into_iter() + .map(|uri| { + let id = server.send_request::( + lsp_types::DocumentDiagnosticParams { + text_document: TextDocumentIdentifier { uri }, + identifier: Some("ty".to_string()), + previous_result_id: None, + work_done_progress_params: lsp_types::WorkDoneProgressParams::default(), + partial_result_params: lsp_types::PartialResultParams::default(), + }, + ); + + match server.await_response::(&id) { + lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport( + report, + ) => report.full_document_diagnostic_report.items, + lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport( + _, + ) => { + panic!("Expected a full diagnostic report") + } + } + }) + .collect::>(); + + assert_json_snapshot!(diagnostics); + + Ok(()) +} + #[test] fn publish_unused_binding_diagnostics_open() -> anyhow::Result<()> { let mut server = TestServerBuilder::new()? diff --git a/crates/ty_server/tests/e2e/publish_diagnostics.rs b/crates/ty_server/tests/e2e/publish_diagnostics.rs index 9b0ac72ffa..f68a2a384a 100644 --- a/crates/ty_server/tests/e2e/publish_diagnostics.rs +++ b/crates/ty_server/tests/e2e/publish_diagnostics.rs @@ -8,7 +8,7 @@ use lsp_types::{ TextDocumentContentChangePartial, TextDocumentContentChangeWholeDocument, TextDocumentItem, Uri, }; -use ruff_db::system::SystemPath; +use ruff_db::system::{SystemPath, SystemVirtualPath}; use ty_server::ClientOptions; use crate::notebook::NotebookBuilder; @@ -142,7 +142,7 @@ def foo() -> str: #[test] fn on_did_open_non_existing_file_workspace_with_untitled_uri() -> Result<()> { let workspace_root = SystemPath::new("src"); - let foo = SystemPath::new("src/foo.py"); + let foo = SystemVirtualPath::new("untitled:foo.py"); let foo_content = "\ def foo() -> str: return 42 @@ -159,17 +159,7 @@ def foo() -> str: .build() .wait_until_workspaces_are_initialized(); - server.send_notification::(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: { - let uri = server.file_uri(foo); - Uri::parse(&format!("untitled://{}", uri.path())).unwrap() - }, - language_id: LanguageKind::Python, - version: 1, - text: foo_content.to_string(), - }, - }); + server.open_virtual_text_document(foo, foo_content, 1)?; let diagnostics = server.await_notification::(); insta::assert_debug_snapshot!(diagnostics); @@ -245,6 +235,236 @@ def foo() -> str: Ok(()) } +#[test] +fn on_did_open_invalid_script_reports_only_configuration_diagnostics() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let content = r#"# /// script +# requires-python = +# /// + +def function(): + unused = 1 + return missing +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, content)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, content, 1); + + let diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(diagnostics); + + Ok(()) +} + +#[test] +fn on_did_change_invalid_script_metadata_restores_semantic_diagnostics() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let invalid = r#"# /// script +# requires-python = +# /// + +missing +"#; + let valid = r#"# /// script +# requires-python = ">=3.12" +# /// + +missing +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, invalid)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, invalid, 1); + let initial = server.await_notification::(); + insta::assert_debug_snapshot!(initial); + + server.change_text_document( + script, + vec![ + lsp_types::TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: valid.to_string(), + }, + ), + ], + 2, + ); + + let updated = server.await_notification::(); + insta::assert_debug_snapshot!(updated); + + Ok(()) +} + +#[test] +fn on_did_change_script_python_requirement() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let initial = r#"# /// script +# requires-python = ">=3.12" +# /// + +PythonFinalizationError +"#; + let updated = r#"# /// script +# requires-python = ">=3.13" +# /// + +PythonFinalizationError +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, initial)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, initial, 1); + let initial_diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(initial_diagnostics); + + server.change_text_document( + script, + vec![ + lsp_types::TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: updated.to_string(), + }, + ), + ], + 2, + ); + + let updated_diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(updated_diagnostics, @r#" + PublishDiagnosticsParams { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/script.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + diagnostics: [], + } + "#); + + Ok(()) +} + +#[test] +fn on_did_open_virtual_script_reports_invalid_metadata() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemVirtualPath::new("untitled:script.py"); + let content = r#"# /// script +# requires-python = +# /// + +missing +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_virtual_text_document(script, content, 1)?; + + let diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(diagnostics); + + Ok(()) +} + +#[test] +fn on_did_open_virtual_script_uses_its_python_requirement() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemVirtualPath::new("untitled:script.py"); + let content = r#"# /// script +# requires-python = ">=3.13" +# /// + +PythonFinalizationError +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_virtual_text_document(script, content, 1)?; + + let diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(diagnostics, @r#" + PublishDiagnosticsParams { + uri: Url { + scheme: "untitled", + cannot_be_a_base: true, + username: "", + password: None, + host: None, + port: None, + path: "script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [], + } + "#); + + Ok(()) +} + +#[test] +fn on_did_open_virtual_script_reports_inline_configuration_diagnostics() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemVirtualPath::new("untitled:script.py"); + let content = r#"# /// script +# [tool.ty.rules] +# unknown-rule = "warn" +# /// +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_virtual_text_document(script, content, 1)?; + + let diagnostics = server.await_notification::(); + insta::assert_debug_snapshot!(diagnostics); + + Ok(()) +} + #[test] fn on_did_save_publishes_open_file_documents() -> Result<()> { let workspace_root = SystemPath::new("src"); @@ -738,3 +958,136 @@ fn collect_publish_diagnostic_notifications_with_versions( results } + +mod uv_metadata { + #[cfg(feature = "test-uv")] + use std::process::Command; + + use anyhow::Result; + use lsp_types::{Code, PublishDiagnosticsNotification}; + use ruff_db::system::SystemPath; + use serde_json::json; + use ty_project::UseUv; + + use crate::TestServerBuilder; + + #[cfg(feature = "test-uv")] + #[test] + fn project_refresh_reports_progress_and_clears_errors() -> Result<()> { + let manifest = r#" +[project] +name = "example" +version = "0.1.0" +requires-python = ">=3.8" +"#; + let mut server = TestServerBuilder::new()? + .with_workspace(SystemPath::new("src"), None)? + .with_file( + "src/pyproject.toml", + format!( + r#"{manifest} +[tool.uv] +package = "invalid" +"# + ), + )? + .with_real_uv(UseUv::On)? + .enable_work_done_progress(true) + .build() + .wait_until_workspaces_are_initialized(); + let event = lsp_types::FileEvent { + uri: server.file_uri("src/pyproject.toml"), + kind: lsp_types::FileChangeType::Changed, + }; + let diagnostics = server.collect_publish_diagnostic_notifications(1); + assert_eq!(diagnostics[&event.uri].len(), 1); + assert_eq!( + diagnostics[&event.uri][0].code, + Some(Code::String("uv-metadata".into())) + ); + + server.write_file("src/pyproject.toml", manifest)?; + let output = Command::new("uv") + .current_dir(server.file_path("src")) + .args(["sync", "--offline"]) + .output()?; + anyhow::ensure!( + output.status.success(), + "uv sync failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + server.did_change_watched_files(vec![event.clone()]); + + server.assert_work_done_progress("Refreshing example metadata")?; + + // The existing warning is republished while the refresh is pending, then cleared. + assert_eq!( + server.collect_publish_diagnostic_notifications(1), + diagnostics + ); + assert!(server.collect_publish_diagnostic_notifications(1)[&event.uri].is_empty()); + Ok(()) + } + + #[test] + fn untrusted_workspace_keeps_semantic_diagnostics() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = "# /// script\n# dependencies = []\n# ///\nmissing\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .with_raw_initialization_options(json!({"untrustedWorkspace": true})) + .with_use_uv(UseUv::On) + .with_env_var("TY_UV", "true") + .with_env_var("UV", "missing-ty-script-uv-executable") + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + // An attempted synchronization would replace this with a `uv-metadata` error. + let diagnostics = server.await_notification::(); + assert_eq!(diagnostics.uri, server.file_uri(script)); + assert_eq!( + diagnostics + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_ref()) + .collect::>(), + [Some(&Code::String("unresolved-reference".to_string()))], + ); + + Ok(()) + } + + #[test] + fn pushed_diagnostics_wait_for_the_initial_environment() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = "# /// script\n# dependencies = []\n# ///\nmissing\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .with_use_uv(UseUv::Scripts) + .with_env_var("UV", "missing-ty-script-uv-executable") + .enable_pull_diagnostics(false) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + let synchronized = server.await_notification::(); + assert!(synchronized.diagnostics.iter().any(|diagnostic| { + diagnostic.code == Some(Code::String("uv-metadata".to_string())) + })); + assert!(!synchronized.diagnostics.iter().any(|diagnostic| { + diagnostic.code == Some(Code::String("unresolved-reference".to_string())) + })); + + Ok(()) + } +} diff --git a/crates/ty_server/tests/e2e/pull_diagnostics.rs b/crates/ty_server/tests/e2e/pull_diagnostics.rs index 746173090e..76b0f801d8 100644 --- a/crates/ty_server/tests/e2e/pull_diagnostics.rs +++ b/crates/ty_server/tests/e2e/pull_diagnostics.rs @@ -350,7 +350,7 @@ def foo( )? .with_file(foo, foo_content)? .with_initialization_options( - ClientOptions::default() + &ClientOptions::default() .with_show_syntax_errors(false) .with_diagnostic_mode(DiagnosticMode::Workspace), ) @@ -693,7 +693,7 @@ def foo() -> str: let mut server = TestServerBuilder::new()? .with_workspace(workspace_root, None)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(file_a, file_a_content)? .with_file(file_b, file_b_content_v1)? @@ -816,7 +816,7 @@ def foo() -> str: .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .build() .wait_until_workspaces_are_initialized(); @@ -906,7 +906,7 @@ def foo() -> str: let mut builder = TestServerBuilder::new()? .with_workspace(workspace_root, None)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ); for i in 0..NUM_FILES { @@ -983,7 +983,7 @@ fn workspace_diagnostic_streaming_with_caching() -> Result<()> { let mut builder = TestServerBuilder::new()? .with_workspace(workspace_root, None)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ); for i in 0..NUM_FILES { @@ -1094,7 +1094,7 @@ fn workspace_diagnostic_streaming_with_caching() -> Result<()> { Ok(()) } -fn sort_workspace_diagnostic_response(response: &mut WorkspaceDiagnosticReport) { +pub(crate) fn sort_workspace_diagnostic_response(response: &mut WorkspaceDiagnosticReport) { sort_workspace_report_items(&mut response.items); } @@ -1417,7 +1417,7 @@ fn create_workspace_server_with_file( .with_workspace(workspace_root, None)? .with_file(file_path, file_content)? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .build() .wait_until_workspaces_are_initialized()) @@ -1504,3 +1504,159 @@ fn extract_result_ids_from_response(response: &WorkspaceDiagnosticReport) -> Vec }) .collect() } + +mod uv_metadata { + use lsp_types::{ + Code, Position, Range, TextDocumentContentChangeEvent, + TextDocumentContentChangeWholeDocument, + }; + use ty_project::UseUv; + + use super::{ + ClientOptions, DiagnosticMode, DocumentDiagnosticReport, Result, SystemPath, + TestServerBuilder, WorkspaceDocumentDiagnosticReport, + }; + + #[test] + fn opening_new_file_updates_workspace_index() -> Result<()> { + let added = SystemPath::new("src/added.py"); + let source = "missing\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace( + SystemPath::new("src"), + Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)), + )? + .with_file(SystemPath::new("src/initial.py"), source)? + .with_use_uv(UseUv::Scripts) + .build() + .wait_until_workspaces_are_initialized(); + + // Build the index before creating the file, without sending a watcher event. + let _ = server.workspace_diagnostic_request(None, None); + server.write_file(added, source)?; + server.open_text_document(added, source, 1); + + let uri = server.file_uri(added); + let diagnostics = server.workspace_diagnostic_request(None, None); + assert!(diagnostics.items.iter().any(|report| matches!( + report, + WorkspaceDocumentDiagnosticReport::WorkspaceFullDocumentDiagnosticReport(report) + if report.uri == uri + ))); + + Ok(()) + } + + #[test] + fn synchronization_failure_highlights_script_metadata() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = + "#!/usr/bin/env python3\n\n# /// script\n# dependencies = []\n# ///\nvalue = 1\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .with_use_uv(UseUv::Scripts) + .with_env_var("UV", "missing-ty-script-uv-executable") + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + server.await_diagnostic_refresh(); + let report = server.document_diagnostic_request(script, None); + let DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) = report else { + anyhow::bail!("expected a full diagnostic report for the script"); + }; + + let diagnostic = report + .full_document_diagnostic_report + .items + .iter() + .find(|diagnostic| diagnostic.code == Some(Code::String("uv-metadata".to_string()))) + .ok_or_else(|| { + anyhow::anyhow!("expected the script synchronization error: {report:?}") + })?; + + assert_eq!( + diagnostic.range, + Range::new(Position::new(2, 0), Position::new(4, 5)) + ); + + Ok(()) + } + + #[test] + fn unsaved_script_uses_its_settings_and_keeps_diagnostics() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let initial = "PythonFinalizationError\nmissing\n"; + let updated = "# /// script\n# requires-python = '>=3.13'\n# dependencies = []\n# ///\nPythonFinalizationError\nmissing\n"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, initial)? + .with_file( + SystemPath::new("src/ty.toml"), + "[environment]\npython-version = '3.12'\n", + )? + .with_use_uv(UseUv::Scripts) + .with_env_var("UV", "missing-ty-script-uv-executable") + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, initial, 1); + + let report = server.document_diagnostic_request(script, None); + let DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) = report else { + anyhow::bail!("expected a full diagnostic report for the ordinary file"); + }; + assert_eq!(report.full_document_diagnostic_report.items.len(), 2); + + server.change_text_document( + script, + vec![ + TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: updated.to_string(), + }, + ), + ], + 2, + ); + + let report = server.document_diagnostic_request(script, None); + let DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) = report else { + anyhow::bail!("expected a full diagnostic report for the provisional script"); + }; + let [diagnostic] = report.full_document_diagnostic_report.items.as_slice() else { + anyhow::bail!("expected only the unresolved `missing` reference"); + }; + assert_eq!( + diagnostic.code, + Some(Code::String("unresolved-reference".to_string())) + ); + assert_eq!(diagnostic.range.start.line, 5); + + server.write_file(script, updated)?; + server.save_text_document(script); + server.await_diagnostic_refresh(); + + let report = server.document_diagnostic_request(script, None); + let DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(report) = report else { + anyhow::bail!("expected a full diagnostic report for the synchronized script"); + }; + assert!( + report + .full_document_diagnostic_report + .items + .iter() + .any(|diagnostic| diagnostic.code == Some(Code::String("uv-metadata".to_string()))) + ); + + Ok(()) + } +} diff --git a/crates/ty_server/tests/e2e/script_preparation.rs b/crates/ty_server/tests/e2e/script_preparation.rs new file mode 100644 index 0000000000..e185bcb5d4 --- /dev/null +++ b/crates/ty_server/tests/e2e/script_preparation.rs @@ -0,0 +1,181 @@ +use anyhow::Result; +use insta::assert_snapshot; +use lsp_types::{ + DidOpenTextDocumentNotification, DidOpenTextDocumentParams, LanguageKind, TextDocumentItem, +}; +use ruff_db::system::SystemPath; +use ruff_python_trivia::textwrap::dedent; +use ty_project::UseUv; +use ty_server::{ClientOptions, DiagnosticMode}; + +use crate::TestServerBuilder; +use crate::workspace_folders::condensed_workspace_diagnostic_snapshot; + +#[test] +#[cfg(feature = "test-uv")] +fn closed_scripts_are_prepared_at_startup() -> Result<()> { + let ordinary = SystemPath::new("src/main.py"); + let script = SystemPath::new("src/script.py"); + let mut server = workspace_builder()? + .with_file(ordinary, "ordinary_missing")? + .with_file( + script, + dedent( + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + missing + "#, + ), + )? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + // Closed scripts are initialized without a document-open notification. + server.await_diagnostic_refresh(); + let report = server.workspace_diagnostic_request(None, None); + assert_snapshot!(condensed_workspace_diagnostic_snapshot(report), @" + file:///src/main.py + 0:0..0:16[ERROR]: Name `ordinary_missing` used when not defined + file:///src/script.py + 5:0..5:7[ERROR]: Name `missing` used when not defined + "); + Ok(()) +} + +#[test] +#[cfg(feature = "test-uv")] +fn created_closed_script_is_prepared_after_a_file_event() -> Result<()> { + let script = SystemPath::new("src/script.py"); + let mut server = workspace_builder()? + .with_real_uv(UseUv::Scripts)? + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + + server.write_file( + script, + dedent( + r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + missing + "#, + ), + )?; + server.did_change_watched_files(vec![lsp_types::FileEvent { + uri: server.file_uri(script), + kind: lsp_types::FileChangeType::Created, + }]); + + // The file event and completed synchronization each refresh diagnostics. + server.await_diagnostic_refresh(); + server.await_diagnostic_refresh(); + let report = server.workspace_diagnostic_request(None, None); + assert_snapshot!(condensed_workspace_diagnostic_snapshot(report), @" + file:///src/script.py + 5:0..5:7[ERROR]: Name `missing` used when not defined + "); + Ok(()) +} + +#[test] +fn exclude_scripts_uses_saved_contents_after_close() -> Result<()> { + let script = SystemPath::new("src/script.py"); + let ordinary = SystemPath::new("src/main.py"); + let unsaved = dedent( + r" + # /// script + # dependencies = [] + # /// + unsaved_missing + ", + ); + let mut server = workspace_builder()? + .with_file( + "src/ty.toml", + r" + [src] + exclude-scripts = true + ", + )? + .with_file(script, "saved_missing\n")? + .with_file(ordinary, "ordinary_missing\n")? + .with_use_uv(UseUv::Off) + .build() + .wait_until_workspaces_are_initialized(); + + // `exclude-scripts` omits this file from workspace diagnostics because its unsaved + // contents contain a PEP 723 block. + server.open_text_document(script, unsaved, 1); + + let report = server.workspace_diagnostic_request(None, None); + assert_snapshot!(condensed_workspace_diagnostic_snapshot(report), @" + file:///src/main.py + 0:0..0:16[ERROR]: Name `ordinary_missing` used when not defined + "); + + // The saved file has no script metadata, so closing without saving includes it in + // workspace diagnostics again. + server.close_text_document(script); + + let report = server.workspace_diagnostic_request(None, None); + assert_snapshot!(condensed_workspace_diagnostic_snapshot(report), @" + file:///src/main.py + 0:0..0:16[ERROR]: Name `ordinary_missing` used when not defined + file:///src/script.py + 0:0..0:13[ERROR]: Name `saved_missing` used when not defined + "); + Ok(()) +} + +#[test] +fn non_python_overlay_does_not_block_workspace_diagnostics() -> Result<()> { + let script = SystemPath::new("src/script.py"); + let main = SystemPath::new("src/main.py"); + let mut server = workspace_builder()? + .with_file(script, "pass\n")? + .with_file(main, "missing\n")? + .with_use_uv(UseUv::Scripts) + .with_env_var("UV", "missing-script-preparation-uv") + .enable_workspace_diagnostic_refresh(true) + .build() + .wait_until_workspaces_are_initialized(); + // This document has an editor overlay, but is not in the diagnostic open-file set. + server.send_notification::(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: server.file_uri(script), + language_id: LanguageKind::Plaintext, + version: 1, + text: dedent( + r" + # /// script + # dependencies = [] + # /// + pass + ", + ) + .into_owned(), + }, + }); + + let report = server.workspace_diagnostic_request(None, None); + assert_snapshot!(condensed_workspace_diagnostic_snapshot(report), @" + file:///src/main.py + 0:0..0:7[ERROR]: Name `missing` used when not defined + "); + Ok(()) +} + +fn workspace_builder() -> Result { + TestServerBuilder::new()?.with_workspace( + SystemPath::new("src"), + Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)), + ) +} diff --git a/crates/ty_server/tests/e2e/semantic_tokens.rs b/crates/ty_server/tests/e2e/semantic_tokens.rs index 3bae7ea29c..305f81a1b1 100644 --- a/crates/ty_server/tests/e2e/semantic_tokens.rs +++ b/crates/ty_server/tests/e2e/semantic_tokens.rs @@ -3,6 +3,62 @@ use ruff_db::system::SystemPath; use crate::TestServerBuilder; +#[test] +fn script_metadata_is_highlighted_as_toml() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let source = r#"#!/usr/bin/env python3 +# /// script +# dependencies = ["httpx"] +# requires-python = ">=3.12" +# /// +value = 1 +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file(script, source)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, source, 1); + + let tokens = server + .semantic_tokens_full_request(&server.file_uri(script)) + .ok_or_else(|| anyhow::anyhow!("expected semantic tokens for the script"))?; + + let actual: Vec<_> = tokens + .data + .into_iter() + .map(|token| { + ( + token.delta_line, + token.delta_start, + token.length, + token.token_type, + ) + }) + .collect(); + + assert_eq!( + actual, + vec![ + (2, 2, 12, ty_ide::SemanticTokenType::Variable as u32), + (0, 13, 1, ty_ide::SemanticTokenType::Operator as u32), + (0, 2, 1, ty_ide::SemanticTokenType::Operator as u32), + (0, 1, 7, ty_ide::SemanticTokenType::String as u32), + (0, 7, 1, ty_ide::SemanticTokenType::Operator as u32), + (1, 2, 15, ty_ide::SemanticTokenType::Variable as u32), + (0, 16, 1, ty_ide::SemanticTokenType::Operator as u32), + (0, 2, 8, ty_ide::SemanticTokenType::String as u32), + (2, 0, 5, ty_ide::SemanticTokenType::Variable as u32), + (0, 8, 1, ty_ide::SemanticTokenType::Number as u32), + ] + ); + + Ok(()) +} + #[test] fn multiline_token_client_not_supporting_multiline_tokens() -> Result<()> { let workspace_root = SystemPath::new("src"); diff --git a/crates/ty_server/tests/e2e/signature_help.rs b/crates/ty_server/tests/e2e/signature_help.rs index 555590d17a..7c12f951cd 100644 --- a/crates/ty_server/tests/e2e/signature_help.rs +++ b/crates/ty_server/tests/e2e/signature_help.rs @@ -20,7 +20,7 @@ re.match('', '') "; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_workspace(workspace_root, None)? .with_file(foo, foo_content)? .build() diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_unsafe_fix.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_unsafe_fix.snap new file mode 100644 index 0000000000..dd6e2c7a06 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_unsafe_fix.snap @@ -0,0 +1,102 @@ +--- +source: crates/ty_server/tests/e2e/code_actions.rs +expression: code_actions +--- +[ + { + "title": "Remove the unused suppression comment", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 31 + } + }, + "severity": 2, + "code": "unused-ignore-comment", + "codeDescription": { + "href": "https://ty.dev/rules#unused-ignore-comment" + }, + "source": "ty", + "message": "Unused `ty: ignore` directive\n\nhelp: Remove the unused suppression comment", + "tags": [ + 1 + ] + } + ], + "isPreferred": false, + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 31 + } + }, + "newText": "" + } + ] + } + } + }, + { + "title": "Ignore 'unused-ignore-comment' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 31 + } + }, + "severity": 2, + "code": "unused-ignore-comment", + "codeDescription": { + "href": "https://ty.dev/rules#unused-ignore-comment" + }, + "source": "ty", + "message": "Unused `ty: ignore` directive\n\nhelp: Remove the unused suppression comment", + "tags": [ + 1 + ] + } + ], + "isPreferred": false, + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 0, + "character": 41 + }, + "end": { + "line": 0, + "character": 41 + } + }, + "newText": " # ty: ignore[unused-ignore-comment]" + } + ] + } + } + } +] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index 08c3fdef4e..1a9a78548f 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -267,15 +267,22 @@ Settings: Settings { Memory report: =======SALSA STRUCTS======= `FileRoot` metadata=[X.XXMB] fields=[X.XXMB] count=1 +`File` metadata=[X.XXMB] fields=[X.XXMB] count=1 `ModuleResolveModeIngredient` metadata=[X.XXMB] fields=[X.XXMB] count=1 `Program` metadata=[X.XXMB] fields=[X.XXMB] count=1 `Project` metadata=[X.XXMB] fields=[X.XXMB] count=1 `ResolverEnvironment` metadata=[X.XXMB] fields=[X.XXMB] count=1 =======SALSA QUERIES======= -`dynamic_resolution_paths -> alloc::vec::Vec` - metadata=[X.XXMB] fields=[X.XXMB] count=1 `Project::program_ -> ty_python_core::program::Program<'_>` metadata=[X.XXMB] fields=[X.XXMB] count=1 +`dynamic_resolution_paths -> alloc::boxed::Box<[ty_module_resolver::path::SearchPath]>` + metadata=[X.XXMB] fields=[X.XXMB] count=1 +`script_tag -> core::option::Option>` + metadata=[X.XXMB] fields=[X.XXMB] count=1 +`site_packages_editables -> alloc::boxed::Box<[ty_module_resolver::resolve::SitePackagesEditables]>` + metadata=[X.XXMB] fields=[X.XXMB] count=1 +`source_text -> ruff_db::source::SourceText` + metadata=[X.XXMB] fields=[X.XXMB] count=1 =======SALSA SUMMARY======= TOTAL MEMORY USAGE: [X.XXMB] struct metadata = [X.XXMB] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap index d80895c5f1..5b7b714e17 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap @@ -94,7 +94,8 @@ expression: initialization_result "builtinConstant", "typeParameter", "comment", - "operator" + "operator", + "regexp" ], "tokenModifiers": [ "definition", diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap index d80895c5f1..5b7b714e17 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap @@ -94,7 +94,8 @@ expression: initialization_result "builtinConstant", "typeParameter", "comment", - "operator" + "operator", + "regexp" ], "tokenModifiers": [ "definition", diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__notebook__pull_diagnostics_for_notebook_cells.snap b/crates/ty_server/tests/e2e/snapshots/e2e__notebook__pull_diagnostics_for_notebook_cells.snap new file mode 100644 index 0000000000..3f8d193af5 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__notebook__pull_diagnostics_for_notebook_cells.snap @@ -0,0 +1,68 @@ +--- +source: crates/ty_server/tests/e2e/notebook.rs +expression: diagnostics +--- +[ + [ + { + "range": { + "start": { + "line": 0, + "character": 13 + }, + "end": { + "line": 0, + "character": 14 + } + }, + "severity": 1, + "code": "invalid-assignment", + "codeDescription": { + "href": "https://ty.dev/rules#invalid-assignment" + }, + "source": "ty", + "message": "Object of type `Literal[1]` is not assignable to `str`" + } + ], + [ + { + "range": { + "start": { + "line": 1, + "character": 4 + }, + "end": { + "line": 1, + "character": 10 + } + }, + "severity": 4, + "source": "ty", + "message": "`unused` is unused", + "tags": [ + 1 + ] + } + ], + [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 13 + } + }, + "severity": 2, + "code": "unused-return-value", + "codeDescription": { + "href": "https://ty.dev/rules#unused-return-value" + }, + "source": "ty", + "message": "The result of this call is unused\n\ninfo: `upper` returns `str`\nhelp: Decorate `upper` with `@ignorable_return_value` if discarding its result is expected" + } + ] +] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics-2.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics-2.snap new file mode 100644 index 0000000000..1ed1f80f2a --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics-2.snap @@ -0,0 +1,72 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: updated +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/script.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 4, + character: 0, + }, + end: Position { + line: 4, + character: 7, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "unresolved-reference", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "unresolved-reference", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: String( + "Name `missing` used when not defined", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics.snap new file mode 100644 index 0000000000..2fbdd39753 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_invalid_script_metadata_restores_semantic_diagnostics.snap @@ -0,0 +1,52 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: initial +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 19, + }, + end: Position { + line: 1, + character: 19, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-script-metadata", + ), + ), + code_description: None, + source: Some( + "ty", + ), + message: String( + "string values must be quoted, expected literal string", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_script_python_requirement.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_script_python_requirement.snap new file mode 100644 index 0000000000..15d27053bc --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_change_script_python_requirement.snap @@ -0,0 +1,72 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: initial_diagnostics +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 4, + character: 0, + }, + end: Position { + line: 4, + character: 23, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "unresolved-reference", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "unresolved-reference", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: String( + "Name `PythonFinalizationError` used when not defined\n\ninfo: `PythonFinalizationError` was added as a builtin in Python 3.13", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_invalid_script_reports_only_configuration_diagnostics.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_invalid_script_reports_only_configuration_diagnostics.snap new file mode 100644 index 0000000000..366bd77181 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_invalid_script_reports_only_configuration_diagnostics.snap @@ -0,0 +1,52 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: diagnostics +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 19, + }, + end: Position { + line: 1, + character: 19, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-script-metadata", + ), + ), + code_description: None, + source: Some( + "ty", + ), + message: String( + "string values must be quoted, expected literal string", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_non_existing_file_workspace_with_untitled_uri.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_non_existing_file_workspace_with_untitled_uri.snap index 54183854d2..355bde4d9f 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_non_existing_file_workspace_with_untitled_uri.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_non_existing_file_workspace_with_untitled_uri.snap @@ -5,12 +5,12 @@ expression: diagnostics PublishDiagnosticsParams { uri: Url { scheme: "untitled", - cannot_be_a_base: false, + cannot_be_a_base: true, username: "", password: None, host: None, port: None, - path: "/src/foo.py", + path: "foo.py", query: None, fragment: None, }, diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_inline_configuration_diagnostics.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_inline_configuration_diagnostics.snap new file mode 100644 index 0000000000..6217c52972 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_inline_configuration_diagnostics.snap @@ -0,0 +1,52 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: diagnostics +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "untitled", + cannot_be_a_base: true, + username: "", + password: None, + host: None, + port: None, + path: "script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 2, + character: 2, + }, + end: Position { + line: 2, + character: 14, + }, + }, + severity: Some( + Warning, + ), + code: Some( + String( + "unknown-rule", + ), + ), + code_description: None, + source: Some( + "ty", + ), + message: String( + "Unknown rule `unknown-rule`. Did you mean `unknown-argument`?", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_invalid_metadata.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_invalid_metadata.snap new file mode 100644 index 0000000000..a48c74443b --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__on_did_open_virtual_script_reports_invalid_metadata.snap @@ -0,0 +1,52 @@ +--- +source: crates/ty_server/tests/e2e/publish_diagnostics.rs +expression: diagnostics +--- +PublishDiagnosticsParams { + uri: Url { + scheme: "untitled", + cannot_be_a_base: true, + username: "", + password: None, + host: None, + port: None, + path: "script.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + diagnostics: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 19, + }, + end: Position { + line: 1, + character: 19, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-script-metadata", + ), + ), + code_description: None, + source: Some( + "ty", + ), + message: String( + "string values must be quoted, expected literal string", + ), + tags: None, + related_information: None, + data: None, + }, + ], +} diff --git a/crates/ty_server/tests/e2e/workspace_folders.rs b/crates/ty_server/tests/e2e/workspace_folders.rs index 874e72a4ab..cd609f6d0c 100644 --- a/crates/ty_server/tests/e2e/workspace_folders.rs +++ b/crates/ty_server/tests/e2e/workspace_folders.rs @@ -11,10 +11,30 @@ use crate::{ TestServer, TestServerBuilder, pull_diagnostics::{ assert_workspace_diagnostics_suspends_for_long_polling, send_workspace_diagnostic_request, - shutdown_and_await_workspace_diagnostic, + shutdown_and_await_workspace_diagnostic, sort_workspace_diagnostic_response, }, }; +/// A file-valued workspace initializes successfully and discovers its parent configuration. +#[test] +fn single_file_workspace() -> Result<()> { + let main = SystemPath::new("project/main.py"); + let mut server = TestServerBuilder::new()? + .with_file(main, "missing")? + .with_file("project/ty.toml", "[rules]\nunresolved-reference = 'warn'")? + .with_workspace(main, None)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(main, "missing", 1); + assert_eq!( + condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)), + "0:0..0:7[WARNING]: Name `missing` used when not defined", + ); + + Ok(()) +} + /// Test that we can initialize multiple workspace folders. #[test] fn initialize_multiple_workspace_folders() -> Result<()> { @@ -22,7 +42,7 @@ fn initialize_multiple_workspace_folders() -> Result<()> { let root2 = SystemPath::new("root2"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -52,7 +72,7 @@ fn add_workspace_folder_after_init() -> Result<()> { let root2 = SystemPath::new("root2"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -95,7 +115,7 @@ fn add_multiple_workspace_folders() -> Result<()> { let root3 = SystemPath::new("root3"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -142,7 +162,7 @@ fn remove_workspace_folder_after_init() -> Result<()> { let root2 = SystemPath::new("root2"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -190,7 +210,7 @@ fn remove_multiple_workspace_folders() -> Result<()> { let root3 = SystemPath::new("root3"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -246,7 +266,7 @@ fn remove_workspace_folder_with_open_document() -> Result<()> { let main2_content = "does_not_exist2()"; let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_file(&main1, main1_content)? .with_file(&main2, main1_content)? .with_workspace(root1, None)? @@ -293,7 +313,7 @@ fn add_and_remove_workspace_folders() -> Result<()> { let root3 = SystemPath::new("root3"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_file(root2.join("main.py"), "does_not_exist()")? @@ -341,7 +361,7 @@ fn add_existing_workspace_folder_is_no_op() -> Result<()> { let root1 = SystemPath::new("root1"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_workspace(root1, None)? @@ -376,7 +396,7 @@ fn remove_only_workspace() -> Result<()> { let root1 = SystemPath::new("root1"); let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(root1.join("main.py"), "does_not_exist()")? .with_workspace(root1, None)? @@ -404,7 +424,7 @@ fn different_settings() -> Result<()> { let mut server = TestServerBuilder::new()? .with_initialization_options( - ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + &ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), ) .with_file(&main1, main_content)? .with_file(&main2, main_content)? @@ -565,7 +585,7 @@ fn global_settings_precedence() -> Result<()> { // which causes it to take precedence and apply even to root1. let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_file(&main1, main_content)? .with_file(&main2, main_content)? .with_workspace(root1, None)? @@ -601,7 +621,7 @@ fn global_settings_precedence() -> Result<()> { // winning out, and we get syntax error diagnostics. let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_file(&main1, main_content)? .with_file(&main2, main_content)? .with_workspace( @@ -650,7 +670,7 @@ fn global_settings_change() -> Result<()> { // we get syntax error diagnostics. let mut server = TestServerBuilder::new()? - .with_initialization_options(ClientOptions::default()) + .with_initialization_options(&ClientOptions::default()) .with_file(&main1, main_content)? .with_file(&main2, main_content)? .with_workspace(root1, None)? @@ -703,7 +723,10 @@ fn global_settings_change() -> Result<()> { /// LSP is correctly recognizing and reporting diagnostics for each /// workspace folder. This isn't really meant to test the diagnostics /// themselves, hence the condensed output. -fn condensed_workspace_diagnostic_snapshot(report: WorkspaceDiagnosticReport) -> String { +pub(crate) fn condensed_workspace_diagnostic_snapshot( + mut report: WorkspaceDiagnosticReport, +) -> String { + sort_workspace_diagnostic_response(&mut report); let items = report.items; items .into_iter() diff --git a/crates/ty_site_packages/Cargo.toml b/crates/ty_site_packages/Cargo.toml index cb95e5ffd3..ab2133ae87 100644 --- a/crates/ty_site_packages/Cargo.toml +++ b/crates/ty_site_packages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_site_packages" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_site_packages/README.md b/crates/ty_site_packages/README.md index c2ce5db95e..4a3711cb55 100644 --- a/crates/ty_site_packages/README.md +++ b/crates/ty_site_packages/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_site_packages). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_site_packages). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_site_packages/src/lib.rs b/crates/ty_site_packages/src/lib.rs index 7ff9a793d7..7988413c18 100644 --- a/crates/ty_site_packages/src/lib.rs +++ b/crates/ty_site_packages/src/lib.rs @@ -10,6 +10,7 @@ mod version; +use std::debug_assert_matches; use std::hash::{Hash, Hasher}; use std::io; use std::num::NonZeroUsize; @@ -134,9 +135,10 @@ impl SitePackagesPaths { .map(|c| { // This should have all been validated in `site_packages.rs` // when we resolved the search paths for the project. - debug_assert!( - matches!(c, Utf8Component::Normal(_)), - "Unexpected component in site-packages path `{c:?}` \ + debug_assert_matches!( + c, + Utf8Component::Normal(_), + "Unexpected component in site-packages path \ (expected `site-packages` to be an absolute path \ with symlinks resolved, located at \ `/lib/pythonX.Y/site-packages`)" @@ -279,10 +281,10 @@ impl PythonEnvironment { /// /// 1. activated virtual environment /// 2. conda (child) - /// 3. working dir virtual environment + /// 3. project virtual environment, when a project root is provided /// 4. conda (base) pub fn discover( - project_root: &SystemPath, + project_root: Option<&SystemPath>, system: &dyn System, ) -> Result, SitePackagesDiscoveryError> { fn resolve_environment( @@ -308,22 +310,24 @@ impl PythonEnvironment { .map(Some); } - tracing::debug!("Discovering virtual environment in `{project_root}`"); - let virtual_env_directory = project_root.join(".venv"); + if let Some(project_root) = project_root { + tracing::debug!("Discovering virtual environment in `{project_root}`"); + let virtual_env_directory = project_root.join(".venv"); - match PythonEnvironment::new( - &virtual_env_directory, - SysPrefixPathOrigin::LocalVenv, - system, - ) { - Ok(environment) => return Ok(Some(environment)), - Err(err) => { - if system.is_directory(&virtual_env_directory) { - tracing::debug!( - "Ignoring automatically detected virtual environment at `{}`: {}", - &virtual_env_directory, - err - ); + match PythonEnvironment::new( + &virtual_env_directory, + SysPrefixPathOrigin::LocalVenv, + system, + ) { + Ok(environment) => return Ok(Some(environment)), + Err(err) => { + if system.is_directory(&virtual_env_directory) { + tracing::debug!( + "Ignoring automatically detected virtual environment at `{}`: {}", + &virtual_env_directory, + err + ); + } } } } @@ -366,6 +370,14 @@ impl PythonEnvironment { } } + /// Returns the canonical, absolute `sys.prefix` of this environment. + pub fn sys_prefix(&self) -> &SysPrefixPath { + match self { + Self::Virtual(env) => &env.root_path, + Self::System(env) => env.path.sys_prefix(), + } + } + /// Returns the Python version that was used to create this environment /// (will only be available for virtual environments that specify /// the metadata in their `pyvenv.cfg` files). @@ -405,14 +417,6 @@ impl PythonEnvironment { matches!(self, Self::Virtual(_)) } - /// This environment's `sys.prefix`. - pub fn sys_prefix(&self) -> &SystemPath { - match self { - Self::Virtual(env) => &env.root_path, - Self::System(env) => env.path.sys_prefix(), - } - } - /// The interpreter this environment runs, if it has one. /// /// Type checking only ever needs the search paths, but `by run` needs the @@ -2154,12 +2158,14 @@ impl Deref for SysPrefixPath { pub enum SysPrefixPathOrigin { /// The `sys.prefix` path came from a configuration file setting: `pyproject.toml` or `ty.toml` ConfigFileSetting(Arc, Option), + /// The `sys.prefix` path came from a standalone script's inline metadata. + ScriptMetadataSetting, /// The `sys.prefix` path came from a `--python` CLI flag PythonCliFlag, /// The selected interpreter in the user's editor. Editor, - /// The `sys.prefix` path was provided by `uv workspace metadata`. - UvWorkspace, + /// The `sys.prefix` path was provided by uv metadata. + UvMetadata, /// The `sys.prefix` path came from the `VIRTUAL_ENV` environment variable VirtualEnvVar, /// The `sys.prefix` path came from the `CONDA_PREFIX` environment variable @@ -2184,12 +2190,13 @@ impl SysPrefixPathOrigin { match self { Self::LocalVenv | Self::VirtualEnvVar => true, Self::ConfigFileSetting(..) + | Self::ScriptMetadataSetting | Self::PythonCliFlag | Self::Editor | Self::DerivedFromPyvenvCfg | Self::CondaPrefixVar | Self::PythonBinary - | Self::UvWorkspace + | Self::UvMetadata | Self::SelfEnvironment => false, } } @@ -2202,6 +2209,7 @@ impl SysPrefixPathOrigin { match self { Self::PythonCliFlag | Self::ConfigFileSetting(..) + | Self::ScriptMetadataSetting | Self::Editor | Self::SelfEnvironment | Self::PythonBinary => false, @@ -2209,7 +2217,7 @@ impl SysPrefixPathOrigin { | Self::CondaPrefixVar | Self::DerivedFromPyvenvCfg | Self::LocalVenv - | Self::UvWorkspace => true, + | Self::UvMetadata => true, } } @@ -2223,9 +2231,10 @@ impl SysPrefixPathOrigin { | Self::Editor | Self::DerivedFromPyvenvCfg | Self::ConfigFileSetting(..) + | Self::ScriptMetadataSetting | Self::PythonCliFlag | Self::PythonBinary - | Self::UvWorkspace => false, + | Self::UvMetadata => false, Self::LocalVenv => true, } } @@ -2236,12 +2245,15 @@ impl std::fmt::Display for SysPrefixPathOrigin { match self { Self::PythonCliFlag => f.write_str("`--python` argument"), Self::ConfigFileSetting(_, _) => f.write_str("`environment.python` setting"), + Self::ScriptMetadataSetting => { + f.write_str("`environment.python` setting in script metadata") + } Self::VirtualEnvVar => f.write_str("`VIRTUAL_ENV` environment variable"), Self::CondaPrefixVar => f.write_str("`CONDA_PREFIX` environment variable"), Self::DerivedFromPyvenvCfg => f.write_str("derived `sys.prefix` path"), Self::LocalVenv => f.write_str("local virtual environment"), Self::Editor => f.write_str("selected interpreter in your editor"), - Self::UvWorkspace => f.write_str("uv workspace environment"), + Self::UvMetadata => f.write_str("uv environment"), Self::SelfEnvironment => f.write_str("ty environment"), Self::PythonBinary => f.write_str("Python binary discovered in $PATH"), } @@ -2319,6 +2331,8 @@ impl PartialEq for PythonHomePath { #[cfg(test)] mod tests { + use std::assert_matches; + use ruff_db::system::TestSystem; #[cfg(unix)] use ruff_db::system::{OsSystem, SystemPath}; @@ -2466,6 +2480,7 @@ mod tests { .expect("Expected environment construction to succeed"); let expect_virtual_env = self.virtual_env.is_some(); + assert_eq!(env.sys_prefix().as_std_path(), env_path.as_std_path()); match &env { PythonEnvironment::Virtual(venv) if expect_virtual_env => { self.assert_virtual_environment(venv, &env_path); @@ -2687,12 +2702,12 @@ mod tests { } #[test] - fn can_find_site_packages_directory_no_virtual_env_at_origin_uv_workspace() { + fn can_find_site_packages_directory_no_virtual_env_at_origin_uv_metadata() { let test = PythonEnvironmentTestCase { system: TestSystem::default(), minor_version: 12, free_threaded: false, - origin: SysPrefixPathOrigin::UvWorkspace, + origin: SysPrefixPathOrigin::UvMetadata, virtual_env: None, }; test.run(); @@ -2722,10 +2737,7 @@ mod tests { virtual_env: None, }; let err = test.err(); - assert!( - matches!(err, SitePackagesDiscoveryError::NoPyvenvCfgFile(..)), - "Got {err:?}", - ); + assert_matches!(err, SitePackagesDiscoveryError::NoPyvenvCfgFile(..)); } #[test] @@ -2738,10 +2750,7 @@ mod tests { virtual_env: None, }; let err = test.err(); - assert!( - matches!(err, SitePackagesDiscoveryError::NoPyvenvCfgFile(..)), - "Got {err:?}", - ); + assert_matches!(err, SitePackagesDiscoveryError::NoPyvenvCfgFile(..)); } #[test] @@ -2907,10 +2916,10 @@ mod tests { #[test] fn reject_env_that_does_not_exist() { let system = TestSystem::default(); - assert!(matches!( + assert_matches!( PythonEnvironment::new("/env", SysPrefixPathOrigin::PythonCliFlag, &system), Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory(..)) - )); + ); } #[test] @@ -2920,10 +2929,10 @@ mod tests { .memory_file_system() .write_file_all("/env", "") .unwrap(); - assert!(matches!( + assert_matches!( PythonEnvironment::new("/env", SysPrefixPathOrigin::PythonCliFlag, &system), Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory(..)) - )); + ); } #[test] @@ -2939,22 +2948,16 @@ mod tests { PythonEnvironment::new("/env", SysPrefixPathOrigin::PythonCliFlag, &system).unwrap(); let site_packages = env.site_packages_paths(&system); if cfg!(unix) { - assert!( - matches!( - site_packages, - Err(SitePackagesDiscoveryError::CouldNotReadLibDirectory(..)), - ), - "Got {site_packages:?}", + assert_matches!( + site_packages, + Err(SitePackagesDiscoveryError::CouldNotReadLibDirectory(..)) ); } else { // On Windows, we look for `Lib/site-packages` directly instead of listing the entries // of `lib/...` — so we don't see the intermediate failure - assert!( - matches!( - site_packages, - Err(SitePackagesDiscoveryError::NoSitePackagesDirFound(..)), - ), - "Got {site_packages:?}", + assert_matches!( + site_packages, + Err(SitePackagesDiscoveryError::NoSitePackagesDirFound(..)) ); } } @@ -2977,12 +2980,9 @@ mod tests { let env = PythonEnvironment::new("/env", SysPrefixPathOrigin::PythonCliFlag, &system).unwrap(); let site_packages = env.site_packages_paths(&system); - assert!( - matches!( - site_packages, - Err(SitePackagesDiscoveryError::NoSitePackagesDirFound(..)), - ), - "Got {site_packages:?}", + assert_matches!( + site_packages, + Err(SitePackagesDiscoveryError::NoSitePackagesDirFound(..)) ); } @@ -2996,14 +2996,14 @@ mod tests { .unwrap(); let venv_result = PythonEnvironment::new("/.venv", SysPrefixPathOrigin::VirtualEnvVar, &system); - assert!(matches!( + assert_matches!( venv_result, Err(SitePackagesDiscoveryError::PyvenvCfgParseError( path, PyvenvCfgParseErrorKind::MalformedKeyValuePair { line_number } )) if path == pyvenv_cfg_path && Some(line_number) == NonZeroUsize::new(1) - )); + ); } #[test] @@ -3016,14 +3016,14 @@ mod tests { .unwrap(); let venv_result = PythonEnvironment::new("/.venv", SysPrefixPathOrigin::VirtualEnvVar, &system); - assert!(matches!( + assert_matches!( venv_result, Err(SitePackagesDiscoveryError::PyvenvCfgParseError( path, PyvenvCfgParseErrorKind::MalformedKeyValuePair { line_number } )) if path == pyvenv_cfg_path && Some(line_number) == NonZeroUsize::new(1) - )); + ); } #[test] @@ -3034,14 +3034,14 @@ mod tests { memory_fs.write_file_all(&pyvenv_cfg_path, "").unwrap(); let venv_result = PythonEnvironment::new("/.venv", SysPrefixPathOrigin::VirtualEnvVar, &system); - assert!(matches!( + assert_matches!( venv_result, Err(SitePackagesDiscoveryError::PyvenvCfgParseError( path, PyvenvCfgParseErrorKind::NoHomeKey )) if path == pyvenv_cfg_path - )); + ); } #[test] @@ -3086,14 +3086,14 @@ mod tests { let venv_result = PythonEnvironment::new("/.venv", SysPrefixPathOrigin::VirtualEnvVar, &system); - assert!(matches!( + assert_matches!( venv_result, Err(SitePackagesDiscoveryError::PyvenvCfgParseError( path, PyvenvCfgParseErrorKind::InvalidHomeValue(_) )) if path == pyvenv_cfg_path - )); + ); } #[test] diff --git a/crates/ty_site_packages/src/version.rs b/crates/ty_site_packages/src/version.rs index 6832650350..48ebab0975 100644 --- a/crates/ty_site_packages/src/version.rs +++ b/crates/ty_site_packages/src/version.rs @@ -15,6 +15,9 @@ pub enum PythonVersionSource { /// Value loaded from a project's configuration file. ConfigFile(PythonVersionFileSource), + /// Value configured in a standalone script's inline metadata. + ScriptMetadata(Span), + /// Value loaded from the `pyvenv.cfg` file of the virtual environment. /// The virtual environment might have been configured, activated or inferred. PyvenvCfgFile(PythonVersionFileSource), @@ -39,8 +42,8 @@ pub enum PythonVersionSource { /// (e.g., the Python environment) Editor, - /// The value was provided by `uv workspace metadata`. - UvWorkspace, + /// The value was provided by uv metadata for a project or standalone script. + UvMetadata, /// We fell back to a default value because the value was not specified via the CLI or a config file. #[default] diff --git a/crates/ty_static/Cargo.toml b/crates/ty_static/Cargo.toml index 365a9854c6..078a1537ba 100644 --- a/crates/ty_static/Cargo.toml +++ b/crates/ty_static/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_static" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ty_static/README.md b/crates/ty_static/README.md index 6952f9a0ff..d2e8c715c3 100644 --- a/crates/ty_static/README.md +++ b/crates/ty_static/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.8) is a component of [Ruff 0.16.2](https://crates.io/crates/ruff/0.16.2). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.2/crates/ty_static). +This version (0.0.12) is a component of [Ruff 0.16.6](https://crates.io/crates/ruff/0.16.6). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.16.6/crates/ty_static). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_static/src/env_vars.rs b/crates/ty_static/src/env_vars.rs index 97e9c820d6..88c6b4110f 100644 --- a/crates/ty_static/src/env_vars.rs +++ b/crates/ty_static/src/env_vars.rs @@ -64,7 +64,8 @@ impl EnvVars { /// Enable uv integration. /// /// When set to `"1"` or `"true"`, ty invokes `uv workspace metadata` to discover the workspace - /// root. + /// root and initialize script environments. When set to `"scripts"`, only script environments + /// are initialized. #[attr_hidden] pub const TY_UV: &'static str = "TY_UV"; diff --git a/crates/ty_test/Cargo.toml b/crates/ty_test/Cargo.toml index a7bf0ad241..795cb08849 100644 --- a/crates/ty_test/Cargo.toml +++ b/crates/ty_test/Cargo.toml @@ -27,6 +27,7 @@ ty_vendored = { workspace = true } anyhow = { workspace = true } camino = { workspace = true } +compact_str = { workspace = true, features = ["serde"] } dunce = { workspace = true } salsa = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/ty_test/README.md b/crates/ty_test/README.md index 4b4e981ead..0e275b345d 100644 --- a/crates/ty_test/README.md +++ b/crates/ty_test/README.md @@ -365,6 +365,9 @@ When a test has dependencies: are installed fresh for each test that specifies them, so tests with many dependencies may be slower to run. +To test dependency checks with these packages, also +[supply dependency metadata](#supplying-dependency-metadata). + #### Lockfiles Each `.md` file with external dependencies has a corresponding `.lock` file of the same name. @@ -439,6 +442,69 @@ extra-paths = ["/.venv/"] ``` ```` +### Supplying dependency metadata + +Tests for dependency checks can supply declarations and module ownership through a +`[dependency-metadata]` fixture. This does not invoke uv or install packages. The imported modules +can come from a [mock Python environment](#mocking-a-python-environment) or from +[external dependencies](#testing-with-external-dependencies) installed through `[project]`. + +The two sections are independent: `[project]` supplies installed modules, while +`[dependency-metadata]` describes the declarations and ownership used by dependency checks. +Automatically deriving dependency metadata from `[project]` is deferred to future work. +For now, tests with external dependencies must also supply `[dependency-metadata]` to exercise +dependency checks. Explicit metadata also lets tests model nested projects and ambiguous module +ownership using only mocked modules. + +````markdown +```toml +[environment] +python = "/.venv" + +[dependency-metadata] +projects = [{ path = "/src", distribution = "app", dependencies = ["requests"] }] + +[dependency-metadata.distributions] +app = { name = "my-project" } +requests = { name = "requests" } + +[dependency-metadata.module-owners] +my_project = ["app"] +requests = ["requests"] +``` +```` + +Distribution keys are opaque identifiers, and each distribution's `name` is used in diagnostics. +A project can also specify `group-dependencies`. An editable distribution can specify an absolute +`editable-path` in the test filesystem. Module-owner keys are validated as absolute Python module +names, and their values list the distributions that provide each module. + +The fixture follows the same section inheritance as other mdtest configuration. Standalone PEP 723 +scripts do not inherit this project metadata. Instead, a script can supply its own fixture under +`[tool.ty.dependency-metadata]` in its inline metadata block. Each project entry uses the script's +exact absolute path, not its containing directory: + +````markdown +`script.py`: + +```py +# /// script +# dependencies = ["requests"] +# [tool.ty.dependency-metadata] +# projects = [{ path = "/src/script.py", dependencies = ["requests"] }] +# [tool.ty.dependency-metadata.distributions] +# requests = { name = "requests" } +# [tool.ty.dependency-metadata.module-owners] +# requests = ["requests"] +# /// + +import requests +``` +```` + +`tool.ty.dependency-metadata` is available only in tests. The script's `dependencies` field does not +automatically populate the fixture, and a script without a fixture has no dependency metadata. + ## Documentation of tests Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by diff --git a/crates/ty_test/src/config.rs b/crates/ty_test/src/config.rs index 3e30217156..0a222aa549 100644 --- a/crates/ty_test/src/config.rs +++ b/crates/ty_test/src/config.rs @@ -17,14 +17,18 @@ use std::collections::BTreeMap; +use compact_str::CompactString; use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_python_ast::PythonVersion; use ruff_python_ast::script::ScriptTag; use serde::{Deserialize, Serialize}; -use ty_module_resolver::DistributionName; +use ty_module_resolver::{DistributionName, ModuleName}; use ty_python_core::platform::PythonPlatform; use ty_python_semantic::TypeCheckingPreset; use ty_python_semantic::dependencies::{DependencyGroup, DependencyManifest, GroupName}; +use ty_python_semantic::dependency::{ + DependencyDistribution, DependencyMetadata, DependencyProject, DependencyProjectKind, +}; use ty_python_semantic::lint::Level; #[derive(Deserialize, Debug, Default, Clone)] @@ -59,6 +63,9 @@ pub(crate) struct MarkdownTestConfig { /// requirement strings is `ty_project`'s job and is tested there. pub(crate) dependencies: Option, + /// Dependency declarations and module ownership without installing packages. + pub(crate) dependency_metadata: Option, + /// Simulate the use passing `-v` on the command line, /// which can be used to show more information in test diagnostics. pub(crate) verbose: Option, @@ -138,18 +145,18 @@ impl MarkdownTestConfig { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct Dependencies { /// `[project].name`, which is what decides the modules the project ships. - pub(crate) name: Option, + name: Option, /// `[project].dependencies`. - pub(crate) project: Option>, + project: Option>, /// `[project.optional-dependencies]`. #[serde(default)] - pub(crate) extras: BTreeMap>, + extras: BTreeMap>, /// `[dependency-groups]`. #[serde(default)] - pub(crate) groups: BTreeMap>, + groups: BTreeMap>, } #[derive(Deserialize, Debug, Default)] @@ -158,6 +165,7 @@ pub(crate) struct ScriptOptions { pub(crate) type_checking_preset: Option, pub(crate) rules: Option, pub(crate) analysis: Option, + pub(crate) dependency_metadata: Option, } impl ScriptOptions { @@ -165,7 +173,14 @@ impl ScriptOptions { let tag = ScriptTag::parse(source.as_bytes())?; let metadata: ScriptMetadata = toml::from_str(tag.metadata()).ok()?; - Some(metadata.tool.and_then(|tool| tool.ty).unwrap_or_default()) + let mut options = metadata.tool.and_then(|tool| tool.ty).unwrap_or_default(); + if let Some(fixture) = &mut options.dependency_metadata { + for project in &mut fixture.metadata.projects { + project.kind = DependencyProjectKind::Script; + } + } + + Some(options) } } @@ -313,3 +328,84 @@ pub(crate) struct Project { /// Example: `dependencies = ["pydantic==2.12.2"]` dependencies: Option>, } + +#[derive(Deserialize, Debug, Clone)] +#[serde(try_from = "DependencyMetadataOptions")] +pub(crate) struct DependencyMetadataFixture { + pub(crate) metadata: DependencyMetadata, +} + +impl TryFrom for DependencyMetadataFixture { + type Error = String; + + fn try_from(options: DependencyMetadataOptions) -> Result { + let module_owners = options + .module_owners + .into_iter() + .map(|(module, owners)| { + let module_name = ModuleName::new(&module) + .ok_or_else(|| format!("Invalid dependency module name `{module}`"))?; + Ok((module_name, owners.into_boxed_slice())) + }) + .collect::>()?; + + Ok(Self { + metadata: DependencyMetadata { + projects: options + .projects + .into_iter() + .map(|project| DependencyProject { + path: project.path, + kind: DependencyProjectKind::Project, + distribution: project.distribution, + dependencies: project.dependencies.into_iter().collect(), + group_dependencies: project.group_dependencies.into_iter().collect(), + }) + .collect(), + distributions: options + .distributions + .into_iter() + .map(|(id, distribution)| { + ( + id, + DependencyDistribution { + name: distribution.name, + editable_path: distribution.editable_path, + }, + ) + }) + .collect(), + module_owners, + }, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct DependencyMetadataOptions { + #[serde(default)] + projects: Vec, + #[serde(default)] + distributions: BTreeMap, + #[serde(default)] + module_owners: BTreeMap>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct DependencyProjectOptions { + path: SystemPathBuf, + distribution: Option, + #[serde(default)] + dependencies: Vec, + #[serde(default)] + group_dependencies: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct DependencyDistributionOptions { + name: CompactString, + editable_path: Option, +} diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 71dd52db3f..93326a54ae 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -18,6 +18,7 @@ use ty_module_resolver::ModuleGlobSetBuilder; use ty_python_core::program::ProgramSettings; use ty_python_core::{Db as _, ProgramFile, TestProgramDb}; use ty_python_semantic::dependencies::DependencyManifest; +use ty_python_semantic::dependency::DependencyMetadata; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{ AnalysisSettings, Db as SemanticDb, ExperimentalSettings, PythonVersionWithSource, @@ -119,6 +120,13 @@ impl Db { } } + pub(crate) fn update_dependency_metadata(&mut self, metadata: Option<&DependencyMetadata>) { + let settings = self.settings(); + if settings.dependency_metadata(self).as_ref() != metadata { + settings.set_dependency_metadata(self).to(metadata.cloned()); + } + } + pub(crate) fn update_mdtest_rule_selection( &mut self, preset: TypeCheckingPreset, @@ -235,6 +243,13 @@ impl SemanticDb for Db { self.settings().dependency_manifest(self).as_ref() } + fn dependency_metadata(&self, file: File) -> Option<&DependencyMetadata> { + match file_settings(self, file) { + FileSettings::Global => self.settings().dependency_metadata(self).as_ref(), + FileSettings::File(settings) => settings.dependency_metadata.as_ref(), + } + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -272,6 +287,7 @@ fn file_settings(db: &dyn SemanticDb, file: File) -> FileSettings { FileSettings::File(Box::new(InlineSettings { rules: MdtestRuleSelection(mdtest_rule_selection(preset, options.rules.as_ref(), None)), analysis: mdtest_analysis_settings(preset, options.analysis.as_ref()), + dependency_metadata: options.dependency_metadata.map(|fixture| fixture.metadata), })) } @@ -287,6 +303,7 @@ enum FileSettings { struct InlineSettings { rules: MdtestRuleSelection, analysis: AnalysisSettings, + dependency_metadata: Option, } impl FileSettings { @@ -316,6 +333,9 @@ struct Settings { #[returns(ref)] experimental: ExperimentalSettings, #[default] + #[returns(ref)] + dependency_metadata: Option, + #[default] #[returns(deref)] rule_selection: MdtestRuleSelection, #[default] @@ -509,6 +529,7 @@ fn mdtest_rule_selection( // The `unsound-*` rules are also exceptions because they are very strict, would // result in lots of additional diagnostics in mdtests, and are not the default behaviour // we'll show to our users. + "unsound-assignment", "unsound-return-statement", "unsound-yield", // `implicit-declaration` is an exception for the same reason: it asks every diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index ba9bcf3526..1c285967cb 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -16,6 +16,7 @@ use ruff_db::testing::{setup_logging, setup_logging_with_filter}; use ruff_diagnostics::Applicability; use ruff_python_ast::PythonVersion; use ruff_source_file::OneIndexed; +use std::assert_matches; use std::fmt::Write; use ty_module_resolver::{ Module, SearchPath, SearchPathSettings, list_modules, resolve_module_confident, @@ -169,11 +170,10 @@ fn run_test( return None; } - assert!( - matches!( - embedded.lang, - "py" | "pyi" | "python" | "ipynb" | "by" | "byi" | "bython" | "basedpython" | "text" | "cfg" | "pth" | "json" | "toml" | "yaml" - ), + assert_matches!( + embedded.lang, + "py" | "pyi" | "python" | "ipynb" | "by" | "byi" | "bython" | "basedpython" + | "text" | "cfg" | "pth" | "json" | "toml" | "yaml", "Supported file types are: py (or python), pyi, ipynb, by, bython, basedpython, byi, text, cfg, pth, json, toml, yaml and ignore" ); @@ -312,6 +312,12 @@ fn run_test( db.update_analysis_options(preset, configuration.analysis.as_ref()); db.update_experimental_options(configuration.experimental.as_ref()); db.update_dependency_manifest(configuration.dependency_manifest()); + db.update_dependency_metadata( + configuration + .dependency_metadata + .as_ref() + .map(|fixture| &fixture.metadata), + ); db.update_mdtest_rule_selection( preset, configuration.rules.as_ref(), @@ -360,6 +366,7 @@ fn run_test( test_file, &inline_diagnostics, &mut markdown_edits, + |rendered| normalize_site_packages_paths(rendered, python_version), ) }) { Ok(()) => None, @@ -574,6 +581,28 @@ impl std::fmt::Display for ModuleInconsistency<'_> { } } +// Site-packages placeholders are specific to ty's fixtures. Keeping their normalization outside +// the shared mdtest crate avoids rewriting Ruff snapshots or paths in displayed source and messages. +fn normalize_site_packages_paths(rendered: &str, python_version: PythonVersion) -> String { + let unix_site_packages_path = format!("/lib/python{python_version}/site-packages/"); + let mut normalized = String::with_capacity(rendered.len()); + + for line in rendered.split_inclusive('\n') { + let trimmed = line.trim_start(); + + if trimmed.starts_with("--> ") || trimmed.starts_with("::: ") { + let line = line + .replace(&unix_site_packages_path, "//") + .replace("/Lib/site-packages/", "//"); + normalized.push_str(&line); + } else { + normalized.push_str(line); + } + } + + normalized +} + fn expand_site_packages_placeholder( path: &SystemPath, python_version: PythonVersion, @@ -617,8 +646,32 @@ fn parse<'s>( #[cfg(test)] mod tests { + use ruff_python_ast::PythonVersion; use ruff_python_trivia::textwrap::dedent; + #[test] + fn normalizes_site_packages_paths_only_in_diagnostic_locations() { + let rendered = "warning[example]: Invalid value\n\ + --> .venv/lib/python3.10/site-packages/dependency.py:1:5\n\ + |\n\ + 1 | path = \".venv/lib/python3.10/site-packages/dependency.py\"\n\ + |\n\ + ::: .venv/Lib/site-packages/other.py:2:1\n\ + help: Inspect .venv/lib/python3.10/site-packages/dependency.py"; + let expected = "warning[example]: Invalid value\n\ + --> .venv//dependency.py:1:5\n\ + |\n\ + 1 | path = \".venv/lib/python3.10/site-packages/dependency.py\"\n\ + |\n\ + ::: .venv//other.py:2:1\n\ + help: Inspect .venv/lib/python3.10/site-packages/dependency.py"; + + assert_eq!( + super::normalize_site_packages_paths(rendered, PythonVersion::PY310), + expected, + ); + } + #[test] fn multiple_sections_with_dependencies_not_allowed() { let source = dedent( diff --git a/crates/ty_vendored/Cargo.toml b/crates/ty_vendored/Cargo.toml index ef78599682..8df2d62785 100644 --- a/crates/ty_vendored/Cargo.toml +++ b/crates/ty_vendored/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_vendored" -version = "0.0.8" +version = "0.0.12" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_vendored/ty_extensions/__init__.pyi b/crates/ty_vendored/ty_extensions/__init__.pyi index 9824bbc6d3..c186a0b951 100644 --- a/crates/ty_vendored/ty_extensions/__init__.pyi +++ b/crates/ty_vendored/ty_extensions/__init__.pyi @@ -1,4 +1,4 @@ -# ruff: noqa: PYI021 +# ruff: file-ignore[docstring-in-stub] """Experimental ty APIs intended to be exposed to end users.""" import collections.abc @@ -11,7 +11,7 @@ from typing import ( _SpecialForm, ) -from typing_extensions import LiteralString, Self # noqa: UP035 +from typing_extensions import LiteralString, Self # ruff: ignore[deprecated-import] # basedpython: `Unknown` is part of the language a user reads and writes, not an # internal of the checker, so it stays on the public module. upstream moved its @@ -232,7 +232,7 @@ type JustFloat = _TypeOf[1.0] type JustComplex = _TypeOf[1.0j] class Character(str): - """a single extended grapheme cluster — one user-perceived character + r"""a single extended grapheme cluster — one user-perceived character `Character` is the element type of `str`: it is one user-perceived character (an extended grapheme cluster, per unicode UAX #29), which may span several diff --git a/crates/ty_vendored/ty_extensions/_internal.pyi b/crates/ty_vendored/ty_extensions/_internal.pyi index a0807320b1..b27f8c8093 100644 --- a/crates/ty_vendored/ty_extensions/_internal.pyi +++ b/crates/ty_vendored/ty_extensions/_internal.pyi @@ -1,4 +1,4 @@ -# ruff: noqa: PYI021 +# ruff: file-ignore[docstring-in-stub] """ Internal-only symbols for special forms and type-system tests. @@ -11,7 +11,8 @@ from collections.abc import Callable from enum import Enum from typing import Any, Protocol, _SpecialForm -from typing_extensions import LiteralString, Self, TypeForm # noqa: UP035 +# ruff: ignore[deprecated-import] +from typing_extensions import LiteralString, Self, TypeForm # ------------- # Special forms @@ -165,16 +166,6 @@ class ConstraintSet: Universally abstracts the given type variables from this constraint set. """ - def satisfied_by_all_typevars( - self, *, inferable: TypeForm[tuple[object, ...]] | None = None - ) -> bool: - """ - Returns whether this constraint set is satisfied by all of the typevars - that it mentions. You must provide a tuple of the typevars that should - be considered `inferable`. All other typevars mentioned in the - constraint set will be considered non-inferable. - """ - def solutions_for( self, typevar: TypeForm[object], diff --git a/crates/ty_vendored/ty_extensions/pydantic.pyi b/crates/ty_vendored/ty_extensions/pydantic.pyi index af7b9c6350..4b629e4239 100644 --- a/crates/ty_vendored/ty_extensions/pydantic.pyi +++ b/crates/ty_vendored/ty_extensions/pydantic.pyi @@ -3,6 +3,8 @@ from datetime import date, datetime, time, timedelta from decimal import Decimal +from enum import Enum +from fractions import Fraction from ipaddress import ( IPv4Address, IPv4Interface, @@ -15,14 +17,14 @@ from pathlib import Path from re import Pattern from uuid import UUID -type LaxBool = bool | float | int | str | Decimal +type LaxBool = bool | bytes | float | int | str | Decimal type LaxBytes = bytearray | bytes | str type LaxByteSize = float | int | str | Decimal type LaxDate = bytes | date | datetime | float | int | str | Decimal type LaxDatetime = bytes | date | datetime | float | int | str | Decimal type LaxDecimal = float | int | str | Decimal -type LaxFloat = bool | bytes | float | int | str | Decimal -type LaxInt = bool | bytes | float | int | str | Decimal +type LaxFloat = bool | bytes | float | int | str | Decimal | Fraction +type LaxInt = bool | bytes | float | int | str | Decimal | Enum | Fraction type LaxIPv4Address = bytes | int | str | IPv4Address | IPv4Interface type LaxIPv4Interface = ( bytes | int | str | tuple[object, object] | IPv4Address | IPv4Interface @@ -36,7 +38,7 @@ type LaxIPv6Network = bytes | int | str | IPv6Address | IPv6Interface | IPv6Netw type LaxPath = str | Path type LaxStrPattern = str | Pattern[str] type LaxBytesPattern = bytes | Pattern[bytes] -type LaxStr = bytearray | bytes | str +type LaxStr = bytearray | bytes | str | Enum type LaxTime = bytes | float | int | str | time | Decimal type LaxTimedelta = bytes | float | int | str | timedelta | Decimal type LaxUUID = str | UUID diff --git a/crates/ty_vendored/typeshed_patches/0002-mapping-get-object.patch b/crates/ty_vendored/typeshed_patches/0002-mapping-get-object.patch index 120378ea8e..22ba3e2655 100644 --- a/crates/ty_vendored/typeshed_patches/0002-mapping-get-object.patch +++ b/crates/ty_vendored/typeshed_patches/0002-mapping-get-object.patch @@ -6,8 +6,8 @@ + def get(self, key: object, /) -> _VT_co | None: """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.""" @overload -- def get(self, key: _KT, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter -+ def get(self, key: object, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter +- def get(self, key: _KT, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter # pyrefly: ignore [invalid-variance] ++ def get(self, key: object, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter # pyrefly: ignore [invalid-variance] @overload - def get(self, key: _KT, default: _T, /) -> _VT_co | _T: ... + def get(self, key: object, default: _T, /) -> _VT_co | _T: ... diff --git a/crates/ty_vendored/typeshed_patches/0007-dataclasses-is-dataclass-top.patch b/crates/ty_vendored/typeshed_patches/0007-dataclasses-is-dataclass-top.patch index c0f47888b2..8b700d2250 100644 --- a/crates/ty_vendored/typeshed_patches/0007-dataclasses-is-dataclass-top.patch +++ b/crates/ty_vendored/typeshed_patches/0007-dataclasses-is-dataclass-top.patch @@ -14,8 +14,8 @@ index d46b694a7e..1db97b1893 100644 # HACK: `obj: Never` typing matches if object argument is using `Any` type. @overload --def is_dataclass(obj: Never) -> TypeIs[DataclassInstance | type[DataclassInstance]]: # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-type-guard-definition] -+def is_dataclass(obj: Never) -> TypeIs[Top[DataclassInstance | type[DataclassInstance]]]: # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-type-guard-definition] +-def is_dataclass(obj: Never) -> TypeIs[DataclassInstance | type[DataclassInstance]]: # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-type-guard-definition] # pyrefly: ignore [bad-function-definition] ++def is_dataclass(obj: Never) -> TypeIs[Top[DataclassInstance | type[DataclassInstance]]]: # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-type-guard-definition] # pyrefly: ignore [bad-function-definition] """Returns True if obj is a dataclass or an instance of a dataclass. """ diff --git a/crates/ty_vendored/vendor/typeshed/source_commit.txt b/crates/ty_vendored/vendor/typeshed/source_commit.txt index 5ba2971442..7bdf77fcac 100644 --- a/crates/ty_vendored/vendor/typeshed/source_commit.txt +++ b/crates/ty_vendored/vendor/typeshed/source_commit.txt @@ -1 +1 @@ -1b116673774d062a4af7b0a0b3d05533a6be55d0 +cf09d2a4d7614f648e9109dce609887499a7c6ee diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_codecs.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_codecs.byi index 906f51a746..b159a10191 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_codecs.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_codecs.byi @@ -12,7 +12,7 @@ final class _EncodingMap: type _CharMap = dict[int, int] | _EncodingMap private type Handler = (UnicodeError) -> (str | bytes, int) -private type SearchFunction = (str) -> (codecs.CodecInfo?) +private type SearchFunction = (str) -> codecs.CodecInfo? def register(search_function: SearchFunction, /): """Register a codec search function. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_collections_abc.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_collections_abc.byi index f9303ae921..2761d6c8de 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_collections_abc.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_collections_abc.byi @@ -101,7 +101,7 @@ protocol Generator[out Yield, in Send = None, out Return = None](Iterator[Yield] abstract def throw(self, typ: BaseException, val: None = None, tb: TracebackType? = None, /) -> Yield if sys.version_info >= (3, 13): - def close(self) -> Return | None: + def close(self) -> Return?: """Raise GeneratorExit inside generator.""" else: @@ -393,7 +393,7 @@ class Mapping[out Key, out Value](Collection[Key]): abstract def __getitem__(self, key: Overlapping[Key], /) -> Value # Mixin methods - def get(self, key: Overlapping[Key], /) -> Value | None: + def get(self, key: Overlapping[Key], /) -> Value?: """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.""" def get(self, key: Overlapping[Key], default: Value, /) -> Value def get[Element](self, key: Overlapping[Key], default: Element, /) -> Value | Element @@ -447,7 +447,7 @@ class MutableMapping[in out Key, in out Value](Mapping[Key, Value]): # -- collections.ChainMap.setdefault # -- weakref.WeakKeyDictionary.setdefault @ignorable_return_value - def setdefault[Element](self: MutableMapping[Key, Element | None], key: Key, default: None = None, /) -> Element | None: + def setdefault[Element](self: MutableMapping[Key, Element?], key: Key, default: None = None, /) -> Element?: """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D""" @ignorable_return_value def setdefault(self, key: Key, default: Value, /) -> Value diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_ctypes.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_ctypes.byi index c1bfb9cd9c..a27d64be07 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_ctypes.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_ctypes.byi @@ -463,3 +463,9 @@ def buffer_info(o: _CData | _CDataType | type[_CData | _CDataType], /) -> (str, def call_cdeclfunction(address: int, arguments: (*: dynamic), /) -> dynamic def call_function(address: int, arguments: (*: dynamic), /) -> dynamic + +# dllist() is available on Linux and other platforms like NetBSD +if sys.version_info >= (3, 14) and sys.platform != "win32" and sys.platform != "darwin": + # Added in Python 3.14.7 + def dllist() -> list[str]: + """dllist() return a list of loaded shared libraries""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib.byi index 0f9641af5d..7ee491854a 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib.byi @@ -36,6 +36,7 @@ def __import__( """ +# TODO: Revise the protocol for 'loader' param def spec_from_loader( name: str, loader: LoaderProtocol?, *, origin: str? = None, is_package: bool? = None ) -> importlib.machinery.ModuleSpec?: @@ -133,19 +134,22 @@ class BuiltinImporter(importlib.abc.MetaPathFinder, importlib.abc.InspectLoader) override class def is_package(cls, fullname: str) -> bool: """Return False as built-in modules are never packages.""" - class def load_module(cls, fullname: str) -> types.ModuleType: - """Load the specified module into sys.modules and return it. - - This method is deprecated. Use loader.exec_module() instead. - - """ - override class def get_code(cls, fullname: str): """Return None as built-in modules do not have code objects.""" override class def get_source(cls, fullname: str): """Return None as built-in modules do not have source code.""" + if sys.version_info < (3, 15): + @classmethod + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(cls, fullname: str) -> types.ModuleType: + """Load the specified module into sys.modules and return it. + + This method is deprecated. Use loader.exec_module() instead. + + """ + # Loader if sys.version_info < (3, 12): @staticmethod @@ -192,19 +196,22 @@ class FrozenImporter(importlib.abc.MetaPathFinder, importlib.abc.InspectLoader): override class def is_package(cls, fullname: str) -> bool: """Return True if the frozen module is a package.""" - class def load_module(cls, fullname: str) -> types.ModuleType: - """Load a frozen module. - - This method is deprecated. Use exec_module() instead. - - """ - override class def get_code(cls, fullname: str): """Return the code object for the frozen module.""" override class def get_source(cls, fullname: str): """Return None as frozen modules do not have source code.""" + if sys.version_info < (3, 15): + @classmethod + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(cls, fullname: str) -> types.ModuleType: + """Load a frozen module. + + This method is deprecated. Use exec_module() instead. + + """ + # Loader if sys.version_info < (3, 12): @staticmethod diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.byi index 5b21052d7c..41c4396d35 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.byi @@ -15,7 +15,7 @@ import sys import types from _typeshed import ReadableBuffer, StrOrBytesPath, StrPath from _typeshed.importlib import LoaderProtocol -from collections.abc import Callable, Iterable, Mapping, MutableSequence, Sequence +from collections.abc import Callable, Iterable, Iterator, Mapping, MutableSequence, Sequence from importlib.machinery import ModuleSpec from importlib.metadata import DistributionFinder, PathDistribution from typing import Final, Literal @@ -32,24 +32,45 @@ else: final MAGIC_NUMBER: bytes -@deprecated( - "The `debug_override` parameter is deprecated since Python 3.5; will be removed in Python 3.15. Use `optimization` instead." -) -def cache_from_source(path: StrPath, debug_override: bool, *, optimization: None = None) -> str: - """Given the path to a .py file, return the path to its .pyc file. +if sys.version_info >= (3, 15): + def cache_from_source(path: StrPath, *, optimization: dynamic? = None) -> str: + """Given the path to a .py file, return the path to its .pyc file. - The .py file does not need to exist; this simply returns the path to the - .pyc file calculated as if the .py file were imported. + The .py file does not need to exist; this simply returns the path to the + .pyc file calculated as if the .py file were imported. - The 'optimization' parameter controls the presumed optimization level of - the bytecode file. If 'optimization' is not None, the string representation - of the argument is taken and verified to be alphanumeric (else ValueError - is raised). + The 'optimization' parameter controls the presumed optimization level of + the bytecode file. If 'optimization' is not None, the string representation + of the argument is taken and verified to be alphanumeric (else ValueError + is raised). - If sys.implementation.cache_tag is None then NotImplementedError is raised. + If sys.implementation.cache_tag is None then NotImplementedError is raised. - """ -def cache_from_source(path: StrPath, debug_override: None = None, *, optimization: dynamic? = None) -> str + """ + +else: + @deprecated( + "The `debug_override` parameter is deprecated since Python 3.5; removed in Python 3.15. Use `optimization` instead." + ) + def cache_from_source(path: StrPath, debug_override: bool, *, optimization: None = None) -> str: + """Given the path to a .py file, return the path to its .pyc file. + + The .py file does not need to exist; this simply returns the path to the + .pyc file calculated as if the .py file were imported. + + The 'optimization' parameter controls the presumed optimization level of + the bytecode file. If 'optimization' is not None, the string representation + of the argument is taken and verified to be alphanumeric (else ValueError + is raised). + + The debug_override parameter is deprecated. If debug_override is not None, + a True value is the same as setting 'optimization' to the empty string + while a False value is equivalent to setting 'optimization' to '1'. + + If sys.implementation.cache_tag is None then NotImplementedError is raised. + + """ + def cache_from_source(path: StrPath, debug_override: None = None, *, optimization: dynamic? = None) -> str def source_from_cache(path: StrPath) -> str: """Given the path to a .pyc. file, return the path to its .py file. @@ -192,8 +213,10 @@ class _LoaderBasics: def exec_module(self, module: types.ModuleType): """Execute the module.""" - def load_module(self, fullname: str) -> types.ModuleType: - """This method is deprecated.""" + if sys.version_info < (3, 15): + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(self, fullname: str) -> types.ModuleType: + """This method is deprecated.""" class SourceLoader(_LoaderBasics): def path_mtime(self, path: str) -> int | float: @@ -225,13 +248,32 @@ class SourceLoader(_LoaderBasics): Raises OSError when the path cannot be handled. """ - def source_to_code( - self, data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: bytes | StrPath - ) -> types.CodeType: - """Return the code object compiled from source. + if sys.version_info >= (3, 15): + def source_to_code( + self, + data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, + path: bytes | StrPath, + fullname: str? = None, + *, + _optimize: int = -1, + ) -> types.CodeType: + """Return the code object compiled from source. + + The 'data' argument can be any object type that compile() supports. + """ - The 'data' argument can be any object type that compile() supports. - """ + else: + def source_to_code( + self, + data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, + path: bytes | StrPath, + *, + _optimize: int = -1, + ) -> types.CodeType: + """Return the code object compiled from source. + + The 'data' argument can be any object type that compile() supports. + """ def get_code(self, fullname: str) -> types.CodeType?: """Concrete implementation of InspectLoader.get_code. @@ -259,14 +301,15 @@ class FileLoader: def get_filename(self, fullname: str? = None) -> str: """Return the path to the source file as found by the finder.""" - def load_module(self, fullname: str? = None) -> types.ModuleType: - """Load a module from a file. - - This method is deprecated. Use exec_module() instead. + def get_resource_reader(self, name: str? = None) -> importlib.readers.FileReader + if sys.version_info < (3, 15): + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(self, fullname: str? = None) -> types.ModuleType: + """Load a module from a file. - """ + This method is deprecated. Use exec_module() instead. - def get_resource_reader(self, name: str? = None) -> importlib.readers.FileReader + """ class SourceFileLoader(importlib.abc.FileLoader, FileLoader, importlib.abc.SourceLoader, SourceLoader): """Concrete implementation of SourceLoader using the file system.""" @@ -277,18 +320,6 @@ class SourceFileLoader(importlib.abc.FileLoader, FileLoader, importlib.abc.Sourc def path_stats(self, path: str) -> Mapping[str, dynamic]: """Return the metadata for the path.""" - def source_to_code( - self, - data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, - path: bytes | StrPath, - *, - _optimize: int = -1, - ) -> types.CodeType: - """Return the code object compiled from source. - - The 'data' argument can be any object type that compile() supports. - """ - class SourcelessFileLoader(importlib.abc.FileLoader, FileLoader, _LoaderBasics): """Loader which handles sourceless file imports.""" @@ -322,6 +353,30 @@ class ExtensionFileLoader(FileLoader, _LoaderBasics, importlib.abc.ExecutionLoad override def __eq__(self, other: object) -> bool override def __hash__(self) -> int +if sys.version_info >= (3, 15): + class NamespacePath: + """Represents a namespace package's path. + + It uses the module *name* to find its parent module, and from there it looks + up the parent's __path__. When this changes, the module's own path is + recomputed, using *path_finder*. The initial value is set to *path*. + + For top-level modules, the parent module's path is sys.path. + + *path_finder* should be a callable with the same signature as + MetaPathFinder.find_spec((fullname, path, target=None) -> spec). + """ + + init( + self, name: str, path: MutableSequence[str], path_finder: (str, (*: str)) -> ModuleSpec + ) + def __iter__(self) -> Iterator[str] + def __getitem__(self, index: int) -> str + def __setitem__(self, index: int, path: str) + def __len__(self) -> int + def __contains__(self, item: str) -> bool + def append(self, item: str) + if sys.version_info >= (3, 11): class NamespaceLoader(importlib.abc.InspectLoader): init( @@ -334,15 +389,16 @@ if sys.version_info >= (3, 11): """Use default semantics for module creation.""" def exec_module(self, module: types.ModuleType) - @deprecated("Deprecated since Python 3.10; will be removed in Python 3.15. Use `exec_module()` instead.") - def load_module(self, fullname: str) -> types.ModuleType: - """Load a namespace module. + def get_resource_reader(self, module: types.ModuleType) -> importlib.readers.NamespaceReader + if sys.version_info < (3, 15): + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(self, fullname: str) -> types.ModuleType: + """Load a namespace module. - This method is deprecated. Use exec_module() instead. + This method is deprecated. Use exec_module() instead. - """ + """ - def get_resource_reader(self, module: types.ModuleType) -> importlib.readers.NamespaceReader if sys.version_info < (3, 12): @staticmethod @deprecated( @@ -369,7 +425,7 @@ else: """Use default semantics for module creation.""" def exec_module(self, module: types.ModuleType) - @deprecated("Deprecated since Python 3.10; will be removed in Python 3.15. Use `exec_module()` instead.") + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") def load_module(self, fullname: str) -> types.ModuleType: """Load a namespace module. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_interpchannels.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_interpchannels.byi index 016d741602..36a2dd1041 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_interpchannels.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_interpchannels.byi @@ -112,14 +112,14 @@ def list_interpreters(cid: SupportsIndex, *, send: bool) -> list[int]: receive end. """ -def send(cid: SupportsIndex, obj: object, *, blocking: bool = True, timeout: int | float | None = None): +def send(cid: SupportsIndex, obj: object, *, blocking: bool = True, timeout: (int | float)? = None): """channel_send(cid, obj, *, blocking=True, timeout=None) Add the object's data to the channel's queue. By default this waits for the object to be received. """ -def send_buffer(cid: SupportsIndex, obj: Buffer, *, blocking: bool = True, timeout: int | float | None = None): +def send_buffer(cid: SupportsIndex, obj: Buffer, *, blocking: bool = True, timeout: (int | float)? = None): """channel_send_buffer(cid, obj, *, blocking=True, timeout=None) Add the object's buffer to the channel's queue. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_operator.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_operator.byi index e79417eb86..98baa48c83 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_operator.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_operator.byi @@ -9,6 +9,7 @@ used for special methods; variants without leading and trailing import sys from _typeshed import ( + ReadableBuffer, SupportsAdd, SupportsGetItem, SupportsMod, @@ -232,7 +233,7 @@ if sys.version_info >= (3, 11): def call[Parameters: (*: *, **: *), R](obj: (**Parameters) -> R, /, *args: *Parameters, **kwargs: **Parameters) -> R: """Same as obj(*args, **kwargs).""" -def _compare_digest(a: AnyStr, b: AnyStr, /) -> bool: +def _compare_digest(a: ReadableBuffer, b: ReadableBuffer, /) -> bool: """Return 'a == b'. This function uses an approach designed to prevent @@ -245,6 +246,7 @@ def _compare_digest(a: AnyStr, b: AnyStr, /) -> bool: a timing attack could theoretically reveal information about the types and lengths of a and b--but not their values. """ +def _compare_digest(a: str, b: str, /) -> bool if sys.version_info >= (3, 14): def is_none(a: object, /) -> a is None: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_queue.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_queue.byi index cdcd94e231..5f49087908 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_queue.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_queue.byi @@ -18,7 +18,7 @@ class SimpleQueue[in out Element]: def empty(self) -> bool: """Return True if the queue is empty, False otherwise (not reliable!).""" - def get(self, block: bool = True, timeout: int | float | None = None) -> Element: + def get(self, block: bool = True, timeout: (int | float)? = None) -> Element: """Remove and return an item from the queue. If optional args 'block' is true and 'timeout' is None (the @@ -38,7 +38,7 @@ class SimpleQueue[in out Element]: raise the Empty exception. """ - def put(self, item: Element, block: bool = True, timeout: int | float | None = None): + def put(self, item: Element, block: bool = True, timeout: (int | float)? = None): """Put the item on the queue. The optional 'block' and 'timeout' arguments are ignored, as this diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_socket.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_socket.byi index 6c00de721c..a6c611aa5b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_socket.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_socket.byi @@ -1170,7 +1170,7 @@ class socket: setblocking(False) is equivalent to settimeout(0.0). """ - def settimeout(self, value: int | float | None, /): + def settimeout(self, value: (int | float)?, /): """settimeout(timeout) Set a timeout on socket operations. 'timeout' can be a float, @@ -1329,7 +1329,7 @@ def getdefaulttimeout() -> float?: """ # F811: "Redefinition of unused `timeout`" -def setdefaulttimeout(timeout: int | float | None, /): +def setdefaulttimeout(timeout: (int | float)?, /): """setdefaulttimeout(timeout) Set the default timeout in seconds (real number) for new socket objects. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_struct.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_struct.byi index 66f024a25d..59c1f4ee65 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_struct.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_struct.byi @@ -31,14 +31,14 @@ from _typeshed import ReadableBuffer, WriteableBuffer from collections.abc import Iterator from typing_extensions import disjoint_base -def pack(fmt: str | bytes, /, *v: dynamic) -> bytes: +def pack(format: str | bytes, /, *values: dynamic) -> bytes: """Pack values and return the packed bytes. Return a bytes object containing the provided values packed according to the format string. See help(struct) for more on format strings. """ -def pack_into(fmt: str | bytes, buffer: WriteableBuffer, offset: int, /, *v: dynamic): +def pack_into(format: str | bytes, buffer: WriteableBuffer, offset: int, /, *values: dynamic): """Pack values and write the packed bytes into the buffer. Pack the provided values according to the format string and write the @@ -86,7 +86,7 @@ class Struct: let size: int init(self, format: str | bytes) - def pack(self, *v: dynamic) -> bytes: + def pack(self, *values: dynamic) -> bytes: """Pack values and return the packed bytes. Return a bytes object containing the provided values packed @@ -94,7 +94,7 @@ class Struct: format strings. """ - def pack_into(self, buffer: WriteableBuffer, offset: int, *v: dynamic): + def pack_into(self, buffer: WriteableBuffer, offset: int, /, *values: dynamic): """Pack values and write the packed bytes into the buffer. Pack the provided values according to the struct format string diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_thread.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_thread.byi index c0792e794f..e5775d5d8b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_thread.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_thread.byi @@ -59,7 +59,9 @@ final class RLock: __enter__ = acquire """Lock the lock.""" - def __exit__(self, t: type[BaseException]?, v: BaseException?, tb: TracebackType?): + def __exit__( + self, exc_type: type[BaseException]?, exc_value: BaseException?, exc_tb: TracebackType?, / + ): """Release the lock.""" if sys.version_info >= (3, 14): @@ -70,7 +72,7 @@ if sys.version_info >= (3, 13): final class _ThreadHandle: ident: int - def join(self, timeout: int | float | None = None, /) + def join(self, timeout: (int | float)? = None, /) def is_done(self) -> bool def _set_done(self) @@ -140,7 +142,7 @@ if sys.version_info >= (3, 13): """Lock the lock.""" def __exit__( - self, type: type[BaseException]?, value: BaseException?, traceback: TracebackType? + self, exc_type: type[BaseException]?, exc_value: BaseException?, exc_tb: TracebackType?, / ): """Release the lock.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.byi index 98f7cc50a0..9f3d0a6874 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.byi @@ -144,6 +144,15 @@ protocol SupportsTrunc: # Mapping-like protocols +# The second and third overload could technically be combined, but splitting +# them works better with some type checkers. +protocol SupportsGet[in KT, out Value]: + def get(self, key: KT, /) -> Value | None + def get( # pyrefly: ignore[invalid-variance] + self, key: KT, default: Value, / + ) -> Value + def get[Element](self, key: KT, default: Element, /) -> Value | Element + # stable protocol SupportsItems[out Key, out Value]: def items(self) -> AbstractSet[(Key, Value)] @@ -169,6 +178,8 @@ protocol SupportsItemAccess[in KT, in out Value]: def __setitem__(self, key: KT, value: Value, /) def __delitem__(self, key: KT, /) +# Path and file handling + type StrPath = str | PathLike[str] # stable type BytesPath = bytes | PathLike[bytes] # stable type GenericPath = AnyStr | PathLike[AnyStr] @@ -278,7 +289,7 @@ StrOrLiteralStr = TypeVar("StrOrLiteralStr", LiteralString, str) # noqa: Y001 type ProfileFunction = (FrameType, Literal["call", "return", "c_call", "c_return", "c_exception"], dynamic) -> object # Objects suitable to be passed to sys.settrace, threading.settrace, and similar -type TraceFunction = (FrameType, Literal["call", "line", "return", "exception", "opcode"], dynamic) -> (TraceFunction?) +type TraceFunction = (FrameType, Literal["call", "line", "return", "exception", "opcode"], dynamic) -> TraceFunction? # experimental # Might not work as expected for pyright, see diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.byi index 6ebf742f32..6b2f0d9304 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.byi @@ -64,11 +64,14 @@ class NamedTupleFallback((*: dynamic)): if sys.version_info >= (3, 12): __orig_bases__: ClassVar[(*: dynamic)] - init(self, typename: str, fields: Iterable[(str, dynamic)], /) - @typing_extensions.deprecated( - "Creating a typing.NamedTuple using keyword arguments is deprecated and support will be removed in Python 3.15" - ) - def __init__(self, typename: str, fields: None = None, /, **kwargs: dynamic) -> None + if sys.version_info >= (3, 15): + def __init__(self, typename: str, fields: Iterable[(str, dynamic)], /) -> None + else: + def __init__(self, typename: str, fields: Iterable[(str, dynamic)], /) -> None + @typing_extensions.deprecated( + "Creating a typing.NamedTuple using keyword arguments is deprecated; support removed in Python 3.15" + ) + def __init__(self, typename: str, fields: None = None, /, **kwargs: dynamic) -> None class def _make(cls, iterable: Iterable[dynamic]) -> typing_extensions.Self def _asdict(self) -> dict[str, dynamic] diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_winapi.byi b/crates/ty_vendored/vendor/typeshed/stdlib/_winapi.byi index 62ff25fa8a..e94bb69b13 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_winapi.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_winapi.byi @@ -440,6 +440,7 @@ if sys.platform == "win32": def NeedCurrentDirectoryForExePath(exe_name: str, /) -> bool - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 13): + # Added in Python 3.13.15, 3.14.7 def GetTickCount64() -> int: """Number of milliseconds that have elapsed since the system was started.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/argparse.byi b/crates/ty_vendored/vendor/typeshed/stdlib/argparse.byi index 661941de79..0306c0c3be 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/argparse.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/argparse.byi @@ -319,7 +319,7 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer): title: str = "subcommands", description: str? = None, prog: str? = None, - action: type[Action] = ..., + action: str | type[Action] = ..., option_string: str = ..., dest: str? = None, required: bool = False, @@ -333,7 +333,7 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer): description: str? = None, prog: str? = None, parser_class: type[ArgumentParserT], - action: type[Action] = ..., + action: str | type[Action] = ..., option_string: str = ..., dest: str? = None, required: bool = False, @@ -422,8 +422,8 @@ class HelpFormatter: _current_indent: int _level: int _action_max_length: int - _root_section: _Section - _current_section: _Section + _root_section: _Section # pyrefly: ignore [unknown-name] + _current_section: _Section # pyrefly: ignore [unknown-name] _whitespace_matcher: Pattern[str] _long_break_matcher: Pattern[str] @@ -630,7 +630,7 @@ if sys.version_info >= (3, 12): help: str? = None, deprecated: bool = False, ) -> None - @deprecated("The `type`, `choices`, and `metavar` parameters are ignored and will be removed in Python 3.14.") + @deprecated("The `type`, `choices`, and `metavar` parameters are ignored; removed in Python 3.14.") def __init__[Element]( self, option_strings: Sequence[str], @@ -653,7 +653,7 @@ if sys.version_info >= (3, 12): required: bool = False, help: str? = None, ) -> None - @deprecated("The `type`, `choices`, and `metavar` parameters are ignored and will be removed in Python 3.14.") + @deprecated("The `type`, `choices`, and `metavar` parameters are ignored; removed in Python 3.14.") def __init__[Element]( self, option_strings: Sequence[str], @@ -677,7 +677,7 @@ else: required: bool = False, help: str? = None, ) - @deprecated("The `type`, `choices`, and `metavar` parameters are ignored and will be removed in Python 3.14.") + @deprecated("The `type`, `choices`, and `metavar` parameters are ignored; removed in Python 3.14.") def __init__[Element]( self, option_strings: Sequence[str], diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.byi index f8c46f8c2b..0efd05d7e1 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.byi @@ -46,8 +46,8 @@ class Server(AbstractServer): protocol_factory: _ProtocolFactory, ssl_context: _SSLContext, backlog: int, - ssl_handshake_timeout: int | float | None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)?, + ssl_shutdown_timeout: (int | float)? = None, ) -> None else: def __init__( @@ -57,7 +57,7 @@ class Server(AbstractServer): protocol_factory: _ProtocolFactory, ssl_context: _SSLContext, backlog: int, - ssl_handshake_timeout: int | float | None, + ssl_handshake_timeout: (int | float)?, ) -> None if sys.version_info >= (3, 13): @@ -181,7 +181,8 @@ class BaseEventLoop(AbstractEventLoop): """Create a Future object attached to the loop.""" # Tasks methods - if sys.version_info >= (3, 14): + # `eager_start` is supported as an arbitrary kwarg starting in 3.13.3. + if sys.version_info >= (3, 13): def create_task[Element]( self, coro: _CoroutineLike[Element], @@ -259,9 +260,9 @@ class BaseEventLoop(AbstractEventLoop): sock: None = None, local_addr: (str, int)? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, all_errors: bool = False, ) -> (Transport, ProtocolT): @@ -289,9 +290,9 @@ class BaseEventLoop(AbstractEventLoop): sock: socket, local_addr: None = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, all_errors: bool = False, ) -> (Transport, ProtocolT) @@ -309,9 +310,9 @@ class BaseEventLoop(AbstractEventLoop): sock: None = None, local_addr: (str, int)? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT): """Connect to a TCP server. @@ -338,9 +339,9 @@ class BaseEventLoop(AbstractEventLoop): sock: socket, local_addr: None = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) else: @@ -357,8 +358,8 @@ class BaseEventLoop(AbstractEventLoop): sock: None = None, local_addr: (str, int)? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT): """Connect to a TCP server. @@ -385,8 +386,8 @@ class BaseEventLoop(AbstractEventLoop): sock: socket, local_addr: None = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) @@ -406,8 +407,8 @@ class BaseEventLoop(AbstractEventLoop): reuse_address: bool? = None, reuse_port: bool? = None, keep_alive: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """Create a TCP server. @@ -439,8 +440,8 @@ class BaseEventLoop(AbstractEventLoop): reuse_address: bool? = None, reuse_port: bool? = None, keep_alive: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server elif sys.version_info >= (3, 11): @@ -457,8 +458,8 @@ class BaseEventLoop(AbstractEventLoop): ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """Create a TCP server. @@ -489,8 +490,8 @@ class BaseEventLoop(AbstractEventLoop): ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server else: @@ -507,7 +508,7 @@ class BaseEventLoop(AbstractEventLoop): ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """Create a TCP server. @@ -538,7 +539,7 @@ class BaseEventLoop(AbstractEventLoop): ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server @@ -551,8 +552,8 @@ class BaseEventLoop(AbstractEventLoop): *, server_side: bool = False, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> Transport?: """Upgrade transport to TLS. @@ -566,8 +567,8 @@ class BaseEventLoop(AbstractEventLoop): sock: socket, *, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> (Transport, ProtocolT) else: override async def start_tls( @@ -578,7 +579,7 @@ class BaseEventLoop(AbstractEventLoop): *, server_side: bool = False, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, ) -> Transport?: """Upgrade transport to TLS. @@ -592,7 +593,7 @@ class BaseEventLoop(AbstractEventLoop): sock: socket, *, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, ) -> (Transport, ProtocolT) override async def sock_sendfile( @@ -776,7 +777,7 @@ class BaseEventLoop(AbstractEventLoop): override def get_debug(self) -> bool override def set_debug(self, enabled: bool) if sys.version_info >= (3, 12): - async def shutdown_default_executor(self, timeout: int | float | None = None) -> None: + async def shutdown_default_executor(self, timeout: (int | float)? = None) -> None: """Schedule the shutdown of the default executor. The timeout parameter specifies the amount of time the executor will diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.byi index 7519b0f094..f6cf352fc6 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.byi @@ -203,7 +203,8 @@ class AbstractEventLoop: # Future methods abstract def create_future(self) -> Future[dynamic] # Tasks methods - if sys.version_info >= (3, 14): + # `eager_start` is supported as an arbitrary kwarg starting in 3.13.3. + if sys.version_info >= (3, 13): abstract def create_task[Element]( self, coro: _CoroutineLike[Element], @@ -255,9 +256,9 @@ class AbstractEventLoop: sock: None = None, local_addr: (str, int)? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) abstract async def create_connection[ProtocolT: BaseProtocol]( @@ -273,9 +274,9 @@ class AbstractEventLoop: sock: socket, local_addr: None = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) else: @@ -292,8 +293,8 @@ class AbstractEventLoop: sock: None = None, local_addr: (str, int)? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) abstract async def create_connection[ProtocolT: BaseProtocol]( @@ -309,8 +310,8 @@ class AbstractEventLoop: sock: socket, local_addr: None = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - happy_eyeballs_delay: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + happy_eyeballs_delay: (int | float)? = None, interleave: int? = None, ) -> (Transport, ProtocolT) @@ -330,8 +331,8 @@ class AbstractEventLoop: reuse_address: bool? = None, reuse_port: bool? = None, keep_alive: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """A coroutine which creates a TCP server bound to host and port. @@ -400,8 +401,8 @@ class AbstractEventLoop: reuse_address: bool? = None, reuse_port: bool? = None, keep_alive: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server elif sys.version_info >= (3, 11): @@ -418,8 +419,8 @@ class AbstractEventLoop: ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """A coroutine which creates a TCP server bound to host and port. @@ -483,8 +484,8 @@ class AbstractEventLoop: ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server else: @@ -501,7 +502,7 @@ class AbstractEventLoop: ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """A coroutine which creates a TCP server bound to host and port. @@ -561,7 +562,7 @@ class AbstractEventLoop: ssl: _SSLContext = None, reuse_address: bool? = None, reuse_port: bool? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server @@ -574,8 +575,8 @@ class AbstractEventLoop: *, server_side: bool = False, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> Transport?: """Upgrade a transport to TLS. @@ -591,8 +592,8 @@ class AbstractEventLoop: sock: socket? = None, backlog: int = 100, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """A coroutine which creates a UNIX Domain Socket server. @@ -633,7 +634,7 @@ class AbstractEventLoop: *, server_side: bool = False, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, ) -> Transport?: """Upgrade a transport to TLS. @@ -649,7 +650,7 @@ class AbstractEventLoop: sock: socket? = None, backlog: int = 100, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, start_serving: bool = True, ) -> Server: """A coroutine which creates a UNIX Domain Socket server. @@ -685,8 +686,8 @@ class AbstractEventLoop: sock: socket, *, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> (Transport, ProtocolT): """Handle an accepted connection. @@ -704,7 +705,7 @@ class AbstractEventLoop: sock: socket, *, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, ) -> (Transport, ProtocolT): """Handle an accepted connection. @@ -724,8 +725,8 @@ class AbstractEventLoop: ssl: _SSLContext = None, sock: socket? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> (Transport, ProtocolT) else: async def create_unix_connection[ProtocolT: BaseProtocol]( @@ -736,7 +737,7 @@ class AbstractEventLoop: ssl: _SSLContext = None, sock: socket? = None, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, ) -> (Transport, ProtocolT) abstract async def sock_sendfile( diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/protocols.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/protocols.byi index cca46aae55..a1b30040e3 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/protocols.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/protocols.byi @@ -166,11 +166,9 @@ class DatagramProtocol(BaseProtocol): When the connection is closed, connection_lost() is called. """ - # addr can be a tuple[int, int] for some unusual protocols like socket.AF_NETLINK. - # Use tuple[str | Any, int] to not cause typechecking issues on most usual cases. - # This could be improved by using tuple[AnyOf[str, int], int] if the AnyOf feature is accepted. - # See https://github.com/python/typing/issues/566 - def datagram_received(self, data: bytes, addr: (str | dynamic, int)): + # addr is a tuple[str, int] for IPv4 or tuple[str, int, int, int] for IPv6. + # It can also be a tuple[int, int] for unusual protocols like socket.AF_NETLINK. + def datagram_received(self, data: bytes, addr: (*: dynamic)): """Called when some datagram is received.""" def error_received(self, exc: Exception): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/sslproto.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/sslproto.byi index fb10072c69..cf3c476baf 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/sslproto.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/sslproto.byi @@ -241,7 +241,7 @@ class SSLProtocol(_SSLProtocolBase): server_hostname: str? = None, call_connection_made: bool = True, ssl_handshake_timeout: int? = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> None else: def __init__( diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/staggered.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/staggered.byi index 19bfe55b88..b6a964af08 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/staggered.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/staggered.byi @@ -7,7 +7,7 @@ from . import events __all__ = ("staggered_race",) async def staggered_race( - coro_fns: Iterable[() -> Awaitable[dynamic]], delay: int | float | None, *, loop: events.AbstractEventLoop? = None + coro_fns: Iterable[() -> Awaitable[dynamic]], delay: (int | float)?, *, loop: events.AbstractEventLoop? = None ) -> (dynamic, int?, list[Exception?]): """Run coroutines with staggered start times and take the first to finish. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/streams.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/streams.byi index 96f515e3a6..39a730b8d6 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/streams.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/streams.byi @@ -23,7 +23,7 @@ else: "start_unix_server", ) -private type ClientConnectedCallback = (StreamReader, StreamWriter) -> (Awaitable[None]?) +private type ClientConnectedCallback = (StreamReader, StreamWriter) -> Awaitable[None]? @type_check_only private protocol ReaduntilBuffer(ReadableBuffer, Sized) @@ -33,7 +33,7 @@ async def open_connection( port: int | str | None = None, *, limit: int = 65536, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, **kwds: dynamic, ) -> (StreamReader, StreamWriter): """A wrapper for create_connection() returning a (reader, writer) pair. @@ -60,7 +60,7 @@ async def start_server( port: int | str | None = None, *, limit: int = 65536, - ssl_handshake_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, **kwds: dynamic, ) -> Server: """Start a socket server, call back for each client connected. @@ -165,14 +165,14 @@ class StreamWriter: sslcontext: ssl.SSLContext, *, server_hostname: str? = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, ) -> None: """Upgrade an existing stream-based connection to TLS.""" elif sys.version_info >= (3, 11): async def start_tls( - self, sslcontext: ssl.SSLContext, *, server_hostname: str? = None, ssl_handshake_timeout: int | float | None = None + self, sslcontext: ssl.SSLContext, *, server_hostname: str? = None, ssl_handshake_timeout: (int | float)? = None ) -> None: """Upgrade an existing stream-based connection to TLS.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/tasks.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/tasks.byi index fe41d90262..61210e435a 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/tasks.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/tasks.byi @@ -84,7 +84,7 @@ if sys.version_info >= (3, 13): @type_check_only private protocol SyncAndAsyncIterator[in out Element](Iterator[Coroutine[dynamic, dynamic, Element]], AsyncIterator[Future[Element]]) - def as_completed[Element](fs: Iterable[_FutureLike[Element]], *, timeout: int | float | None = None) -> SyncAndAsyncIterator[Element]: + def as_completed[Element](fs: Iterable[_FutureLike[Element]], *, timeout: (int | float)? = None) -> SyncAndAsyncIterator[Element]: """Create an iterator of awaitables or their results in completion order. Run the supplied awaitables concurrently. The returned object can be @@ -134,7 +134,7 @@ if sys.version_info >= (3, 13): """ else: - def as_completed[Element](fs: Iterable[_FutureLike[Element]], *, timeout: int | float | None = None) -> Iterator[Future[Element]]: + def as_completed[Element](fs: Iterable[_FutureLike[Element]], *, timeout: (int | float)? = None) -> Iterator[Future[Element]]: """Return an iterator whose values are coroutines. When waiting for the yielded coroutines you'll get the results (or @@ -336,7 +336,7 @@ async def sleep(delay: int | float) -> None: """Coroutine that completes after a given time (in seconds).""" async def sleep[Element](delay: int | float, result: Element) -> Element -async def wait_for[Element](fut: _FutureLike[Element], timeout: int | float | None) -> Element: +async def wait_for[Element](fut: _FutureLike[Element], timeout: (int | float)?) -> Element: """Wait for the single Future or coroutine to complete, with timeout. Coroutine will be wrapped in Task. @@ -355,7 +355,7 @@ async def wait_for[Element](fut: _FutureLike[Element], timeout: int | float | No if sys.version_info >= (3, 11): async def wait[FT: Future[dynamic]]( - fs: Iterable[FT], *, timeout: int | float | None = None, return_when: str = "ALL_COMPLETED" + fs: Iterable[FT], *, timeout: (int | float)? = None, return_when: str = "ALL_COMPLETED" ) -> (set[FT], set[FT]): """Wait for the Futures or Tasks given by fs to complete. @@ -373,7 +373,7 @@ if sys.version_info >= (3, 11): else: async def wait[FT: Future[dynamic]]( - fs: Iterable[FT], *, timeout: int | float | None = None, return_when: str = "ALL_COMPLETED" + fs: Iterable[FT], *, timeout: (int | float)? = None, return_when: str = "ALL_COMPLETED" ) -> (set[FT], set[FT]): """Wait for the Futures and coroutines given by fs to complete. @@ -391,7 +391,7 @@ else: when the timeout occurs are returned in the second set. """ async def wait[Element]( - fs: Iterable[Awaitable[Element]], *, timeout: int | float | None = None, return_when: str = "ALL_COMPLETED" + fs: Iterable[Awaitable[Element]], *, timeout: (int | float)? = None, return_when: str = "ALL_COMPLETED" ) -> (set[Task[Element]], set[Task[Element]]) if sys.version_info >= (3, 12): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/timeouts.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/timeouts.byi index 7d39bf0a92..9c4ad78ab2 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/timeouts.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/timeouts.byi @@ -12,7 +12,7 @@ final class Timeout: directly. """ - init(self, when: int | float | None): + init(self, when: (int | float)?): """Schedule a timeout that will trigger at a given loop time. - If `when` is `None`, the timeout will never trigger. @@ -20,10 +20,10 @@ final class Timeout: iteration of the event loop. """ - def when(self) -> int | float | None: + def when(self) -> (int | float)?: """Return the current deadline.""" - def reschedule(self, when: int | float | None): + def reschedule(self, when: (int | float)?): """Reschedule the timeout.""" def expired(self) -> bool: @@ -34,7 +34,7 @@ final class Timeout: self, exc_type: type[BaseException]?, exc_val: BaseException?, exc_tb: TracebackType? ) -> None -def timeout(delay: int | float | None) -> Timeout: +def timeout(delay: (int | float)?) -> Timeout: """Timeout async context manager. Useful in cases when you want to apply timeout logic around block @@ -51,7 +51,7 @@ def timeout(delay: int | float | None) -> Timeout: into TimeoutError. """ -def timeout_at(when: int | float | None) -> Timeout: +def timeout_at(when: (int | float)?) -> Timeout: """Schedule the timeout at absolute time. Like timeout() but argument gives absolute time in the same clock system diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/trsock.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/trsock.byi index e7f8e5cee0..50d3a06e35 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/trsock.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/trsock.byi @@ -41,7 +41,7 @@ class TransportSocket: def getpeername(self) -> _RetAddress def getsockname(self) -> _RetAddress def getsockbyname(self) -> Never # This method doesn't exist on socket, yet is passed through? - def settimeout(self, value: int | float | None) + def settimeout(self, value: (int | float)?) def gettimeout(self) -> float? def setblocking(self, flag: bool) if sys.version_info < (3, 11): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/unix_events.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/unix_events.byi index 7759d596d2..eba087811b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/unix_events.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/unix_events.byi @@ -320,8 +320,8 @@ if sys.platform != "win32": sock: socket? = None, backlog: int = 100, ssl: _SSLContext = None, - ssl_handshake_timeout: int | float | None = None, - ssl_shutdown_timeout: int | float | None = None, + ssl_handshake_timeout: (int | float)? = None, + ssl_shutdown_timeout: (int | float)? = None, start_serving: bool = True, cleanup_socket: bool = True, ) -> Server diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/windows_events.byi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/windows_events.byi index 2f4ceef24e..0d795fd693 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/windows_events.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/windows_events.byi @@ -85,7 +85,7 @@ if sys.platform == "win32": def recv_into(self, conn: socket.socket, buf: WriteableBuffer, flags: int = 0) -> futures.Future[dynamic] def recvfrom( self, conn: socket.socket, nbytes: int, flags: int = 0 - ) -> futures.Future[tuple[bytes, socket._RetAddress]] + ) -> futures.Future[(bytes, socket._RetAddress)] def sendto( self, conn: socket.socket, buf: ReadableBuffer, flags: int = 0, addr: socket._Address? = None ) -> futures.Future[int] @@ -110,7 +110,7 @@ if sys.platform == "win32": if sys.version_info >= (3, 11): def recvfrom_into( self, conn: socket.socket, buf: WriteableBuffer, flags: int = 0 - ) -> futures.Future[tuple[int, socket._RetAddress]] + ) -> futures.Future[(int, socket._RetAddress)] SelectorEventLoop = _WindowsSelectorEventLoop diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/base64.byi b/crates/ty_vendored/vendor/typeshed/stdlib/base64.byi index 5945a23a79..a04afdcdbf 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/base64.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/base64.byi @@ -241,7 +241,7 @@ else: Optional casefold is a flag specifying whether a lowercase alphabet is acceptable as input. For security purposes, the default is False. - RFC 3548 allows for optional mapping of the digit 0 (zero) to the + RFC 4648 allows for optional mapping of the digit 0 (zero) to the letter O (oh), and for optional mapping of the digit 1 (one) to either the letter I (eye) or letter L (el). The optional argument map01 when not None, specifies which letter the digit 1 should be diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.byi b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.byi index 7f1e564a80..60be2e8073 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.byi @@ -218,7 +218,7 @@ class type: def __call__(self, *args: dynamic, **kwds: dynamic) -> dynamic: """Call self as a function.""" - def __subclasses__(self: _typeshed.Self) -> list[_typeshed.Self]: + def __subclasses__[Element](self: type[Element]) -> list[type[Element]]: """Return a list of immediate subclasses.""" # Note: the documentation doesn't specify what the return type is, the standard @@ -2427,7 +2427,7 @@ final class memoryview[in out I = int](Sequence[I]): """Set self[key] to value.""" def __setitem__(self, key: SupportsIndex | (*: SupportsIndex), value: I, /) -> None - def tobytes(self, order: "C" | "F" | "A" | None = "C") -> bytes: + def tobytes(self, order: "C" | "F" | "A"? = "C") -> bytes: """Return the data in the buffer as a byte string. Order can be {'C', 'F', 'A'}. When order is 'C' or 'F', the data of @@ -2824,7 +2824,7 @@ class dict[in out Key: Hashable, in out Value](MutableMapping[Key, Value]): def get[Element](self, key: Overlapping[Key], default: Element, /) -> Value | Element @ignorable_return_value - override def pop(self, key: Key, /) -> Value: + def pop(self, key: Key, /) -> Value: """D.pop(k[,d]) -> v, remove specified key and return the corresponding value. If the key is not found, return the default if given; otherwise, @@ -2833,7 +2833,7 @@ class dict[in out Key: Hashable, in out Value](MutableMapping[Key, Value]): @ignorable_return_value def pop(self, key: object, default: Value, /) -> Value @ignorable_return_value - def pop[Element](self, key: object, default: Element, /) -> Value | Element + override def pop[Element](self, key: object, default: Element, /) -> Value | Element override def __len__(self) -> int: """Return len(self).""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/cmd.byi b/crates/ty_vendored/vendor/typeshed/stdlib/cmd.byi index 4dda747964..e02e3922fa 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/cmd.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/cmd.byi @@ -90,7 +90,7 @@ class Cmd: """ - old_completer: ((str, int) -> (str?))? + old_completer: ((str, int) -> str?)? def cmdloop(self, intro: dynamic? = None): """Repeatedly issue a prompt, accept input, parse an initial prefix off the received input, and dispatch to action methods, passing them diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/collections/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/collections/__init__.byi index ca4ef798e2..151371a8de 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/collections/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/collections/__init__.byi @@ -349,6 +349,9 @@ class Counter[in out Element](dict[Element, int]): or multiset. Elements are stored as dictionary keys and their counts are stored as dictionary values. + When constructed from a Mapping or Counter, the original object's + values will be used as the initial counts. + >>> c = Counter('abcdeabcdabcaba') # count elements from a string >>> c.most_common(3) # three most common elements diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/compression/zstd/_zstdfile.byi b/crates/ty_vendored/vendor/typeshed/stdlib/compression/zstd/_zstdfile.byi index f4a89872f0..a84a0663dd 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/compression/zstd/_zstdfile.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/compression/zstd/_zstdfile.byi @@ -47,7 +47,7 @@ class ZstdFile(_streams.BaseStream): ): """Open a Zstandard compressed file in binary mode. - *file* can be either an file-like object, or a file name to open. + *file* can be either a file-like object, or a file name to open. *mode* can be 'r' for reading (default), 'w' for (over)writing, 'x' for creating exclusively, or 'a' for appending. These can diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/_base.byi b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/_base.byi index 7325127ace..9e0fbe3dec 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/_base.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/_base.byi @@ -35,7 +35,7 @@ class InvalidStateError(Error): class BrokenExecutor(RuntimeError): """ - Raised when a executor has become non-functional after a severe failure. + Raised when an executor has become non-functional after a severe failure. """ @@ -76,7 +76,7 @@ class Future[in out Element]: order that they were added. """ - def result(self, timeout: int | float | None = None) -> Element: + def result(self, timeout: (int | float)? = None) -> Element: """Return the result of the call that the future represents. Args: @@ -126,7 +126,7 @@ class Future[in out Element]: Should only be used by Executor implementations and unit tests. """ - def exception(self, timeout: int | float | None = None) -> BaseException?: + def exception(self, timeout: (int | float)? = None) -> BaseException?: """Return the exception raised by the call that the future represents. Args: @@ -176,7 +176,7 @@ class Executor: self, fn: (...) -> Element, *iterables: Iterable[dynamic], - timeout: int | float | None = None, + timeout: (int | float)? = None, chunksize: int = 1, buffersize: int? = None, ) -> Iterator[Element]: @@ -209,7 +209,7 @@ class Executor: else: def map[Element]( - self, fn: (...) -> Element, *iterables: Iterable[dynamic], timeout: int | float | None = None, chunksize: int = 1 + self, fn: (...) -> Element, *iterables: Iterable[dynamic], timeout: (int | float)? = None, chunksize: int = 1 ) -> Iterator[Element]: """Returns an iterator equivalent to map(fn, iter). @@ -262,9 +262,9 @@ private protocol AsCompletedFuture[out Element]: _state: str _waiters: list[_Waiter] # Not used by as_completed, but needed to propagate the generic type - def result(self, timeout: int | float | None = None) -> Element + def result(self, timeout: (int | float)? = None) -> Element -def as_completed[Element](fs: Iterable[AsCompletedFuture[Element]], timeout: int | float | None = None) -> Iterator[Future[Element]]: +def as_completed[Element](fs: Iterable[AsCompletedFuture[Element]], timeout: (int | float)? = None) -> Iterator[Future[Element]]: """An iterator over the given futures that yields each as it completes. Args: @@ -289,7 +289,7 @@ class DoneAndNotDoneFutures[in out Element](NamedTuple): done: set[Future[Element]] not_done: set[Future[Element]] -def wait[Element](fs: Iterable[Future[Element]], timeout: int | float | None = None, return_when: str = "ALL_COMPLETED") -> DoneAndNotDoneFutures[Element]: +def wait[Element](fs: Iterable[Future[Element]], timeout: (int | float)? = None, return_when: str = "ALL_COMPLETED") -> DoneAndNotDoneFutures[Element]: """Wait for the futures in the given sequence to complete. Args: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/process.byi b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/process.byi index 29162b4dc6..271fc898d9 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/process.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/process.byi @@ -236,7 +236,8 @@ class _ExecutorManagerThread(Thread): def process_result_item(self, result_item: int | _ResultItem) def is_shutting_down(self) -> bool - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 14): + # bpe_message parameter added in 3.14.7 def terminate_broken(self, cause: str, bpe_message: str? = None) -> None else: def terminate_broken(self, cause: str) -> None diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/thread.byi b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/thread.byi index b6bffaefe8..18f6e17d70 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/thread.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/concurrent/futures/thread.byi @@ -70,7 +70,8 @@ else: def __class_getitem__(cls, item: dynamic, /) -> GenericAlias: """Represent a PEP 585 generic type - E.g. for t = list[int], t.__origin__ is list and t.__args__ is (int,). + For example, for t = list[int], t.__origin__ is list and t.__args__ + is (int,). """ def _worker[*Args]( diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/configparser.byi b/crates/ty_vendored/vendor/typeshed/stdlib/configparser.byi index 57c025e30d..f19b041569 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/configparser.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/configparser.byi @@ -145,7 +145,7 @@ ConfigParser -- responsible for parsing a list of import sys from _typeshed import BytesPath, GenericPath, MaybeNone, StrOrBytesPath, StrPath, SupportsWrite -from collections.abc import Callable, ItemsView, Iterable, Iterator, Mapping, MutableMapping, Sequence +from collections.abc import Callable, ItemsView, Iterable, Iterator, Mapping, MutableMapping, Sequence, ValuesView from re import Pattern from typing import ClassVar, Final, Literal, TypeAlias, TypeVar, type_check_only from typing_extensions import deprecated @@ -561,6 +561,17 @@ class RawConfigParser(_Parser): """ def items(self, section: _SectionName, raw: bool = False, vars: _Section? = None) -> list[(str, str)] + override def values(self) -> ValuesView[SectionProxy]: + """D.values() -> an object providing a view on D's values""" + + override def popitem(self) -> (str, SectionProxy): + """Remove a section from the parser and return it as + a (section_name, section_proxy) tuple. If no section is present, raise + KeyError. + + The section DEFAULT is never returned because it cannot be removed. + """ + def set(self, section: _SectionName, option: str, value: str? = None): """Set an option.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/contextlib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/contextlib.byi index 17fcea1839..5f4536c6fa 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/contextlib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/contextlib.byi @@ -30,7 +30,7 @@ if sys.version_info >= (3, 11): -private type ExitFunc = (type[BaseException]?, BaseException?, TracebackType?) -> (bool?) +private type ExitFunc = (type[BaseException]?, BaseException?, TracebackType?) -> bool? # mypy and pyright object to this being both ABC and Protocol. # At runtime it inherits from ABC and is not a Protocol, but it is on the diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/copy.byi b/crates/ty_vendored/vendor/typeshed/stdlib/copy.byi index 55bd6431ca..59979419d4 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/copy.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/copy.byi @@ -63,19 +63,27 @@ private protocol SupportsReplace[out RT]: # None in CPython but non-None in Jython PyStringMap: dynamic -# Note: memo and _nil are internal kwargs. -def deepcopy[Element](x: Element, memo: dict[int, dynamic]? = None, _nil: dynamic = []) -> Element: - """Deep copy operation on arbitrary Python objects. - - See the module's __doc__ string for more info. - """ - def copy[Element](x: Element) -> Element: """Shallow copy operation on arbitrary Python objects. See the module's __doc__ string for more info. """ +if sys.version_info >= (3, 15): + def deepcopy[Element](x: Element, memo: dict[int, dynamic]? = None) -> Element: + """Deep copy operation on arbitrary Python objects. + + See the module's __doc__ string for more info. + """ + +else: + # Note: memo and _nil are internal kwargs. + def deepcopy[Element](x: Element, memo: dict[int, dynamic]? = None, _nil: dynamic = []) -> Element: + """Deep copy operation on arbitrary Python objects. + + See the module's __doc__ string for more info. + """ + if sys.version_info >= (3, 13): __all__ += ["replace"] # The types accepted by `**changes` match those of `obj.__replace__`. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/csv.byi b/crates/ty_vendored/vendor/typeshed/stdlib/csv.byi index 0e92648c40..ecb8c8733b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/csv.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/csv.byi @@ -241,6 +241,10 @@ class Sniffer: def sniff(self, sample: str, delimiters: str? = None) -> type[Dialect]: """ Returns a dialect (or None) corresponding to the sample + + If several delimiters fit the sample equally well, the + delimiters listed in the preferred attribute are preferred, in + that order, no matter how many times each of them occurs. """ def has_header(self, sample: str) -> bool diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.byi index fc19e1ca8b..a878f14875 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.byi @@ -229,7 +229,7 @@ def create_unicode_buffer(init: int | str, size: int? = None) -> Array[c_wchar]: """ if sys.version_info < (3, 15): - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def SetPointerType(pointer: type[_Pointer[dynamic]], cls: _CTypeBaseType) -> None @deprecated("Soft deprecated. Use multiplication instead.") diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/util.byi b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/util.byi index bb75f56d9f..35462a8a60 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/util.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/util.byi @@ -8,6 +8,6 @@ if sys.platform == "win32": if sys.version_info >= (3, 14): def dllist() -> list[str]: - """Return a list of loaded shared libraries in the current process.""" + """dllist() return a list of loaded shared libraries""" def test() diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/decimal.byi b/crates/ty_vendored/vendor/typeshed/stdlib/decimal.byi index 301b62187e..6ddd3ee2df 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/decimal.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/decimal.byi @@ -755,7 +755,7 @@ class Context: """Set all traps to False.""" def copy(self) -> Context: - """Return a duplicate of the context with all flags cleared.""" + """Return a duplicate of the context.""" def __copy__(self) -> Context # see https://github.com/python/cpython/issues/94107 diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/difflib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/difflib.byi index 3c734e3e6b..32b675f438 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/difflib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/difflib.byi @@ -540,7 +540,7 @@ class Differ: """ if sys.version_info >= (3, 14): - def IS_LINE_JUNK(line: str, pat: ((str) -> (re.Match[str]?))? = None) -> bool: + def IS_LINE_JUNK(line: str, pat: ((str) -> re.Match[str]?)? = None) -> bool: """ Return True for ignorable line: if `line` is blank or contains a single '#'. @@ -555,7 +555,7 @@ if sys.version_info >= (3, 14): """ else: - def IS_LINE_JUNK(line: str, pat: (str) -> (re.Match[str]?) = ...) -> bool: + def IS_LINE_JUNK(line: str, pat: (str) -> re.Match[str]? = ...) -> bool: """ Return True for ignorable line: iff `line` is blank or contains a single '#'. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/distutils/cygwinccompiler.byi b/crates/ty_vendored/vendor/typeshed/stdlib/distutils/cygwinccompiler.byi index c008bd8163..abaa7d862d 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/distutils/cygwinccompiler.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/distutils/cygwinccompiler.byi @@ -46,7 +46,7 @@ def check_config_h() -> ("ok" | "not ok" | "uncertain", str): final RE_VERSION: Pattern[bytes] -def get_versions() -> (*: LooseVersion | None): +def get_versions() -> (*: LooseVersion?): """Try to find out the versions of gcc, ld and dllwrap. If not possible it returns None for it. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/doctest.byi b/crates/ty_vendored/vendor/typeshed/stdlib/doctest.byi index 194d0a4287..a8eddac7c8 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/doctest.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/doctest.byi @@ -423,6 +423,12 @@ class DocTestRunner: more information. """ + if sys.version_info >= (3, 15): + def report_skip(self, out: Out, test: DocTest, example: Example): + """ + Report that the given example was skipped. + """ + def report_start(self, out: Out, test: DocTest, example: Example): """ Report that the test runner is about to process the given diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/email/utils.byi b/crates/ty_vendored/vendor/typeshed/stdlib/email/utils.byi index b7abeae03a..fbce70ac3e 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/email/utils.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/email/utils.byi @@ -90,7 +90,7 @@ def parsedate_to_datetime(data: str) -> datetime.datetime def mktime_tz(data: PDTZ) -> int: """Turn a 10-tuple as returned by parsedate_tz() into a POSIX timestamp.""" -def formatdate(timeval: int | float | None = None, localtime: bool = False, usegmt: bool = False) -> str: +def formatdate(timeval: (int | float)? = None, localtime: bool = False, usegmt: bool = False) -> str: """Returns a date string as specified by RFC 2822, e.g.: Fri, 09 Nov 2001 01:08:47 -0000 @@ -137,7 +137,7 @@ elif sys.version_info >= (3, 12): The isdst parameter is ignored. """ - @deprecated("The `isdst` parameter does nothing and will be removed in Python 3.14.") + @deprecated("The `isdst` parameter is ignored; removed in Python 3.14.") def localtime(dt: datetime.datetime? = None, isdst: Unused = None) -> datetime.datetime else: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/enum.byi b/crates/ty_vendored/vendor/typeshed/stdlib/enum.byi index 34b103eca4..cd7a143a74 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/enum.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/enum.byi @@ -155,7 +155,7 @@ class EnumMeta(type): """ elif sys.version_info >= (3, 11): - def __contains__(self: type[dynamic], member: object) -> bool: + def __contains__(self: type[dynamic], member: Enum) -> bool: """ Return True if member is a member of this enum raises TypeError if member is not an enum member @@ -165,7 +165,7 @@ class EnumMeta(type): """ else: - def __contains__(self: type[dynamic], obj: object) -> bool + def __contains__(self: type[dynamic], obj: Enum) -> bool def __getitem__[EnumMemberT](self: type[EnumMemberT], name: str) -> EnumMemberT: """ diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ftplib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/ftplib.byi index 0fddef9e5b..56c4512a11 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ftplib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ftplib.byi @@ -84,7 +84,7 @@ class FTP: sock: socket? welcome: str? passiveserver: int - timeout: int | float | None + timeout: (int | float)? af: int lastresp: str file: TextIO? @@ -100,7 +100,7 @@ class FTP: user: str = "", passwd: str = "", acct: str = "", - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, *, encoding: str = "utf-8", @@ -370,7 +370,7 @@ class FTP_TLS(FTP): acct: str = "", *, context: SSLContext? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, encoding: str = "utf-8", ) -> None @@ -384,7 +384,7 @@ class FTP_TLS(FTP): keyfile: None = None, certfile: None = None, context: SSLContext? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, *, encoding: str = "utf-8", @@ -402,7 +402,7 @@ class FTP_TLS(FTP): keyfile: StrOrBytesPath? = None, certfile: StrOrBytesPath? = None, context: None = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, *, encoding: str = "utf-8", diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/functools.byi b/crates/ty_vendored/vendor/typeshed/stdlib/functools.byi index b2fda93b25..53e7c068df 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/functools.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/functools.byi @@ -112,7 +112,7 @@ final class _lru_cache_wrapper[Fn: (...) -> object]: __name__: str __qualname__: str -def lru_cache[Fn: (...) -> object](maxsize: int? = 128, typed: bool = False) -> (Fn) -> _lru_cache_wrapper[Fn]: +def lru_cache[Element](maxsize: int? = 128, typed: bool = False) -> ((...) -> Element) -> _lru_cache_wrapper[Element]: """Least-recently-used cache decorator. If *maxsize* is set to None, the LRU features are disabled and the cache diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/glob.byi b/crates/ty_vendored/vendor/typeshed/stdlib/glob.byi index eee03ef9a2..4e0eec9260 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/glob.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/glob.byi @@ -11,13 +11,9 @@ if sys.version_info >= (3, 13): __all__ += ["translate"] if sys.version_info < (3, 15): - @deprecated( - "Deprecated since Python 3.10; will be removed in Python 3.15. Use `glob.glob()` with the *root_dir* argument instead." - ) + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `glob.glob()` with the *root_dir* argument instead.") def glob0(dirname: AnyStr, pattern: AnyStr) -> list[AnyStr] - @deprecated( - "Deprecated since Python 3.10; will be removed in Python 3.15. Use `glob.glob()` with the *root_dir* argument instead." - ) + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `glob.glob()` with the *root_dir* argument instead.") def glob1(dirname: AnyStr, pattern: AnyStr) -> list[AnyStr] if sys.version_info >= (3, 11): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/gzip.byi b/crates/ty_vendored/vendor/typeshed/stdlib/gzip.byi index 685e4ac940..087a84d8fe 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/gzip.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/gzip.byi @@ -137,7 +137,7 @@ class GzipFile(BaseStream): mode: ReadBinaryMode, compresslevel: int = 9, fileobj: _ReadableFileobj? = None, - mtime: int | float | None = None, + mtime: (int | float)? = None, ): """Constructor for the GzipFile class. @@ -181,7 +181,7 @@ class GzipFile(BaseStream): mode: ReadBinaryMode, compresslevel: int = 9, fileobj: _ReadableFileobj? = None, - mtime: int | float | None = None, + mtime: (int | float)? = None, ) init( self, @@ -189,7 +189,7 @@ class GzipFile(BaseStream): mode: WriteBinaryMode, compresslevel: int = 9, fileobj: _WritableFileobj? = None, - mtime: int | float | None = None, + mtime: (int | float)? = None, ) init( self, @@ -197,7 +197,7 @@ class GzipFile(BaseStream): mode: WriteBinaryMode, compresslevel: int = 9, fileobj: _WritableFileobj? = None, - mtime: int | float | None = None, + mtime: (int | float)? = None, ) init( self, @@ -205,7 +205,7 @@ class GzipFile(BaseStream): mode: str? = None, compresslevel: int = 9, fileobj: _ReadableFileobj | _WritableFileobj | None = None, - mtime: int | float | None = None, + mtime: (int | float)? = None, ) if sys.version_info < (3, 12): @@ -268,7 +268,7 @@ elif sys.version_info >= (3, 14): """ else: - def compress(data: SizedBuffer, compresslevel: int = 9, *, mtime: int | float | None = None) -> bytes: + def compress(data: SizedBuffer, compresslevel: int = 9, *, mtime: (int | float)? = None) -> bytes: """Compress data in one shot and return the compressed string. compresslevel sets the compression level in range of 0-9. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/http/client.byi b/crates/ty_vendored/vendor/typeshed/stdlib/http/client.byi index 6e24adbd7a..998d1b1951 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/http/client.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/http/client.byi @@ -312,7 +312,7 @@ class HTTPConnection: debuglevel: int default_port: int # undocumented response_class: type[HTTPResponse] # undocumented - timeout: int | float | None + timeout: (int | float)? host: str port: int sock: socket | MaybeNone # can be `None` if `.connect()` was not called @@ -321,7 +321,7 @@ class HTTPConnection: self, host: str, port: int? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, blocksize: int = 8192, *, @@ -332,7 +332,7 @@ class HTTPConnection: self, host: str, port: int? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, blocksize: int = 8192, ) -> None @@ -441,7 +441,7 @@ class HTTPSConnection(HTTPConnection): host: str, port: int? = None, *, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, context: ssl.SSLContext? = None, blocksize: int = 8192, @@ -453,7 +453,7 @@ class HTTPSConnection(HTTPConnection): host: str, port: int? = None, *, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, context: ssl.SSLContext? = None, blocksize: int = 8192, @@ -465,7 +465,7 @@ class HTTPSConnection(HTTPConnection): port: int? = None, key_file: None = None, cert_file: None = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, *, context: ssl.SSLContext? = None, @@ -482,7 +482,7 @@ class HTTPSConnection(HTTPConnection): port: int? = None, key_file: StrOrBytesPath? = None, cert_file: StrOrBytesPath? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: (str, int)? = None, *, context: ssl.SSLContext? = None, diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/http/cookies.byi b/crates/ty_vendored/vendor/typeshed/stdlib/http/cookies.byi index 9e366e35d6..3d15759cfd 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/http/cookies.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/http/cookies.byi @@ -92,6 +92,7 @@ from _typeshed import MaybeNone, SupportsItems, SupportsKeysAndGetItem from collections.abc import Container, Iterable from types import GenericAlias from typing import Generic, TypeVar +from typing_extensions import deprecated __all__ = ["CookieError", "BaseCookie", "SimpleCookie"] @@ -130,6 +131,7 @@ class Morsel[in out Element](dict[str, dynamic]): def isReservedKey(self, K: str) -> bool def output(self, attrs: Container[str]? = None, header: str = "Set-Cookie:") -> str __str__ = output + @deprecated("Deprecated; will be removed in Python 3.19. Use `output()` instead.") def js_output(self, attrs: Container[str]? = None) -> str def OutputString(self, attrs: Container[str]? = None) -> str override def __eq__(self, morsel: object) -> bool @@ -164,6 +166,7 @@ class BaseCookie[in out Element](dict[str, Morsel[Element]]): """Return a string suitable for HTTP.""" __str__ = output + @deprecated("Deprecated; will be removed in Python 3.19. Use `output()` instead.") def js_output(self, attrs: Container[str]? = None) -> str: """Return a string suitable for JavaScript.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/http/server.byi b/crates/ty_vendored/vendor/typeshed/stdlib/http/server.byi index d40bd2227b..5b87372017 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/http/server.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/http/server.byi @@ -294,7 +294,7 @@ class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): def version_string(self) -> str: """Return the server software version string.""" - def date_time_string(self, timestamp: int | float | None = None) -> str: + def date_time_string(self, timestamp: (int | float)? = None) -> str: """Return the current date and time formatted for a message header.""" def log_date_time_string(self) -> str: @@ -421,7 +421,7 @@ def executable(path: StrPath) -> bool: # undocumented """Test for executable file.""" if sys.version_info < (3, 15): - @deprecated("Deprecated and unsafe; will be removed in Python 3.15.") + @deprecated("Deprecated and unsafe; removed in Python 3.15.") class CGIHTTPRequestHandler(SimpleHTTPRequestHandler): """Complete HTTP server with GET, HEAD and POST commands. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/imaplib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/imaplib.byi index b8f9426384..37c5eac739 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/imaplib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/imaplib.byi @@ -100,8 +100,8 @@ class IMAP4: welcome: bytes capabilities: (*: str) PROTOCOL_VERSION: str - init(self, host: str = "", port: int = 143, timeout: int | float | None = None) - def open(self, host: str = "", port: int = 143, timeout: int | float | None = None): + init(self, host: str = "", port: int = 143, timeout: (int | float)? = None) + def open(self, host: str = "", port: int = 143, timeout: (int | float)? = None): """Setup connection to remote server on "host:port" (default: localhost:standard IMAP4 port). This connection will be used by the routines: @@ -165,7 +165,7 @@ class IMAP4: All args except 'message' can be None. """ - def authenticate(self, mechanism: str, authobject: (bytes) -> (bytes?)) -> (str, str): + def authenticate(self, mechanism: str, authobject: (bytes) -> bytes?) -> (str, str): """Authenticate command - requires response processing. 'mechanism' specifies which authentication mechanism is to @@ -281,7 +281,7 @@ class IMAP4: """ if sys.version_info >= (3, 14): - def idle(self, duration: int | float | None = None) -> Idler: + def idle(self, duration: (int | float)? = None) -> Idler: """Return an iterable IDLE context manager producing untagged responses. If the argument is not None, limit iteration to 'duration' seconds. @@ -292,7 +292,8 @@ class IMAP4: Note: 'duration' requires a socket connection (not IMAP4_stream). """ - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 13): + # Default was fixed in Python 3.13.15, 3.14.7 def list(self, directory: str = "", pattern: str = "*") -> (str, AnyResponseData): """List mailbox names in directory matching pattern. @@ -332,7 +333,8 @@ class IMAP4: Returns server 'BYE' response. """ - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 13): + # Default was fixed in Python 3.13.15, 3.14.7 def lsub(self, directory: str = "", pattern: str = "*") -> CommandResults: """List 'subscribed' mailbox names in directory matching pattern. @@ -419,7 +421,8 @@ class IMAP4: (typ, [data]) = .setacl(mailbox, who, what) """ - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 13): + # Parameter "mailbox" was added in Python 3.13.15, 3.14.7 def setannotation(self, mailbox: str | bytes, *args: str) -> CommandResults: """(typ, [data]) = .setannotation(mailbox[, entry, attribute]+) Set ANNOTATIONs. @@ -515,7 +518,7 @@ if sys.version_info >= (3, 14): Note: The name and structure of this class are subject to change. """ - init(self, imap: IMAP4, duration: int | float | None = None) + init(self, imap: IMAP4, duration: (int | float)? = None) def __enter__(self) -> Self def __exit__(self, exc_type: object, exc_val: Unused, exc_tb: Unused) -> False def __iter__(self) -> Self @@ -549,7 +552,7 @@ class IMAP4_SSL(IMAP4): if sys.version_info >= (3, 12): def __init__( - self, host: str = "", port: int = 993, *, ssl_context: SSLContext? = None, timeout: int | float | None = None + self, host: str = "", port: int = 993, *, ssl_context: SSLContext? = None, timeout: (int | float)? = None ) -> None else: def __init__( @@ -559,7 +562,7 @@ class IMAP4_SSL(IMAP4): keyfile: None = None, certfile: None = None, ssl_context: SSLContext? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, ) -> None @deprecated( "The `keyfile`, `certfile` parameters are deprecated since Python 3.6; " @@ -572,7 +575,7 @@ class IMAP4_SSL(IMAP4): keyfile: StrOrBytesPath? = None, certfile: StrOrBytesPath? = None, ssl_context: None = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, ) -> None keyfile: StrOrBytesPath? @@ -585,7 +588,7 @@ class IMAP4_SSL(IMAP4): else: file: IO[dynamic] - override def open(self, host: str = "", port: int? = 993, timeout: int | float | None = None): + override def open(self, host: str = "", port: int? = 993, timeout: (int | float)? = None): """Setup connection to remote server on "host:port". (default: localhost:standard IMAP4 SSL port). This connection will be used by the routines: @@ -615,7 +618,7 @@ class IMAP4_stream(IMAP4): process: subprocess.Popen[bytes] writefile: IO[dynamic] readfile: IO[dynamic] - override def open(self, host: str? = None, port: int? = None, timeout: int | float | None = None): + override def open(self, host: str? = None, port: int? = None, timeout: (int | float)? = None): """Setup a stream connection. This connection will be used by the routines: read, readline, send, shutdown. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/imghdr.byi b/crates/ty_vendored/vendor/typeshed/stdlib/imghdr.byi index 23c237df2d..2b88767428 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/imghdr.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/imghdr.byi @@ -16,4 +16,4 @@ def what(file: StrPath | ReadableBinary, h: None = None) -> str?: """Return the type of image contained in a file or byte stream.""" def what(file: dynamic, h: bytes) -> str? -tests: list[(bytes, BinaryIO?) -> (str?)] +tests: list[(bytes, BinaryIO?) -> str?] diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/_abc.byi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/_abc.byi index c92cd537f3..76c3645676 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/_abc.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/_abc.byi @@ -9,19 +9,21 @@ from typing_extensions import deprecated class Loader(metaclass=ABCMeta): """Abstract base class for import loaders.""" - def load_module(self, fullname: str) -> types.ModuleType: - """Return the loaded module. + if sys.version_info < (3, 15): + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + def load_module(self, fullname: str) -> types.ModuleType: + """Return the loaded module. - The module must be added to sys.modules and have import-related - attributes set properly. The fullname is a str. + The module must be added to sys.modules and have import-related + attributes set properly. The fullname is a str. - ImportError is raised on failure. + ImportError is raised on failure. - This method is deprecated in favor of loader.exec_module(). If - exec_module() exists then it is used to provide a backwards-compatible - functionality for this method. + This method is deprecated in favor of loader.exec_module(). If + exec_module() exists then it is used to provide a backwards-compatible + functionality for this method. - """ + """ if sys.version_info < (3, 12): @deprecated( diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.byi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.byi index fd4caa29a3..6bbd0e10e2 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.byi @@ -5,7 +5,7 @@ import sys import types from _typeshed import ReadableBuffer, StrPath from abc import ABCMeta -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from importlib import _bootstrap_external from importlib._abc export Loader from importlib.machinery import ModuleSpec @@ -92,14 +92,26 @@ class InspectLoader(Loader): def exec_module(self, module: types.ModuleType): """Execute the module.""" - static def source_to_code( - data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: bytes | StrPath = "" - ) -> types.CodeType: - """Compile 'data' into a code object. + if sys.version_info >= (3, 15): + static def source_to_code( + data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, + path: bytes | StrPath = "", + fullname: str? = None, + ) -> types.CodeType: + """Compile 'data' into a code object. - The 'data' argument can be anything that compile() can handle. The'path' - argument should be where the data was retrieved (when applicable). - """ + The 'data' argument can be anything that compile() can handle. The'path' + argument should be where the data was retrieved (when applicable). + """ + else: + static def source_to_code( + data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: bytes | StrPath = "" + ) -> types.CodeType: + """Compile 'data' into a code object. + + The 'data' argument can be anything that compile() can handle. The'path' + argument should be where the data was retrieved (when applicable). + """ class ExecutionLoader(InspectLoader): """Abstract base class for loaders that wish to support the execution of @@ -184,6 +196,14 @@ class MetaPathFinder(metaclass=ABCMeta): def find_spec( self, fullname: str, path: Sequence[str]?, target: types.ModuleType? = ..., / ) -> ModuleSpec? + if sys.version_info >= (3, 15): + def discover(self, parent: ModuleSpec? = None) -> Iterable[ModuleSpec]: + """An optional method which searches for possible specs with given *parent* + module spec. If *parent* is *None*, MetaPathFinder.discover will search + for top-level modules. + + Returns an iterable of possible specs. + """ class PathEntryFinder(metaclass=ABCMeta): """Abstract base class for path entry finders used by PathFinder.""" @@ -222,6 +242,14 @@ class PathEntryFinder(metaclass=ABCMeta): # Not defined on the actual class, but expected to exist. def find_spec(self, fullname: str, target: types.ModuleType? = ...) -> ModuleSpec? + if sys.version_info >= (3, 15): + def discover(self, parent: ModuleSpec? = None) -> Iterable[ModuleSpec]: + """An optional method which searches for possible specs with given + *parent* module spec. If *parent* is *None*, PathEntryFinder.discover + will search for top-level modules. + + Returns an iterable of possible specs. + """ class FileLoader(_bootstrap_external.FileLoader, ResourceLoader, ExecutionLoader, metaclass=ABCMeta): """Abstract base class partially implementing the ResourceLoader and @@ -241,12 +269,14 @@ class FileLoader(_bootstrap_external.FileLoader, ResourceLoader, ExecutionLoader override def get_filename(self, fullname: str? = None) -> str: """Return the path to the source file as found by the finder.""" - override def load_module(self, fullname: str? = None) -> types.ModuleType: - """Load a module from a file. + if sys.version_info < (3, 15): + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `exec_module()` instead.") + override def load_module(self, fullname: str? = None) -> types.ModuleType: + """Load a module from a file. - This method is deprecated. Use exec_module() instead. + This method is deprecated. Use exec_module() instead. - """ + """ if sys.version_info < (3, 11): class ResourceReader(metaclass=ABCMeta): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_common.byi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_common.byi index b3cda851c1..04ef8de61b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_common.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_common.byi @@ -12,9 +12,18 @@ if sys.version_info >= (3, 11): type Package = str | types.ModuleType - if sys.version_info >= (3, 12): + if sys.version_info >= (3, 15): + type Anchor = Package + + def files(anchor: Anchor? = None) -> Traversable: + """ + Get a Traversable resource for an anchor. + """ + + elif sys.version_info >= (3, 12): type Anchor = Package + @deprecated("Deprecated since Python 3.12; removed in Python 3.15.") def package_to_anchor( func: (Anchor?) -> Traversable, ) -> (Anchor?, Anchor?) -> Traversable: @@ -34,7 +43,7 @@ if sys.version_info >= (3, 11): """ Get a Traversable resource for an anchor. """ - @deprecated("Deprecated since Python 3.12; will be removed in Python 3.15. Use `anchor` parameter instead.") + @deprecated("Deprecated since Python 3.12; removed in Python 3.15. Use `anchor` parameter instead.") def files(package: Anchor? = None) -> Traversable else: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/inspect.byi b/crates/ty_vendored/vendor/typeshed/stdlib/inspect.byi index bf08f95b7a..80deb1fae2 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/inspect.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/inspect.byi @@ -283,8 +283,8 @@ def isgeneratorfunction(obj: (...) -> Generator[dynamic, dynamic, dynamic]) -> b Generator function objects provide the same attributes as functions. See help(isfunction) for a list of attributes. """ -def isgeneratorfunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> dynamic) -> TypeGuard[(**Parameters) -> GeneratorType[dynamic, dynamic, dynamic]] -def isgeneratorfunction(obj: object) -> TypeGuard[(...) -> GeneratorType[dynamic, dynamic, dynamic]] +def isgeneratorfunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> dynamic) -> TypeGuard[(**Parameters) -> Generator[dynamic, dynamic, dynamic]] +def isgeneratorfunction(obj: object) -> TypeGuard[(...) -> Generator[dynamic, dynamic, dynamic]] def iscoroutinefunction(obj: (...) -> Coroutine[dynamic, dynamic, dynamic]) -> bool: """Return true if the object is a coroutine function. @@ -292,9 +292,9 @@ def iscoroutinefunction(obj: (...) -> Coroutine[dynamic, dynamic, dynamic]) -> b Coroutine functions are normally defined with "async def" syntax, but may be marked via markcoroutinefunction. """ -def iscoroutinefunction[Parameters: (*: *, **: *), Element](obj: (**Parameters) -> Awaitable[Element]) -> TypeGuard[(**Parameters) -> CoroutineType[dynamic, dynamic, Element]] -def iscoroutinefunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> object) -> TypeGuard[(**Parameters) -> CoroutineType[dynamic, dynamic, dynamic]] -def iscoroutinefunction(obj: object) -> TypeGuard[(...) -> CoroutineType[dynamic, dynamic, dynamic]] +def iscoroutinefunction[Parameters: (*: *, **: *), Element](obj: (**Parameters) -> Awaitable[Element]) -> TypeGuard[(**Parameters) -> Coroutine[dynamic, dynamic, Element]] +def iscoroutinefunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> object) -> TypeGuard[(**Parameters) -> Coroutine[dynamic, dynamic, dynamic]] +def iscoroutinefunction(obj: object) -> TypeGuard[(...) -> Coroutine[dynamic, dynamic, dynamic]] def isgenerator(object: object) -> object is GeneratorType[object, Never, object]: """Return true if the object is a generator. @@ -327,8 +327,8 @@ def isasyncgenfunction(obj: (...) -> AsyncGenerator[dynamic, dynamic]) -> bool: Asynchronous generator functions are defined with "async def" syntax and have "yield" expressions in their body. """ -def isasyncgenfunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> dynamic) -> TypeGuard[(**Parameters) -> AsyncGeneratorType[dynamic, dynamic]] -def isasyncgenfunction(obj: object) -> TypeGuard[(...) -> AsyncGeneratorType[dynamic, dynamic]] +def isasyncgenfunction[Parameters: (*: *, **: *)](obj: (**Parameters) -> dynamic) -> TypeGuard[(**Parameters) -> AsyncGenerator[dynamic, dynamic]] +def isasyncgenfunction(obj: object) -> TypeGuard[(...) -> AsyncGenerator[dynamic, dynamic]] @type_check_only private protocol SupportsSet[in Input, in V]: @@ -429,18 +429,18 @@ def isroutine( def ismethoddescriptor(object: object) -> object is MethodDescriptorType: """Return true if the object is a method descriptor. - But not if ismethod() or isclass() or isfunction() are true. + But not if ismethod(), isclass() or isfunction() is true. - This is new in Python 2.2, and, for example, is true of int.__add__. - An object passing this test has a __get__ attribute, but not a - __set__ attribute or a __delete__ attribute. Beyond that, the set - of attributes varies; __name__ is usually sensible, and __doc__ - often is. + An object passing this test (for example, int.__add__) has a __get__ + attribute, but not a __set__ attribute or a __delete__ attribute. + Beyond that, the set of attributes varies; __name__ is usually + sensible, and __doc__ often is. Methods implemented via descriptors that also pass one of the other - tests return false from the ismethoddescriptor() test, simply because - the other tests promise more -- you can, e.g., count on having the - __func__ attribute (etc) when an object passes ismethod(). + tests (ismethod(), isclass(), isfunction()) make this function return + false, simply because those other tests promise more -- you can, for + example, count on having the __func__ attribute when an object passes + ismethod(). """ def ismemberdescriptor(object: object) -> object is MemberDescriptorType: @@ -463,8 +463,13 @@ def isgetsetdescriptor(object: object) -> object is GetSetDescriptorType: def isdatadescriptor(object: object) -> object is SupportsSet[Never, Never] | SupportsDelete[Never]: """Return true if the object is a data descriptor. + But not if ismethod(), isclass() or isfunction() is true. + Data descriptors have a __set__ or a __delete__ attribute. Examples are - properties (defined in Python) and getsets and members (defined in C). + properties, getsets, and members. For the latter two (defined only in C + extension modules) more specific tests are available as well: + isgetsetdescriptor() and ismemberdescriptor(), respectively. + Typically, data descriptors will also have __name__ and __doc__ attributes (properties, getsets, and members have both of these attributes), but this is not guaranteed. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/linecache.byi b/crates/ty_vendored/vendor/typeshed/stdlib/linecache.byi index b2cb2c785c..8dc9e5e804 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/linecache.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/linecache.byi @@ -13,7 +13,7 @@ __all__ = ["getline", "clearcache", "checkcache", "lazycache"] type _ModuleGlobals = dict[str, dynamic] private type ModuleMetadata = (int, float?, list[str], str) -private type SourceLoader = (() -> (str?),) +private type SourceLoader = (() -> str?,) cache: dict[str, SourceLoader | ModuleMetadata] # undocumented diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/logging/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/logging/__init__.byi index a1b428c998..94cba7110a 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/logging/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/logging/__init__.byi @@ -647,7 +647,7 @@ class Formatter: the record is emitted """ - converter: (int | float | None) -> struct_time + converter: ((int | float)?) -> struct_time _fmt: str? # undocumented datefmt: str? # undocumented _style: PercentStyle # undocumented diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/logging/config.byi b/crates/ty_vendored/vendor/typeshed/stdlib/logging/config.byi index 9397e3ed1f..26395f7492 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/logging/config.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/logging/config.byi @@ -92,7 +92,7 @@ def fileConfig( """ def valid_ident(s: str) -> True # undocumented -def listen(port: int = 9030, verify: ((bytes) -> (bytes?))? = None) -> Thread: +def listen(port: int = 9030, verify: ((bytes) -> bytes?)? = None) -> Thread: """ Start up a socket server on the specified port, and listen for new configurations. @@ -150,7 +150,7 @@ else: class ConvertingTuple((*: dynamic), ConvertingMixin): # undocumented """A converting tuple wrapper.""" - def __getitem__(self, key: SupportsIndex) -> dynamic + override def __getitem__(self, key: SupportsIndex) -> dynamic def __getitem__(self, key: slice[SupportsIndex?]) -> dynamic class BaseConfigurator: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/logging/handlers.byi b/crates/ty_vendored/vendor/typeshed/stdlib/logging/handlers.byi index 5099818295..084bf3580b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/logging/handlers.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/logging/handlers.byi @@ -348,13 +348,13 @@ class SysLogHandler(Handler): facility_names: ClassVar[dict[str, int]] # undocumented priority_map: ClassVar[dict[str, str]] # undocumented if sys.version_info >= (3, 14): - timeout: int | float | None + timeout: (int | float)? def __init__( self, address: (str, int) | str = ("localhost", 514), facility: str | int = 1, socktype: SocketKind? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, ) -> None: """ Initialize a handler. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/mailbox.byi b/crates/ty_vendored/vendor/typeshed/stdlib/mailbox.byi index dc67e33930..786e3b09f0 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/mailbox.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/mailbox.byi @@ -164,6 +164,11 @@ class Mailbox[out MessageT: Message = Message]: abstract def close(self): """Flush and close the mailbox.""" + if sys.version_info >= (3, 15): + def __enter__(self) -> Self + def __exit__( + self, type: type[BaseException]?, value: BaseException?, traceback: TracebackType? + ) # Undocumented, called by subclasses to parse added messages. def _dump_message(self, message: MessageData, target: SupportsWrite[bytes], mangle_from_: bool = False): """Dump message contents to target file.""" @@ -555,11 +560,13 @@ class _ProxyFile: def seekable(self) -> bool def flush(self) let closed: bool - def __class_getitem__(cls, item: dynamic, /) -> GenericAlias: - """Represent a PEP 585 generic type + if sys.version_info < (3, 15): + def __class_getitem__(cls, item: dynamic, /) -> GenericAlias: + """Represent a PEP 585 generic type - E.g. for t = list[int], t.__origin__ is list and t.__args__ is (int,). - """ + For example, for t = list[int], t.__origin__ is list and t.__args__ + is (int,). + """ class _PartialFile(_ProxyFile): """A read-only wrapper of part of a file.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/marshal.byi b/crates/ty_vendored/vendor/typeshed/stdlib/marshal.byi index e4c4acd68c..f40370ed97 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/marshal.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/marshal.byi @@ -27,7 +27,6 @@ dumps() -- marshal value as a bytes object loads() -- read value from a bytes-like object """ -import builtins import sys import types from _typeshed import ReadableBuffer, SupportsRead, SupportsWrite @@ -39,7 +38,7 @@ type _Marshallable = ( # handled in w_object() in marshal.c None | type[StopIteration] - | builtins.ellipsis + | types.EllipsisType | bool # handled in w_complex_object() in marshal.c | int diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/mmap.byi b/crates/ty_vendored/vendor/typeshed/stdlib/mmap.byi index f57f4902ab..9c4140c0fe 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/mmap.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/mmap.byi @@ -93,7 +93,10 @@ class mmap: def close(self) if sys.version_info >= (3, 15): - def flush(self, offset: int = 0, size: int = ..., /, *, flags: int = 0) -> None + def flush(self, offset: int = 0, size: int = -1, /, *, flags: int = 0) -> None + elif sys.version_info >= (3, 14): + # size default changed in Python 3.14.1 + def flush(self, offset: int = 0, size: int? = None, /) -> None else: def flush(self, offset: int = 0, size: int = ..., /) -> None @@ -120,7 +123,8 @@ class mmap: else: def madvise(self, option: int, start: int = 0, length: int = ..., /) -> None - if sys.version_info >= (3, 15): + if sys.version_info >= (3, 14): + # default values changed in Python 3.14.1 def find(self, view: ReadableBuffer, start: int? = None, end: int? = None, /) -> int def rfind(self, view: ReadableBuffer, start: int? = None, end: int? = None, /) -> int diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/connection.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/connection.byi index 7a991f0b82..a98acaa49c 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/connection.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/connection.byi @@ -47,7 +47,7 @@ class _ConnectionBase[in Send = dynamic, out RecvT = dynamic]: def recv(self) -> RecvT: """Receive a (picklable) object""" - def poll(self, timeout: int | float | None = 0.0) -> bool: + def poll(self, timeout: (int | float)? = 0.0) -> bool: """Whether there is any input available to be read""" def __enter__(self) -> Self @@ -118,7 +118,7 @@ else: def answer_challenge(connection: _ConnectionBase[dynamic, dynamic], authkey: bytes) def wait[Send = dynamic, RecvT = dynamic]( - object_list: Iterable[_ConnectionBase[Send, RecvT] | socket.socket | int], timeout: int | float | None = None + object_list: Iterable[_ConnectionBase[Send, RecvT] | socket.socket | int], timeout: (int | float)? = None ) -> list[_ConnectionBase[Send, RecvT] | socket.socket | int]: """ Wait till an object in object_list is ready/readable. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/context.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/context.byi index 6c457b0b1d..225469ab5e 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/context.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/context.byi @@ -73,7 +73,7 @@ class BaseContext: """Returns two connection object connected by a pipe""" def Barrier( - self, parties: int, action: (...) -> object? = None, timeout: int | float | None = None + self, parties: int, action: (...) -> object? = None, timeout: (int | float)? = None ) -> synchronize.Barrier: """Returns a barrier object""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/dummy/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/dummy/__init__.byi index 9d0817b5a3..c23430d710 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/dummy/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/dummy/__init__.byi @@ -45,7 +45,7 @@ class DummyProcess(threading.Thread): _start_called: int let exitcode: 0? if sys.version_info >= (3, 14): - # Default changed in Python 3.14.1 + # kwargs default changed in Python 3.14.1 def __init__( self, group: dynamic = None, diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.byi index 18b32cf803..802da33fb5 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.byi @@ -359,7 +359,7 @@ class BaseManager: """ shutdown: _Finalize # only available after start() was called - def join(self, timeout: int | float | None = None): # undocumented + def join(self, timeout: (int | float)? = None): # undocumented """ Join the manager process (if it has been spawned) """ @@ -395,7 +395,7 @@ class SyncManager(BaseManager): """ def Barrier( - self, parties: int, action: (() -> None)? = None, timeout: int | float | None = None + self, parties: int, action: (() -> None)? = None, timeout: (int | float)? = None ) -> threading.Barrier def BoundedSemaphore(self, value: int = 1) -> threading.BoundedSemaphore def Condition(self, lock: threading.Lock | threading._RLock | None = None) -> threading.Condition diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.byi index 10293731f9..9a1de9cb9c 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.byi @@ -11,8 +11,8 @@ class ApplyResult[in out Element]: init( self, pool: Pool, callback: ((Element) -> object)?, error_callback: ((BaseException) -> object)? ) - def get(self, timeout: int | float | None = None) -> Element - def wait(self, timeout: int | float | None = None) + def get(self, timeout: (int | float)? = None) -> Element + def wait(self, timeout: (int | float)? = None) def ready(self) -> bool def successful(self) -> bool def __class_getitem__(cls, item: dynamic, /) -> GenericAlias: @@ -38,8 +38,8 @@ class MapResult[in out Element](ApplyResult[list[Element]]): class IMapIterator[in out Element]: init(self, pool: Pool) def __iter__(self) -> Self - def next(self, timeout: int | float | None = None) -> Element - def __next__(self, timeout: int | float | None = None) -> Element + def next(self, timeout: (int | float)? = None) -> Element + def __next__(self, timeout: (int | float)? = None) -> Element class IMapUnorderedIterator[in out Element](IMapIterator[Element]) diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_fork.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_fork.byi index ea76f0a521..0b564dde91 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_fork.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_fork.byi @@ -17,7 +17,7 @@ if sys.platform != "win32": init(self, process_obj: BaseProcess) def duplicate_for_child(self, fd: int) -> int def poll(self, flag: int = 1) -> int? - def wait(self, timeout: int | float | None = None) -> int? + def wait(self, timeout: (int | float)? = None) -> int? if sys.version_info >= (3, 14): def interrupt(self) diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_spawn_win32.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_spawn_win32.byi index a18525127d..953451c738 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_spawn_win32.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/popen_spawn_win32.byi @@ -25,7 +25,7 @@ if sys.platform == "win32": init(self, process_obj: BaseProcess) def duplicate_for_child(self, handle: int) -> int - def wait(self, timeout: int | float | None = None) -> int? + def wait(self, timeout: (int | float)? = None) -> int? def poll(self) -> int? def terminate(self) diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/process.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/process.byi index 5b4a93bab4..85a7b38530 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/process.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/process.byi @@ -18,16 +18,30 @@ class BaseProcess: authkey: bytes _identity: (*: int) # undocumented - init( - self, - group: None = None, - target: (...) -> object? = None, - name: str? = None, - args: Iterable[dynamic] = (), - kwargs: Mapping[str, dynamic] = {}, - *, - daemon: bool? = None, - ) + if sys.version_info >= (3, 14): + # kwargs default changed in Python 3.14.1 + def __init__( + self, + group: None = None, + target: (...) -> object? = None, + name: str? = None, + args: Iterable[dynamic] = (), + kwargs: Mapping[str, dynamic]? = None, + *, + daemon: bool? = None, + ) -> None + else: + def __init__( + self, + group: None = None, + target: (...) -> object? = None, + name: str? = None, + args: Iterable[dynamic] = (), + kwargs: Mapping[str, dynamic] = {}, + *, + daemon: bool? = None, + ) -> None + def run(self): """ Method to be run in sub-process; can be overridden in sub-class @@ -62,7 +76,7 @@ class BaseProcess: an error to call this method if the child process is still running. """ - def join(self, timeout: int | float | None = None): + def join(self, timeout: (int | float)? = None): """ Wait until child process terminates """ diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/queues.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/queues.byi index ffe49675c7..385f8ebf30 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/queues.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/queues.byi @@ -15,8 +15,8 @@ class Queue[in out Element]: init(self, maxsize: int = 0, *, ctx: dynamic = ...) def __getstate__(self) -> _QueueState def __setstate__(self, state: _QueueState) - def put(self, obj: Element, block: bool = True, timeout: int | float | None = None) - def get(self, block: bool = True, timeout: int | float | None = None) -> Element + def put(self, obj: Element, block: bool = True, timeout: (int | float)? = None) + def get(self, block: bool = True, timeout: (int | float)? = None) -> Element def qsize(self) -> int def empty(self) -> bool def full(self) -> bool diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/resource_sharer.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/resource_sharer.byi index b7fb1dfcaf..d249e4bc20 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/resource_sharer.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/resource_sharer.byi @@ -23,5 +23,5 @@ else: def detach(self) -> int: """Get the fd. This should only be called once.""" -def stop(timeout: int | float | None = None): +def stop(timeout: (int | float)? = None): """Stop the background thread and clear registered resources.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/sharedctypes.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/sharedctypes.byi index 398256476f..74a58b6578 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/sharedctypes.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/sharedctypes.byi @@ -82,7 +82,7 @@ def synchronized[CT: _CData](obj: CT, lock: _LockLike? = None, ctx: dynamic? = N @type_check_only private protocol AcquireFunc: - def __call__(self, block: bool = ..., timeout: int | float | None = ..., /) -> bool + def __call__(self, block: bool = ..., timeout: (int | float)? = ..., /) -> bool class SynchronizedBase[in out CT: _CData]: acquire: AcquireFunc diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/synchronize.byi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/synchronize.byi index 6fd1990411..a57c4326b0 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/synchronize.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/synchronize.byi @@ -11,22 +11,22 @@ type _LockLike = Lock | RLock class Barrier(threading.Barrier): init( - self, parties: int, action: (() -> object)? = None, timeout: int | float | None = None, *, ctx: BaseContext + self, parties: int, action: (() -> object)? = None, timeout: (int | float)? = None, *, ctx: BaseContext ) class Condition: init(self, lock: _LockLike? = None, *, ctx: BaseContext) def notify(self, n: int = 1) def notify_all(self) - def wait(self, timeout: int | float | None = None) -> bool - def wait_for(self, predicate: () -> bool, timeout: int | float | None = None) -> bool + def wait(self, timeout: (int | float)? = None) -> bool + def wait_for(self, predicate: () -> bool, timeout: (int | float)? = None) -> bool def __enter__(self) -> bool def __exit__( self, exc_type: type[BaseException]?, exc_val: BaseException?, exc_tb: TracebackType?, / ) # These methods are copied from the lock passed to the constructor, or an # instance of ctx.RLock() if lock was None. - def acquire(self, block: bool = True, timeout: int | float | None = None) -> bool + def acquire(self, block: bool = True, timeout: (int | float)? = None) -> bool def release(self) class Event: @@ -34,7 +34,7 @@ class Event: def is_set(self) -> bool def set(self) def clear(self) - def wait(self, timeout: int | float | None = None) -> bool + def wait(self, timeout: (int | float)? = None) -> bool # Not part of public API class SemLock: @@ -44,7 +44,7 @@ class SemLock: self, exc_type: type[BaseException]?, exc_val: BaseException?, exc_tb: TracebackType?, / ) # These methods are copied from the wrapped _multiprocessing.SemLock object - def acquire(self, block: bool = True, timeout: int | float | None = None) -> bool + def acquire(self, block: bool = True, timeout: (int | float)? = None) -> bool def release(self) if sys.version_info >= (3, 14): def locked(self) -> bool diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/pathlib/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/pathlib/__init__.byi index 07b4dfe0dc..4531f8a9ad 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/pathlib/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/pathlib/__init__.byi @@ -151,7 +151,7 @@ class PurePath(PathLike[str]): if sys.version_info < (3, 15): if sys.version_info >= (3, 13): @deprecated( - "Deprecated since Python 3.13; will be removed in Python 3.15. " + "Deprecated since Python 3.13; removed in Python 3.15. " "Use `os.path.isreserved()` to detect reserved paths on Windows." ) def is_reserved(self) -> bool: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/platform.byi b/crates/ty_vendored/vendor/typeshed/stdlib/platform.byi index c4a8c57433..7228812623 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/platform.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/platform.byi @@ -41,7 +41,7 @@ def mac_ver( """ if sys.version_info < (3, 15): - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def java_ver( release: str = "", vendor: str = "", diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/heatmap_collector.byi b/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/heatmap_collector.byi index e25fc65f3b..5cf45c161d 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/heatmap_collector.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/heatmap_collector.byi @@ -45,8 +45,8 @@ class HeatmapCollector(Collector): sample_interval_usec: int, duration_sec: int | float, sample_rate: int | float, - error_rate: int | float | None = None, - missed_samples: int | float | None = None, + error_rate: (int | float)? = None, + missed_samples: (int | float)? = None, **kwargs: object, ): """Set profiling statistics to include in heatmap output. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/stack_collector.byi b/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/stack_collector.byi index 6147d7bfd3..bb429b8188 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/stack_collector.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/profiling/sampling/stack_collector.byi @@ -28,8 +28,8 @@ class FlamegraphCollector(StackTraceCollector): sample_interval_usec: int, duration_sec: int | float, sample_rate: int | float, - error_rate: int | float | None = None, - missed_samples: int | float | None = None, + error_rate: (int | float)? = None, + missed_samples: (int | float)? = None, mode: int? = None, ): """Set profiling statistics to include in flamegraph data.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/pydoc.byi b/crates/ty_vendored/vendor/typeshed/stdlib/pydoc.byi index 135026782f..e51dd85822 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/pydoc.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/pydoc.byi @@ -179,8 +179,8 @@ class HTMLDoc(Doc): """Formatter class for HTML documentation.""" _repr_instance: HTMLRepr - repr = _repr_instance.repr - escape = _repr_instance.escape + repr = _repr_instance.repr # pyrefly: ignore [unknown-name] + escape = _repr_instance.escape # pyrefly: ignore [unknown-name] def page(self, title: str, contents: str) -> str: """Format an HTML page.""" @@ -343,7 +343,7 @@ class TextDoc(Doc): """Formatter class for text documentation.""" _repr_instance: TextRepr - repr = _repr_instance.repr + repr = _repr_instance.repr # pyrefly: ignore [unknown-name] def bold(self, text: str) -> str: """Format a string in bold by overstriking.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/pyexpat/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/pyexpat/__init__.byi index 31336963ea..75179dfc67 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/pyexpat/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/pyexpat/__init__.byi @@ -1,6 +1,5 @@ """Python wrapper for Expat parser.""" -import sys from _typeshed import ReadableBuffer, SupportsRead from collections.abc import Callable from pyexpat export errors, model @@ -101,41 +100,40 @@ final class XMLParserType: library. """ - if sys.version_info >= (3, 13): - # Added in Python 3.13.4, 3.14.6 - def SetBillionLaughsAttackProtectionActivationThreshold(self, threshold: int, /): - """Sets the number of output bytes needed to activate protection against billion laughs attacks. - - The number of output bytes includes amplification from entity - expansion and reading DTD files. - - Parser objects usually have a protection activation threshold of - 8 MiB, but the actual default value depends on the underlying Expat - library. - - Activation thresholds below 4 MiB are known to break support for - DITA 1.3 payload and are hence not recommended. - """ - - def SetBillionLaughsAttackProtectionMaximumAmplification(self, max_factor: int | float, /): - """Sets the maximum tolerated amplification factor for protection against billion laughs attacks. - - The amplification factor is calculated as "(direct + indirect) / - direct" while parsing, where "direct" is the number of bytes read - from the primary document in parsing and "indirect" is the number of - bytes added by expanding entities and reading external DTD files, - combined. - - The 'max_factor' value must be a non-NaN floating point value - greater than or equal to 1.0. Amplification factors greater than - 30,000 can be observed in the middle of parsing even with benign - files in practice. In particular, the activation threshold should - be carefully chosen to avoid false positives. - - Parser objects usually have a maximum amplification factor of 100, - but the actual default value depends on the underlying Expat - library. - """ + # Added in Python 3.10.19, 3.11.14, 3.12.12, 3.13.4, 3.14.6 + def SetBillionLaughsAttackProtectionActivationThreshold(self, threshold: int, /): + """Sets the number of output bytes needed to activate protection against billion laughs attacks. + + The number of output bytes includes amplification from entity + expansion and reading DTD files. + + Parser objects usually have a protection activation threshold of + 8 MiB, but the actual default value depends on the underlying Expat + library. + + Activation thresholds below 4 MiB are known to break support for + DITA 1.3 payload and are hence not recommended. + """ + + def SetBillionLaughsAttackProtectionMaximumAmplification(self, max_factor: int | float, /): + """Sets the maximum tolerated amplification factor for protection against billion laughs attacks. + + The amplification factor is calculated as "(direct + indirect) / + direct" while parsing, where "direct" is the number of bytes read + from the primary document in parsing and "indirect" is the number of + bytes added by expanding entities and reading external DTD files, + combined. + + The 'max_factor' value must be a non-NaN floating point value + greater than or equal to 1.0. Amplification factors greater than + 30,000 can be observed in the middle of parsing even with benign + files in practice. In particular, the activation threshold should + be carefully chosen to avoid false positives. + + Parser objects usually have a maximum amplification factor of 100, + but the actual default value depends on the underlying Expat + library. + """ let intern: dict[str, str] buffer_size: int diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/queue.byi b/crates/ty_vendored/vendor/typeshed/stdlib/queue.byi index b26dcb1e2b..4b59614c87 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/queue.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/queue.byi @@ -60,7 +60,7 @@ class Queue[in out Element]: qsize() can be used. """ - def get(self, block: bool = True, timeout: int | float | None = None) -> Element: + def get(self, block: bool = True, timeout: (int | float)? = None) -> Element: """Remove and return an item from the queue. If optional args 'block' is true and 'timeout' is None (the default), @@ -97,7 +97,7 @@ class Queue[in out Element]: """ def _get(self) -> Element - def put(self, item: Element, block: bool = True, timeout: int | float | None = None): + def put(self, item: Element, block: bool = True, timeout: (int | float)? = None): """Put an item into the queue. If optional args 'block' is true and 'timeout' is None (the default), diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/random.byi b/crates/ty_vendored/vendor/typeshed/stdlib/random.byi index 543c8f0d9a..2b41743e74 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/random.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/random.byi @@ -255,7 +255,7 @@ class Random(_random.Random): """ - def triangular(self, low: int | float = 0.0, high: int | float = 1.0, mode: int | float | None = None) -> int | float: + def triangular(self, low: int | float = 0.0, high: int | float = 1.0, mode: (int | float)? = None) -> int | float: """Triangular distribution. Continuous distribution bounded by given lower and upper limits, diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/readline.byi b/crates/ty_vendored/vendor/typeshed/stdlib/readline.byi index 42ea76771b..df8df24bc5 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/readline.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/readline.byi @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from typing import Literal, TypeAlias if sys.platform != "win32": - type _Completer = (str, int) -> (str?) + type _Completer = (str, int) -> str? type _CompDisp = (str, Sequence[str], int) -> None def parse_and_bind(string: str, /): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/sched.byi b/crates/ty_vendored/vendor/typeshed/stdlib/sched.byi index 5216850ce9..705cfa4e56 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/sched.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/sched.byi @@ -69,7 +69,7 @@ class scheduler: """ - def run(self, blocking: bool = True) -> int | float | None: + def run(self, blocking: bool = True) -> (int | float)?: """Execute events until the queue is empty. If blocking is False executes the scheduled events due to expire soonest (if any) and then return the deadline of the diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/select.byi b/crates/ty_vendored/vendor/typeshed/stdlib/select.byi index 99da75ca37..0c96ef22bc 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/select.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/select.byi @@ -41,11 +41,11 @@ if sys.platform != "win32": def register(self, fd: FileDescriptorLike, eventmask: int = 7, /) def modify(self, fd: FileDescriptorLike, eventmask: int, /) def unregister(self, fd: FileDescriptorLike, /) - def poll(self, timeout: int | float | None = None, /) -> list[(int, int)] + def poll(self, timeout: (int | float)? = None, /) -> list[(int, int)] def select[R: FileDescriptorLike = Never, W: FileDescriptorLike = Never, X: FileDescriptorLike = Never]( - rlist: Iterable[R], wlist: Iterable[W], xlist: Iterable[X], timeout: int | float | None = None, / + rlist: Iterable[R], wlist: Iterable[W], xlist: Iterable[X], timeout: (int | float)? = None, / ) -> (list[R], list[W], list[X]): """Wait until one or more file descriptors are ready for some kind of I/O. @@ -131,7 +131,7 @@ if sys.platform != "linux" and sys.platform != "win32": Further operations on the kqueue object will raise an exception. """ - def control(self, changelist: Iterable[kevent]?, maxevents: int, timeout: int | float | None = None, /) -> list[kevent]: + def control(self, changelist: Iterable[kevent]?, maxevents: int, timeout: (int | float)? = None, /) -> list[kevent]: """Calls the kernel kevent function. changelist @@ -255,7 +255,7 @@ if sys.platform == "linux": the target file descriptor of the operation """ - def poll(self, timeout: int | float | None = None, maxevents: int = -1) -> list[(int, int)]: + def poll(self, timeout: (int | float)? = None, maxevents: int = -1) -> list[(int, int)]: """Wait for events on the epoll file descriptor. timeout @@ -298,4 +298,4 @@ if sys.platform != "linux" and sys.platform != "darwin" and sys.platform != "win def register(self, fd: FileDescriptorLike, eventmask: int = ...) def modify(self, fd: FileDescriptorLike, eventmask: int = ...) def unregister(self, fd: FileDescriptorLike) - def poll(self, timeout: int | float | None = None) -> list[(int, int)] + def poll(self, timeout: (int | float)? = None) -> list[(int, int)] diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/selectors.byi b/crates/ty_vendored/vendor/typeshed/stdlib/selectors.byi index d39f25cad7..2aea828a91 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/selectors.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/selectors.byi @@ -94,7 +94,7 @@ class BaseSelector(metaclass=ABCMeta): Anything that unregister() or register() raises """ - abstract def select(self, timeout: int | float | None = None) -> list[(SelectorKey, int)]: + abstract def select(self, timeout: (int | float)? = None) -> list[(SelectorKey, int)]: """Perform the actual selection, until some monitored file objects are ready or a timeout expires. @@ -141,12 +141,12 @@ class _BaseSelectorImpl(BaseSelector, metaclass=ABCMeta): class SelectSelector(_BaseSelectorImpl): """Select-based selector.""" - override def select(self, timeout: int | float | None = None) -> list[(SelectorKey, int)] + override def select(self, timeout: (int | float)? = None) -> list[(SelectorKey, int)] class _PollLikeSelector(_BaseSelectorImpl): """Base class shared between poll, epoll and devpoll selectors.""" - override def select(self, timeout: int | float | None = None) -> list[(SelectorKey, int)] + override def select(self, timeout: (int | float)? = None) -> list[(SelectorKey, int)] if sys.platform != "win32": class PollSelector(_PollLikeSelector): @@ -168,7 +168,7 @@ if sys.platform != "win32" and sys.platform != "linux": """Kqueue-based selector.""" def fileno(self) -> int - override def select(self, timeout: int | float | None = None) -> list[(SelectorKey, int)] + override def select(self, timeout: (int | float)? = None) -> list[(SelectorKey, int)] # Not a real class at runtime, it is just a conditional alias to other real selectors. # The runtime logic is more fine-grained than a `sys.platform` check; @@ -176,6 +176,6 @@ if sys.platform != "win32" and sys.platform != "linux": class DefaultSelector(_BaseSelectorImpl): """Epoll-based selector.""" - override def select(self, timeout: int | float | None = None) -> list[(SelectorKey, int)] + override def select(self, timeout: (int | float)? = None) -> list[(SelectorKey, int)] if sys.platform != "win32": def fileno(self) -> int diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/socket.byi b/crates/ty_vendored/vendor/typeshed/stdlib/socket.byi index 8c00b80e98..7d45bffbbe 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/socket.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/socket.byi @@ -1544,7 +1544,7 @@ class socket(_socket.socket): def makefile( self, mode: "rwb" | "rbw" | "wrb" | "wbr" | "brw" | "bwr", - buffering: -1 | 1 | None = None, + buffering: -1 | 1? = None, *, encoding: str? = None, errors: str? = None, @@ -1553,7 +1553,7 @@ class socket(_socket.socket): def makefile( self, mode: "rb" | "br", - buffering: -1 | 1 | None = None, + buffering: -1 | 1? = None, *, encoding: str? = None, errors: str? = None, @@ -1562,7 +1562,7 @@ class socket(_socket.socket): def makefile( self, mode: "wb" | "bw", - buffering: -1 | 1 | None = None, + buffering: -1 | 1? = None, *, encoding: str? = None, errors: str? = None, @@ -1709,7 +1709,7 @@ def getfqdn(name: str = "") -> str: if sys.version_info >= (3, 11): def create_connection( address: (str?, bytes | str | int | None), - timeout: int | float | None = ..., + timeout: (int | float)? = ..., source_address: _Address? = None, *, all_errors: bool = False, @@ -1730,7 +1730,7 @@ if sys.version_info >= (3, 11): else: def create_connection( - address: (str?, int), timeout: int | float | None = ..., source_address: _Address? = None + address: (str?, int), timeout: (int | float)? = ..., source_address: _Address? = None ) -> socket: """Connect to *address* and return the socket object. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/socketserver.byi b/crates/ty_vendored/vendor/typeshed/stdlib/socketserver.byi index 7fe08a1e4f..e089de3afa 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/socketserver.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/socketserver.byi @@ -204,7 +204,7 @@ class BaseServer: """ server_address: _Address - timeout: int | float | None + timeout: (int | float)? RequestHandlerClass: (dynamic, _RetAddress, Self) -> BaseRequestHandler init( self, server_address: _Address, RequestHandlerClass: (dynamic, _RetAddress, Self) -> BaseRequestHandler @@ -410,7 +410,7 @@ if sys.platform != "win32": class ForkingMixIn: """Mix-in class to handle each request in a new process.""" - timeout: int | float | None # undocumented + timeout: (int | float)? # undocumented active_children: set[int]? # undocumented max_children: int # undocumented block_on_close: bool @@ -501,7 +501,7 @@ class StreamRequestHandler(BaseRequestHandler): rbufsize: ClassVar[int] # undocumented wbufsize: ClassVar[int] # undocumented - timeout: ClassVar[int | float | None] # undocumented + timeout: ClassVar[(int | float)?] # undocumented disable_nagle_algorithm: ClassVar[bool] # undocumented connection: dynamic # undocumented rfile: BufferedIOBase diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.byi index e0eb9663e2..f4ac70b23a 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.byi @@ -254,7 +254,7 @@ private type AdaptedInputData = SqliteData | dynamic # The Mapping must really be a dict, but making it invariant is too annoying. type _Parameters = SupportsLenAndGetItem[AdaptedInputData] | Mapping[str, AdaptedInputData] # Controls the legacy transaction handling mode of sqlite3. -type _IsolationLevel = "DEFERRED" | "EXCLUSIVE" | "IMMEDIATE" | None +type _IsolationLevel = "DEFERRED" | "EXCLUSIVE" | "IMMEDIATE"? private type RowFactoryOptions = type[Row] | ((Cursor, (*: dynamic)) -> object) | None @type_check_only @@ -491,7 +491,7 @@ class Connection: ) -> None: """Set authorizer callback.""" - def set_progress_handler(self, progress_handler: (() -> (int?))?, /, n: int) -> None: + def set_progress_handler(self, progress_handler: (() -> int?)?, /, n: int) -> None: """Set progress handler callback. progress_handler @@ -520,7 +520,7 @@ class Connection: 'authorizer_callback' will become positional-only in Python 3.15. """ - def set_progress_handler(self, progress_handler: (() -> (int?))?, n: int) -> None: + def set_progress_handler(self, progress_handler: (() -> int?)?, n: int) -> None: """Set progress handler callback. progress_handler diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ssl.byi b/crates/ty_vendored/vendor/typeshed/stdlib/ssl.byi index bec6998c0f..1057ed1750 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ssl.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ssl.byi @@ -142,7 +142,7 @@ private type PCTRTT = (*: (str, str)) private type PCTRTTT = (*: PCTRTT) private type PeerCertRetDictType = dict[str, str | PCTRTTT | PCTRTT] private type PeerCertRetType = PeerCertRetDictType | bytes | None -private type SrvnmeCbType = (SSLSocket | SSLObject, str?, SSLSocket) -> (int?) +private type SrvnmeCbType = (SSLSocket | SSLObject, str?, SSLSocket) -> int? socket_error = OSError diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/string/templatelib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/string/templatelib.byi index ad01eeaa70..ca6280568d 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/string/templatelib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/string/templatelib.byi @@ -35,7 +35,7 @@ final class Interpolation[in out Element]: expression: str """Expression""" - conversion: "a" | "r" | "s" | None + conversion: "a" | "r" | "s"? """Conversion""" format_spec: str @@ -44,7 +44,7 @@ final class Interpolation[in out Element]: __match_args__ = ("value", "expression", "conversion", "format_spec") def __new__( - cls, value: Element, expression: str = "", conversion: "a" | "r" | "s" | None = None, format_spec: str = "" + cls, value: Element, expression: str = "", conversion: "a" | "r" | "s"? = None, format_spec: str = "" ) -> Interpolation[Element] def __class_getitem__(cls, item: dynamic, /) -> GenericAlias: """Interpolations are generic over the types of their values""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/subprocess.byi b/crates/ty_vendored/vendor/typeshed/stdlib/subprocess.byi index 0afef1b099..44581508e3 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/subprocess.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/subprocess.byi @@ -165,7 +165,7 @@ if sys.version_info >= (3, 11): errors: str? = None, input: str? = None, text: True, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -227,7 +227,7 @@ if sys.version_info >= (3, 11): errors: str? = None, input: str? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -261,7 +261,7 @@ if sys.version_info >= (3, 11): errors: str, input: str? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -296,7 +296,7 @@ if sys.version_info >= (3, 11): errors: str? = None, input: str? = None, text: True? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -330,7 +330,7 @@ if sys.version_info >= (3, 11): errors: None = None, input: ReadableBuffer? = None, text: False? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -364,7 +364,7 @@ if sys.version_info >= (3, 11): errors: str? = None, input: InputString? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -400,7 +400,7 @@ else: errors: str? = None, input: str? = None, text: True, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -461,7 +461,7 @@ else: errors: str? = None, input: str? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -494,7 +494,7 @@ else: errors: str, input: str? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -528,7 +528,7 @@ else: errors: str? = None, input: str? = None, text: True? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -561,7 +561,7 @@ else: errors: None = None, input: ReadableBuffer? = None, text: False? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -594,7 +594,7 @@ else: errors: str? = None, input: InputString? = None, text: bool? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, user: str | int | None = None, group: str | int | None = None, extra_groups: Iterable[str | int]? = None, @@ -626,7 +626,7 @@ if sys.version_info >= (3, 11): pass_fds: Collection[int] = (), *, encoding: str? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, text: bool? = None, user: str | int | None = None, group: str | int | None = None, @@ -666,7 +666,7 @@ else: pass_fds: Collection[int] = (), *, encoding: str? = None, - timeout: int | float | None = None, + timeout: (int | float)? = None, text: bool? = None, user: str | int | None = None, group: str | int | None = None, @@ -704,7 +704,7 @@ if sys.version_info >= (3, 11): restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = (), - timeout: int | float | None = None, + timeout: (int | float)? = None, *, encoding: str? = None, text: bool? = None, @@ -746,7 +746,7 @@ else: restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = (), - timeout: int | float | None = None, + timeout: (int | float)? = None, *, encoding: str? = None, text: bool? = None, @@ -786,7 +786,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -850,7 +850,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str, errors: str? = None, @@ -880,7 +880,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str, @@ -911,7 +911,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), # where the real keyword only ones start - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -941,7 +941,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: None = None, errors: None = None, @@ -971,7 +971,7 @@ if sys.version_info >= (3, 11): start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -1003,7 +1003,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -1066,7 +1066,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str, errors: str? = None, @@ -1095,7 +1095,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str, @@ -1125,7 +1125,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), # where the real keyword only ones start - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -1154,7 +1154,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: None = None, errors: None = None, @@ -1183,7 +1183,7 @@ else: start_new_session: bool = False, pass_fds: Collection[int] = (), *, - timeout: int | float | None = None, + timeout: (int | float)? = None, input: InputString? = None, encoding: str? = None, errors: str? = None, @@ -1301,9 +1301,9 @@ class Popen(Generic[AnyStr]): """ args: _CMD - stdin: IO[dynamic]? - stdout: IO[dynamic]? - stderr: IO[dynamic]? + stdin: IO[AnyStr]? + stdout: IO[AnyStr]? + stderr: IO[AnyStr]? pid: int returncode: int | MaybeNone universal_newlines: bool @@ -1677,13 +1677,13 @@ class Popen(Generic[AnyStr]): """ @ignorable_return_value - def wait(self, timeout: int | float | None = None) -> int: + def wait(self, timeout: (int | float)? = None) -> int: """Wait for child process to terminate; returns self.returncode.""" # morally the members of the returned tuple should be optional # TODO: this should allow ReadableBuffer for Popen[bytes], but adding # overloads for that runs into a mypy bug (python/mypy#14070). - def communicate(self, input: AnyStr | None = None, timeout: int | float | None = None) -> (AnyStr, AnyStr): + def communicate(self, input: AnyStr | None = None, timeout: (int | float)? = None) -> (AnyStr, AnyStr): """Interact with process: Send data to stdin and close it. Read data from stdout and stderr, until end-of-file is reached. Wait for process to terminate. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/sys/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/sys/__init__.byi index fa2cc06bef..aa85d7eee9 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/sys/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/sys/__init__.byi @@ -118,6 +118,7 @@ excepthook: (type[BaseException], BaseException, TracebackType?) -> dynamic exec_prefix: str executable: str float_repr_style: "short" | "legacy" +_framework: str # empty string on non-macOS platforms hexversion: int last_type: type[BaseException]? last_value: BaseException? @@ -430,7 +431,7 @@ final class _int_info(structseq[int], tuple[int, int, int, int]): let str_digits_check_threshold: int private type ThreadInfoName = "nt" | "pthread" | "pthread-stubs" | "solaris" -private type ThreadInfoLock = "semaphore" | "mutex+cond" | None +private type ThreadInfoLock = "semaphore" | "mutex+cond"? # This class is not exposed at runtime. It calls itself sys.thread_info. @type_check_only diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tarfile.byi b/crates/ty_vendored/vendor/typeshed/stdlib/tarfile.byi index 98da2867a3..7afb9139d7 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tarfile.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tarfile.byi @@ -46,7 +46,7 @@ if sys.version_info >= (3, 12): if sys.version_info >= (3, 13): __all__ += ["LinkFallbackError"] -private type FilterFunction = (TarInfo, str) -> (TarInfo?) +private type FilterFunction = (TarInfo, str) -> TarInfo? type _TarfileFilter = "fully_trusted" | "tar" | "data" | FilterFunction @type_check_only @@ -151,11 +151,11 @@ class TarFile: encoding: str? = None, errors: str = "surrogateescape", pax_headers: Mapping[str, str]? = None, - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 copybufsize: int? = None, # undocumented stream: bool = False, - mtime: int | float | None = None, + mtime: (int | float)? = None, ) -> None: """Open an (uncompressed) tar archive 'name'. 'mode' is either 'r' to read from an existing archive, 'a' to append data to an existing @@ -179,8 +179,8 @@ class TarFile: encoding: str? = None, errors: str = "surrogateescape", pax_headers: Mapping[str, str]? = None, - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 copybufsize: int? = None, # undocumented stream: bool = False, ) -> None: @@ -206,8 +206,8 @@ class TarFile: encoding: str? = None, errors: str = "surrogateescape", pax_headers: Mapping[str, str]? = None, - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 copybufsize: int? = None, # undocumented ) -> None: """Open an (uncompressed) tar archive `name'. `mode' is either 'r' to @@ -240,8 +240,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open a tar archive for reading, writing or appending. Return an appropriate TarFile class. @@ -298,8 +298,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 level: None = None, options: Mapping[int, int]? = None, zstd_dict: ZstdDict | (ZstdDict, int) | None = None, @@ -359,8 +359,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -376,8 +376,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -393,8 +393,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 compresslevel: int = 9, ) -> Self class def open( @@ -411,8 +411,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 compresslevel: int = 9, ) -> Self class def open( @@ -429,9 +429,9 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 - preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | None = ..., + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 + preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9? = ..., ) -> Self class def open( cls, @@ -447,9 +447,9 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 - preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | None = ..., + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 + preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9? = ..., ) -> Self if sys.version_info >= (3, 14): class def open( @@ -466,8 +466,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 options: Mapping[int, int]? = None, zstd_dict: ZstdDict | (ZstdDict, int) | None = None, ) -> Self: @@ -525,8 +525,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 options: Mapping[int, int]? = None, zstd_dict: ZstdDict | (ZstdDict, int) | None = None, ) -> Self @@ -545,8 +545,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -562,8 +562,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -579,8 +579,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -596,8 +596,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def open( cls, @@ -613,8 +613,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 compresslevel: int = 9, ) -> Self class def open( @@ -631,8 +631,8 @@ class TarFile: encoding: str? = ..., errors: str = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 compresslevel: int = 9, ) -> Self @@ -649,8 +649,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open uncompressed tar archive name for reading or writing.""" @@ -667,8 +667,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open gzip compressed tar archive name for reading or writing. Appending is not allowed. @@ -686,8 +686,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def bz2open( @@ -703,8 +703,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open bzip2 compressed tar archive name for reading or writing. Appending is not allowed. @@ -722,8 +722,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self class def xzopen( @@ -739,8 +739,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open lzma compressed tar archive name for reading or writing. Appending is not allowed. @@ -762,8 +762,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self: """Open zstd compressed tar archive name for reading or writing. Appending is not allowed. @@ -783,8 +783,8 @@ class TarFile: ignore_zeros: bool? = ..., encoding: str? = ..., pax_headers: Mapping[str, str]? = ..., - debug: 0 | 1 | 2 | 3 | None = None, # default 0 - errorlevel: 0 | 1 | 2 | None = None, # default 1 + debug: 0 | 1 | 2 | 3? = None, # default 0 + errorlevel: 0 | 1 | 2? = None, # default 1 ) -> Self def getmember(self, name: str) -> TarInfo: @@ -942,7 +942,7 @@ class TarFile: arcname: StrPath? = None, recursive: bool = True, *, - filter: ((TarInfo) -> (TarInfo?))? = None, + filter: ((TarInfo) -> TarInfo?)? = None, ): """Add the file 'name' to the archive. 'name' may be any type of file (directory, fifo, symbolic link, etc.). If given, 'arcname' diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/telnetlib.byi b/crates/ty_vendored/vendor/typeshed/stdlib/telnetlib.byi index 35c3a60074..5ce8830a83 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/telnetlib.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/telnetlib.byi @@ -224,7 +224,7 @@ class Telnet: """ - def read_until(self, match: bytes, timeout: int | float | None = None) -> bytes: + def read_until(self, match: bytes, timeout: (int | float)? = None) -> bytes: """Read until a given string is encountered or until timeout. When no match is found, return whatever is available instead, @@ -328,7 +328,7 @@ class Telnet: """Helper for mt_interact() -- this executes in the other thread.""" def expect( - self, list: MutableSequence[Pattern[bytes] | bytes] | Sequence[Pattern[bytes]], timeout: int | float | None = None + self, list: MutableSequence[Pattern[bytes] | bytes] | Sequence[Pattern[bytes]], timeout: (int | float)? = None ) -> (int, Match[bytes]?, bytes): """Read until one from a list of a regular expressions matches. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/threading.byi b/crates/ty_vendored/vendor/typeshed/stdlib/threading.byi index a2591c7c3b..fd622e4e14 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/threading.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/threading.byi @@ -368,7 +368,7 @@ class Thread: """ - def join(self, timeout: int | float | None = None): + def join(self, timeout: (int | float)? = None): """Wait until the thread terminates. This blocks the calling thread until the thread whose join() method is @@ -531,7 +531,7 @@ class Condition: def locked(self) -> bool @ignorable_return_value - def wait(self, timeout: int | float | None = None) -> bool: + def wait(self, timeout: (int | float)? = None) -> bool: """Wait until notified or until a timeout occurs. If the calling thread has not acquired the lock when this method is @@ -555,7 +555,7 @@ class Condition: """ - def wait_for[Element](self, predicate: () -> Element, timeout: int | float | None = None) -> Element: + def wait_for[Element](self, predicate: () -> Element, timeout: (int | float)? = None) -> Element: """Wait until a condition evaluates to True. predicate should be a callable which result will be interpreted as a @@ -605,7 +605,7 @@ class Semaphore: init(self, value: int = 1) def __exit__(self, t: type[BaseException]?, v: BaseException?, tb: TracebackType?) @ignorable_return_value - def acquire(self, blocking: bool = True, timeout: int | float | None = None) -> bool: + def acquire(self, blocking: bool = True, timeout: (int | float)? = None) -> bool: """Acquire a semaphore, decrementing the internal counter by one. When invoked without arguments: if the internal counter is larger than @@ -630,7 +630,7 @@ class Semaphore: """ - def __enter__(self, blocking: bool = True, timeout: int | float | None = None) -> bool: + def __enter__(self, blocking: bool = True, timeout: (int | float)? = None) -> bool: """Acquire a semaphore, decrementing the internal counter by one. When invoked without arguments: if the internal counter is larger than @@ -717,7 +717,7 @@ class Event: """ @ignorable_return_value - def wait(self, timeout: int | float | None = None) -> bool: + def wait(self, timeout: (int | float)? = None) -> bool: """Block until the internal flag is true. If the internal flag is true on entry, return immediately. Otherwise, @@ -778,7 +778,7 @@ class Barrier: let broken: bool - init(self, parties: int, action: (() -> None)? = None, timeout: int | float | None = None): + init(self, parties: int, action: (() -> None)? = None, timeout: (int | float)? = None): """Create a barrier, initialised to 'parties' threads. 'action' is a callable which, when supplied, will be called by one of @@ -788,7 +788,7 @@ class Barrier: """ - def wait(self, timeout: int | float | None = None) -> int: + def wait(self, timeout: (int | float)? = None) -> int: """Wait for the barrier. When the specified number of threads have started waiting, they are all diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.byi b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.byi index d7ed795c3f..7bc51cd9b4 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.byi @@ -546,7 +546,7 @@ class IntVar(Variable): class DoubleVar(Variable): """Value holder for float variables.""" - init(self, master: Misc? = None, value: int | float | None = None, name: str? = None): + init(self, master: Misc? = None, value: (int | float)? = None, name: str? = None): """Construct a float variable. MASTER can be given as master widget. @@ -615,6 +615,9 @@ class Misc: master: Misc? tk: _tkinter.TkappType children: dict[str, Widget] + if sys.version_info >= (3, 15): + __iter__: ClassVar[None] # prevent using __getitem__ for iteration + def destroy(self): """Internal function. @@ -928,7 +931,7 @@ class Misc: other applications do not get events anymore. """ - def grab_status(self) -> "local" | "global" | None: + def grab_status(self) -> "local" | "global"?: """Return None, "local" or "global" if this widget has no, a local or a global grab. """ @@ -1367,7 +1370,7 @@ class Misc: def pack_propagate(self) -> None propagate = pack_propagate - def grid_anchor(self, anchor: "nw" | "n" | "ne" | "w" | "center" | "e" | "sw" | "s" | "se" | None = None): + def grid_anchor(self, anchor: "nw" | "n" | "ne" | "w" | "center" | "e" | "sw" | "s" | "se"? = None): """The anchor value controls how to place the grid within the container widget when no row/column has any weight. @@ -1989,7 +1992,7 @@ class Wm: """ deiconify = wm_deiconify - def wm_focusmodel(self, model: "active" | "passive" | None = None) -> "active" | "passive" | "": + def wm_focusmodel(self, model: "active" | "passive"? = None) -> "active" | "passive" | "": """Set focus model to MODEL. "active" means that this widget will claim the focus itself, "passive" means that the window manager shall give the focus. Return current focus model if MODEL is None. @@ -2128,7 +2131,7 @@ class Wm: def wm_overrideredirect(self, boolean: bool) -> None overrideredirect = wm_overrideredirect - def wm_positionfrom(self, who: "program" | "user" | None = None) -> "" | "program" | "user": + def wm_positionfrom(self, who: "program" | "user"? = None) -> "" | "program" | "user": """Instruct the window manager that the position of this widget shall be defined by the user if WHO is "user", and by its own policy if WHO is "program". @@ -2153,7 +2156,7 @@ class Wm: def wm_resizable(self, width: bool, height: bool) -> None resizable = wm_resizable - def wm_sizefrom(self, who: "program" | "user" | None = None) -> "" | "program" | "user": + def wm_sizefrom(self, who: "program" | "user"? = None) -> "" | "program" | "user": """Instruct the window manager that the size of this widget shall be defined by the user if WHO is "user", and by its own policy if WHO is "program". @@ -2233,7 +2236,7 @@ class Tk(Misc, Wm): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -2621,7 +2624,7 @@ class Toplevel(BaseWidget, Wm): pady: int | float | str = 0, relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "flat", screen: str = "", # can't be changed after creating widget - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, use: int = ..., visual: str | (str, int) = "", width: int | float | str = 0, @@ -2653,7 +2656,7 @@ class Toplevel(BaseWidget, Wm): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -2713,7 +2716,7 @@ class Button(Widget): repeatdelay: int = ..., repeatinterval: int = ..., state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", text: int | float | str = "", # We allow the textvariable to be any Variable, not necessarily # StringVar. This is useful for e.g. a button that displays the value @@ -2776,7 +2779,7 @@ class Button(Widget): repeatdelay: int = ..., repeatinterval: int = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., underline: int = ..., @@ -2854,7 +2857,7 @@ class Canvas(Widget, XView, YView): selectforeground: str = ..., # man page says that state can be 'hidden', but it can't state: "normal" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", width: int | float | str = ..., xscrollcommand: str | ((float, float) -> object) = "", xscrollincrement: int | float | str = 0, @@ -2900,7 +2903,7 @@ class Canvas(Widget, XView, YView): selectborderwidth: int | float | str = ..., selectforeground: str = ..., state: "normal" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int | float | str = ..., xscrollcommand: str | ((float, float) -> object) = ..., xscrollincrement: int | float | str = ..., @@ -3701,7 +3704,7 @@ class Checkbutton(Widget): selectcolor: str = ..., selectimage: _Image | str = "", state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", text: int | float | str = "", textvariable: Variable = ..., tristateimage: _Image | str = "", @@ -3760,7 +3763,7 @@ class Checkbutton(Widget): selectcolor: str = ..., selectimage: _Image | str = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., tristateimage: _Image | str = ..., @@ -3828,8 +3831,8 @@ class Entry(Widget, XView): insertofftime: int = 300, insertontime: int = 600, insertwidth: int | float | str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = "", - invcmd: str | list[str] | (*: str) | (() -> bool) = "", # same as invalidcommand + invalidcommand: str | list[str] | (*: str) | (() -> object) = "", + invcmd: str | list[str] | (*: str) | (() -> object) = "", # same as invalidcommand justify: "left" | "center" | "right" = "left", name: str = ..., readonlybackground: str = ..., @@ -3839,7 +3842,7 @@ class Entry(Widget, XView): selectforeground: str = ..., show: str = "", state: "normal" | "disabled" | "readonly" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", textvariable: Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = "none", validatecommand: str | list[str] | (*: str) | (() -> bool) = "", @@ -3885,8 +3888,8 @@ class Entry(Widget, XView): insertofftime: int = ..., insertontime: int = ..., insertwidth: int | float | str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., - invcmd: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., + invcmd: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., readonlybackground: str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., @@ -3895,7 +3898,7 @@ class Entry(Widget, XView): selectforeground: str = ..., show: str = ..., state: "normal" | "disabled" | "readonly" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., @@ -3994,7 +3997,7 @@ class Frame(Widget): padx: int | float | str = 0, pady: int | float | str = 0, relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "flat", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, visual: str | (str, int) = "", # can't be changed with configure() width: int | float | str = 0, ): @@ -4024,7 +4027,7 @@ class Frame(Widget): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -4077,7 +4080,7 @@ class Label(Widget): pady: int | float | str = 1, relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "flat", state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, text: int | float | str = "", textvariable: Variable = ..., underline: int = -1, @@ -4132,7 +4135,7 @@ class Label(Widget): pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., underline: int = ..., @@ -4204,7 +4207,7 @@ class Listbox(Widget, XView, YView): selectmode: str | "single" | "browse" | "multiple" | "extended" = "browse", # noqa: Y051 setgrid: bool = False, state: "normal" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", width: int = 20, xscrollcommand: str | ((float, float) -> object) = "", yscrollcommand: str | ((float, float) -> object) = "", @@ -4250,7 +4253,7 @@ class Listbox(Widget, XView, YView): selectmode: str | "single" | "browse" | "multiple" | "extended" = ..., # noqa: Y051 setgrid: bool = ..., state: "normal" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int = ..., xscrollcommand: str | ((float, float) -> object) = ..., yscrollcommand: str | ((float, float) -> object) = ..., @@ -4365,7 +4368,7 @@ class Menu(Widget): postcommand: (() -> object) | str = "", relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., selectcolor: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, tearoff: bool | 0 | 1 = 1, # I guess tearoffcommand arguments are supposed to be widget objects, # but they are widget name strings. Use nametowidget() to handle the @@ -4403,7 +4406,7 @@ class Menu(Widget): postcommand: (() -> object) | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., selectcolor: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., tearoff: bool = ..., tearoffcommand: ((str, str) -> object) | str = ..., title: str = ..., @@ -4724,7 +4727,7 @@ class Menubutton(Widget): pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "flat", state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, text: int | float | str = "", textvariable: Variable = ..., underline: int = -1, @@ -4773,7 +4776,7 @@ class Menubutton(Widget): pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., underline: int = ..., @@ -4823,7 +4826,7 @@ class Message(Widget): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "flat", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, text: int | float | str = "", textvariable: Variable = ..., # there's width but no height @@ -4859,7 +4862,7 @@ class Message(Widget): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., width: int | float | str = ..., @@ -4920,7 +4923,7 @@ class Radiobutton(Widget): selectcolor: str = ..., selectimage: _Image | str = "", state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", text: int | float | str = "", textvariable: Variable = ..., tristateimage: _Image | str = "", @@ -4978,7 +4981,7 @@ class Radiobutton(Widget): selectcolor: str = ..., selectimage: _Image | str = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: Variable = ..., tristateimage: _Image | str = ..., @@ -5054,7 +5057,7 @@ class Scale(Widget): sliderlength: int | float | str = 30, sliderrelief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "raised", state: "normal" | "active" | "disabled" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", tickinterval: int | float = 0.0, to: int | float = 100.0, troughcolor: str = ..., @@ -5103,7 +5106,7 @@ class Scale(Widget): sliderlength: int | float | str = ..., sliderrelief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., state: "normal" | "active" | "disabled" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., tickinterval: int | float = ..., to: int | float = ..., troughcolor: str = ..., @@ -5131,7 +5134,7 @@ class Scale(Widget): def set(self, value): """Set the value to VALUE.""" - def coords(self, value: int | float | None = None) -> (int, int): + def coords(self, value: (int | float)? = None) -> (int, int): """Return a tuple (X,Y) of the point along the centerline of the trough that corresponds to VALUE or the current value if None is given. @@ -5173,7 +5176,7 @@ class Scrollbar(Widget): relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., repeatdelay: int = 300, repeatinterval: int = 100, - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", troughcolor: str = ..., width: int | float | str = ..., ): @@ -5209,7 +5212,7 @@ class Scrollbar(Widget): relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., repeatdelay: int = ..., repeatinterval: int = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., troughcolor: str = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: @@ -5316,7 +5319,7 @@ class Text(Widget, XView, YView): # Literal inside Tuple doesn't actually work tabs: int | float | str | (*: int | float | str) = "", tabstyle: "tabular" | "wordprocessor" = "tabular", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", undo: bool = False, width: int = 80, wrap: "none" | "char" | "word" = "char", @@ -5391,7 +5394,7 @@ class Text(Widget, XView, YView): state: "normal" | "disabled" = ..., tabs: int | float | str | (*: int | float | str) = ..., tabstyle: "tabular" | "wordprocessor" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., undo: bool = ..., width: int = ..., wrap: "none" | "char" | "word" = ..., @@ -6159,6 +6162,9 @@ class Image(_Image): name: Incomplete tk: _tkinter.TkappType + if sys.version_info >= (3, 15): + __iter__: ClassVar[None] # prevent using __getitem__ for iteration + init(self, imgtype, name=None, cnf={}, master: Misc | _tkinter.TkappType | None = None, **kw) def __del__(self) def __setitem__(self, key, value) @@ -6270,7 +6276,7 @@ class PhotoImage(Image, _PhotoImageLike): zoom: int | (int, int) | list[int] | None = None, subsample: int | (int, int) | list[int] | None = None, # `None` defaults to overlay. - compositingrule: "overlay" | "set" | None = None, + compositingrule: "overlay" | "set"? = None, ): """Copy a region from the source image (which must be a PhotoImage) to this image, possibly with pixel zooming and/or subsampling. If no @@ -6526,8 +6532,8 @@ class Spinbox(Widget, XView): insertofftime: int = 300, insertontime: int = 600, insertwidth: int | float | str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = "", - invcmd: str | list[str] | (*: str) | (() -> bool) = "", + invalidcommand: str | list[str] | (*: str) | (() -> object) = "", + invcmd: str | list[str] | (*: str) | (() -> object) = "", justify: "left" | "center" | "right" = "left", name: str = ..., readonlybackground: str = ..., @@ -6538,7 +6544,7 @@ class Spinbox(Widget, XView): selectborderwidth: int | float | str = ..., selectforeground: str = ..., state: "normal" | "disabled" | "readonly" = "normal", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", textvariable: Variable = ..., to: int | float = 0.0, validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = "none", @@ -6612,8 +6618,8 @@ class Spinbox(Widget, XView): insertofftime: int = ..., insertontime: int = ..., insertwidth: int | float | str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., - invcmd: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., + invcmd: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., readonlybackground: str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., @@ -6623,7 +6629,7 @@ class Spinbox(Widget, XView): selectborderwidth: int | float | str = ..., selectforeground: str = ..., state: "normal" | "disabled" | "readonly" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: Variable = ..., to: int | float = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., @@ -6802,7 +6808,7 @@ class LabelFrame(Widget): padx: int | float | str = 0, pady: int | float | str = 0, relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = "groove", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = 0, + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = 0, text: int | float | str = "", visual: str | (str, int) = "", # can't be changed with configure() width: int | float | str = 0, @@ -6845,7 +6851,7 @@ class LabelFrame(Widget): padx: int | float | str = ..., pady: int | float | str = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/font.byi b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/font.byi index f6baefeda2..95ee8db512 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/font.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/font.byi @@ -2,6 +2,7 @@ import _tkinter import itertools +import sys import tkinter from typing import ClassVar, Final, Literal, TypeAlias, TypedDict, type_check_only from typing_extensions import Unpack @@ -64,6 +65,8 @@ class Font: name: str delete_font: bool + if sys.version_info >= (3, 15): + __iter__: ClassVar[None] # prevent using __getitem__ for iteration counter: ClassVar[itertools.count[int]] # undocumented init( self, diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/simpledialog.byi b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/simpledialog.byi index 915b022635..6ffe6c8f13 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/simpledialog.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/simpledialog.byi @@ -85,9 +85,9 @@ def askfloat( title: str?, prompt: str, *, - initialvalue: int | float | None = ..., - minvalue: int | float | None = ..., - maxvalue: int | float | None = ..., + initialvalue: (int | float)? = ..., + minvalue: (int | float)? = ..., + maxvalue: (int | float)? = ..., parent: Misc? = ..., ) -> float?: """get a float from the user diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/ttk.byi b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/ttk.byi index 2b7f45ba46..8dad6097ce 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/ttk.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/ttk.byi @@ -376,7 +376,7 @@ class Button(Widget): padding=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = "", textvariable: tkinter.Variable = ..., underline: int = -1, @@ -406,7 +406,7 @@ class Button(Widget): padding=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: tkinter.Variable = ..., underline: int = ..., @@ -448,7 +448,7 @@ class Checkbutton(Widget): padding=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = "", textvariable: tkinter.Variable = ..., underline: int = -1, @@ -483,7 +483,7 @@ class Checkbutton(Widget): padding=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: tkinter.Variable = ..., underline: int = ..., @@ -531,13 +531,13 @@ class Entry(Widget, tkinter.Entry): exportselection: bool = True, font: _FontDescription = "TkTextFont", foreground: str = "", - invalidcommand: str | list[str] | (*: str) | (() -> bool) = "", + invalidcommand: str | list[str] | (*: str) | (() -> object) = "", justify: "left" | "center" | "right" = "left", name: str = ..., show: str = "", state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = "none", validatecommand: str | list[str] | (*: str) | (() -> bool) = "", @@ -571,12 +571,12 @@ class Entry(Widget, tkinter.Entry): exportselection: bool = ..., font: _FontDescription = ..., foreground: str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., show: str = ..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., @@ -607,12 +607,12 @@ class Entry(Widget, tkinter.Entry): exportselection: bool = ..., font: _FontDescription = ..., foreground: str = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., show: str = ..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., @@ -665,14 +665,14 @@ class Combobox(Entry): font: _FontDescription = ..., # undocumented foreground: str = ..., # undocumented height: int = 10, - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., # undocumented + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., # undocumented justify: "left" | "center" | "right" = "left", name: str = ..., postcommand: (() -> object) | str = "", show=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., # undocumented validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., # undocumented @@ -704,13 +704,13 @@ class Combobox(Entry): font: _FontDescription = ..., foreground: str = ..., height: int = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., postcommand: (() -> object) | str = ..., show=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., @@ -743,13 +743,13 @@ class Combobox(Entry): font: _FontDescription = ..., foreground: str = ..., height: int = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., postcommand: (() -> object) | str = ..., show=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., validatecommand: str | list[str] | (*: str) | (() -> bool) = ..., @@ -801,7 +801,7 @@ class Frame(Widget): padding: Padding = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", width: int | float | str = 0, ): """Construct a Ttk Frame with parent master. @@ -826,7 +826,7 @@ class Frame(Widget): padding: Padding = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int | float | str = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -868,7 +868,7 @@ class Label(Widget): relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", text: int | float | str = "", textvariable: tkinter.Variable = ..., underline: int = -1, @@ -906,7 +906,7 @@ class Label(Widget): relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: tkinter.Variable = ..., underline: int = ..., @@ -950,7 +950,7 @@ class Labelframe(Widget): padding: Padding = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., # undocumented style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", text: int | float | str = "", underline: int = -1, width: int | float | str = 0, @@ -980,7 +980,7 @@ class Labelframe(Widget): padding: Padding = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., underline: int = ..., width: int | float | str = ..., @@ -1022,7 +1022,7 @@ class Menubutton(Widget): padding=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = "", textvariable: tkinter.Variable = ..., underline: int = -1, @@ -1052,7 +1052,7 @@ class Menubutton(Widget): padding=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: tkinter.Variable = ..., underline: int = ..., @@ -1090,7 +1090,7 @@ class Notebook(Widget): name: str = ..., padding: Padding = ..., style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int = 0, ): """Construct a Ttk Notebook with parent master. @@ -1130,7 +1130,7 @@ class Notebook(Widget): height: int = ..., padding: Padding = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1257,7 +1257,7 @@ class Panedwindow(Widget, tkinter.PanedWindow): name: str = ..., orient: "vertical" | "horizontal" = "vertical", # can't be changed with configure() style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", width: int = 0, ): """Construct a Ttk Panedwindow with parent master. @@ -1291,7 +1291,7 @@ class Panedwindow(Widget, tkinter.PanedWindow): cursor: tkinter._Cursor = ..., height: int = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1316,7 +1316,7 @@ class Panedwindow(Widget, tkinter.PanedWindow): cursor: tkinter._Cursor = ..., height: int = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., width: int = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1384,7 +1384,7 @@ class Progressbar(Widget): orient: "horizontal" | "vertical" = "horizontal", phase: int = 0, # docs say read-only but assigning int to this works style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", value: int | float = 0.0, variable: tkinter.IntVar | tkinter.DoubleVar = ..., ): @@ -1412,7 +1412,7 @@ class Progressbar(Widget): orient: "horizontal" | "vertical" = ..., phase: int = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., value: int | float = ..., variable: tkinter.IntVar | tkinter.DoubleVar = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: @@ -1438,7 +1438,7 @@ class Progressbar(Widget): interval defaults to 50 milliseconds (20 steps/second) if omitted. """ - def step(self, amount: int | float | None = None): + def step(self, amount: (int | float)? = None): """Increments the value option by amount. amount defaults to 1.0 if omitted. @@ -1467,7 +1467,7 @@ class Radiobutton(Widget): padding=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = "", textvariable: tkinter.Variable = ..., underline: int = -1, @@ -1498,7 +1498,7 @@ class Radiobutton(Widget): padding=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., text: int | float | str = ..., textvariable: tkinter.Variable = ..., underline: int = ..., @@ -1548,7 +1548,7 @@ class Scale(Widget, tkinter.Scale): orient: "horizontal" | "vertical" = "horizontal", state: str = ..., # undocumented style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., to: int | float = 1.0, value: int | float = 0, variable: tkinter.IntVar | tkinter.DoubleVar = ..., @@ -1575,7 +1575,7 @@ class Scale(Widget, tkinter.Scale): orient: "horizontal" | "vertical" = ..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., to: int | float = ..., value: int | float = ..., variable: tkinter.IntVar | tkinter.DoubleVar = ..., @@ -1599,7 +1599,7 @@ class Scale(Widget, tkinter.Scale): orient: "horizontal" | "vertical" = ..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., to: int | float = ..., value: int | float = ..., variable: tkinter.IntVar | tkinter.DoubleVar = ..., @@ -1640,7 +1640,7 @@ class Scrollbar(Widget, tkinter.Scrollbar): name: str = ..., orient: "horizontal" | "vertical" = "vertical", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", ): """Construct a Ttk Scrollbar with parent master. @@ -1661,7 +1661,7 @@ class Scrollbar(Widget, tkinter.Scrollbar): cursor: tkinter._Cursor = ..., orient: "horizontal" | "vertical" = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1686,7 +1686,7 @@ class Scrollbar(Widget, tkinter.Scrollbar): cursor: tkinter._Cursor = ..., orient: "horizontal" | "vertical" = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1716,7 +1716,7 @@ class Separator(Widget): name: str = ..., orient: "horizontal" | "vertical" = "horizontal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", ): """Construct a Ttk Separator with parent master. @@ -1736,7 +1736,7 @@ class Separator(Widget): cursor: tkinter._Cursor = ..., orient: "horizontal" | "vertical" = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1767,7 +1767,7 @@ class Sizegrip(Widget): cursor: tkinter._Cursor = ..., name: str = ..., style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", ): """Construct a Ttk Sizegrip with parent master. @@ -1782,7 +1782,7 @@ class Sizegrip(Widget): *, cursor: tkinter._Cursor = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: """Query or modify the configuration options of the widget. @@ -1821,13 +1821,13 @@ class Spinbox(Entry): format: str = "", from_: int | float = 0, increment: int | float = 1, - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., # undocumented + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., # undocumented justify: "left" | "center" | "right" = ..., # undocumented name: str = ..., show=..., # undocumented state: str = "normal", style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., # undocumented to: int | float = 0, validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = "none", @@ -1865,12 +1865,12 @@ class Spinbox(Entry): format: str = ..., from_: int | float = ..., increment: int | float = ..., - invalidcommand: str | list[str] | (*: str) | (() -> bool) = ..., + invalidcommand: str | list[str] | (*: str) | (() -> object) = ..., justify: "left" | "center" | "right" = ..., show=..., state: str = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., textvariable: tkinter.Variable = ..., to: int | float = ..., validate: "none" | "focus" | "focusin" | "focusout" | "key" | "all" = ..., @@ -1956,7 +1956,7 @@ class Treeview(Widget, tkinter.XView, tkinter.YView): # surprised if someone is using it. show: "tree" | "headings" | "tree headings" | "" | list[str] | (*: str) = ("tree", "headings"), style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., xscrollcommand: str | ((float, float) -> object) = "", yscrollcommand: str | ((float, float) -> object) = "", ): @@ -1995,7 +1995,7 @@ class Treeview(Widget, tkinter.XView, tkinter.YView): selectmode: "extended" | "browse" | "none" = ..., show: "tree" | "headings" | "tree headings" | "" | list[str] | (*: str) = ..., style: str = ..., - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = ..., + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = ..., xscrollcommand: str | ((float, float) -> object) = ..., yscrollcommand: str | ((float, float) -> object) = ..., ) -> dict[str, (str, str, str, dynamic, dynamic)]?: @@ -2348,7 +2348,7 @@ class LabeledScale(Frame): padding: Padding = ..., relief: "raised" | "sunken" | "flat" | "ridge" | "solid" | "groove" = ..., style: str = "", - takefocus: bool | 0 | 1 | "" | ((str) -> (bool?)) = "", + takefocus: bool | 0 | 1 | "" | ((str) -> bool?) = "", width: int | float | str = 0, ): """Construct a horizontal LabeledScale with parent master, a diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/turtle.byi b/crates/ty_vendored/vendor/typeshed/stdlib/turtle.byi index 0471dd395c..61bfcd33ed 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/turtle.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/turtle.byi @@ -361,7 +361,7 @@ class TurtleScreenBase: """ def numinput( - self, title: str, prompt: str, default: int | float | None = None, minval: int | float | None = None, maxval: int | float | None = None + self, title: str, prompt: str, default: (int | float)? = None, minval: (int | float)? = None, maxval: (int | float)? = None ) -> float?: """Pop up a dialog window for input of a number. @@ -714,7 +714,7 @@ class TurtleScreen(TurtleScreenBase): """ - def listen(self, xdummy: int | float | None = None, ydummy: int | float | None = None): + def listen(self, xdummy: (int | float)? = None, ydummy: (int | float)? = None): """Set focus on TurtleScreen (in order to collect key-events) No arguments. @@ -884,7 +884,7 @@ class TNavigator: """ if sys.version_info >= (3, 12): - def teleport(self, x: int | float | None = None, y: int | float | None = None, *, fill_gap: bool = False): + def teleport(self, x: (int | float)? = None, y: (int | float)? = None, *, fill_gap: bool = False): """To be overwritten by child class RawTurtle. Includes no TPen references. """ @@ -1169,7 +1169,7 @@ class TNavigator: 90 """ - def circle(self, radius: int | float, extent: int | float | None = None, steps: int? = None): + def circle(self, radius: int | float, extent: (int | float)? = None, steps: int? = None): """Draw a circle with given radius. Arguments: @@ -1436,7 +1436,7 @@ class TPen: def color(self, color1: Color, color2: Color) -> None if sys.version_info >= (3, 12): - def teleport(self, x: int | float | None = None, y: int | float | None = None, *, fill_gap: bool = False): + def teleport(self, x: (int | float)? = None, y: (int | float)? = None, *, fill_gap: bool = False): """To be overwritten by child class RawTurtle. Includes no TNavigator references. """ @@ -1682,7 +1682,7 @@ class RawTurtle(TPen, TNavigator): >>> turtle.shapesize(outline=8) """ def shapesize( - self, stretch_wid: int | float | None = None, stretch_len: int | float | None = None, outline: int | float | None = None + self, stretch_wid: (int | float)? = None, stretch_len: (int | float)? = None, outline: (int | float)? = None ) -> None def shearfactor(self, shear: None = None) -> int | float: @@ -1728,7 +1728,7 @@ class RawTurtle(TPen, TNavigator): (4.0, -1.0, -0.0, 2.0) """ def shapetransform( - self, t11: int | float | None = None, t12: int | float | None = None, t21: int | float | None = None, t22: int | float | None = None + self, t11: (int | float)? = None, t12: (int | float)? = None, t21: (int | float)? = None, t22: (int | float)? = None ) -> None def get_shapepoly(self) -> PolygonCoords?: @@ -2252,7 +2252,7 @@ def textinput(title: str, prompt: str) -> str?: """ def numinput( - title: str, prompt: str, default: int | float | None = None, minval: int | float | None = None, maxval: int | float | None = None + title: str, prompt: str, default: (int | float)? = None, minval: (int | float)? = None, maxval: (int | float)? = None ) -> float?: """Pop up a dialog window for input of a number. @@ -2571,7 +2571,7 @@ def onkey(fun: () -> object, key: str): """ -def listen(xdummy: int | float | None = None, ydummy: int | float | None = None): +def listen(xdummy: (int | float)? = None, ydummy: (int | float)? = None): """Set focus on TurtleScreen (in order to collect key-events) No arguments. @@ -3080,7 +3080,7 @@ def setheading(to_angle: int | float): 90 """ -def circle(radius: int | float, extent: int | float | None = None, steps: int? = None): +def circle(radius: int | float, extent: (int | float)? = None, steps: int? = None): """Draw a circle with given radius. Arguments: @@ -3496,7 +3496,7 @@ def shape(name: None = None) -> str: def shape(name: str) -> None if sys.version_info >= (3, 12): - def teleport(x: int | float | None = None, y: int | float | None = None, *, fill_gap: bool = False): + def teleport(x: (int | float)? = None, y: (int | float)? = None, *, fill_gap: bool = False): """Instantly move turtle to an absolute position. Arguments: @@ -3554,7 +3554,7 @@ def shapesize() -> (int | float, int | float, int | float): >>> shapesize(5, 5, 12) >>> shapesize(outline=8) """ -def shapesize(stretch_wid: int | float | None = None, stretch_len: int | float | None = None, outline: int | float | None = None) -> None +def shapesize(stretch_wid: (int | float)? = None, stretch_len: (int | float)? = None, outline: (int | float)? = None) -> None def shearfactor(shear: None = None) -> int | float: """Set or return the current shearfactor. @@ -3599,7 +3599,7 @@ def shapetransform() -> (int | float, int | float, int | float, int | float): (4.0, -1.0, -0.0, 2.0) """ def shapetransform( - t11: int | float | None = None, t12: int | float | None = None, t21: int | float | None = None, t22: int | float | None = None + t11: (int | float)? = None, t12: (int | float)? = None, t21: (int | float)? = None, t22: (int | float)? = None ) -> None def get_shapepoly() -> PolygonCoords?: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/types.byi b/crates/ty_vendored/vendor/typeshed/stdlib/types.byi index ccc0bf4574..41aee75291 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/types.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/types.byi @@ -161,7 +161,7 @@ final class CodeType: let co_firstlineno: int if sys.version_info < (3, 15): @property - @deprecated("Deprecated since Python 3.10; will be removed in Python 3.15. Use `CodeType.co_lines()` instead.") + @deprecated("Deprecated since Python 3.10; removed in Python 3.15. Use `CodeType.co_lines()` instead.") def co_lnotab(self) -> bytes let co_freevars: (*: str) diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/typing.byi b/crates/ty_vendored/vendor/typeshed/stdlib/typing.byi index 504c69e016..6a48155798 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/typing.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/typing.byi @@ -1809,9 +1809,12 @@ class NamedTuple((*: dynamic)): if sys.version_info >= (3, 12): __orig_bases__: ClassVar[(*: dynamic)] - init(self, typename: str, fields: Iterable[(str, dynamic)], /) - @deprecated("Creating a typing.NamedTuple using keyword arguments is deprecated and support will be removed in Python 3.15") - def __init__(self, typename: str, fields: None = None, /, **kwargs: dynamic) -> None + if sys.version_info >= (3, 15): + def __init__(self, typename: str, fields: Iterable[(str, dynamic)], /) -> None + else: + def __init__(self, typename: str, fields: Iterable[(str, dynamic)], /) -> None + @deprecated("Creating a typing.NamedTuple using keyword arguments is deprecated; support removed in Python 3.15") + def __init__(self, typename: str, fields: None = None, /, **kwargs: dynamic) -> None final class def _make(cls, iterable: Iterable[dynamic]) -> typing_extensions.Self # ty:ignore[invalid-type-form] final def _asdict(self) -> dict[str, dynamic] diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/unittest/mock.byi b/crates/ty_vendored/vendor/typeshed/stdlib/unittest/mock.byi index 5d9096a492..4f33119913 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/unittest/mock.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/unittest/mock.byi @@ -536,7 +536,7 @@ class _patch_dict: values: dynamic clear: dynamic init(self, in_dict: dynamic, values: dynamic = (), clear: dynamic = False, **kwargs: dynamic) - def __call__(self, f: dynamic) -> dynamic + def __call__[F: (...) -> dynamic](self, f: F) -> F def __enter__(self) -> dynamic: """Patch the dict.""" @@ -980,16 +980,16 @@ class PropertyMock(Mock): if sys.version_info >= (3, 13): class ThreadingMixin(Base): - final DEFAULT_TIMEOUT: int | float | None = None + final DEFAULT_TIMEOUT: (int | float)? = None - init(self, /, *args: dynamic, timeout: int | float | None | _SentinelObject = ..., **kwargs: dynamic) + init(self, /, *args: dynamic, timeout: (int | float)? | _SentinelObject = ..., **kwargs: dynamic) # Same as `NonCallableMock.reset_mock.` def reset_mock(self, visited: dynamic = None, *, return_value: bool = False, side_effect: bool = False): """ See :func:`.Mock.reset_mock()` """ - def wait_until_called(self, *, timeout: int | float | None | _SentinelObject = ...): + def wait_until_called(self, *, timeout: (int | float)? | _SentinelObject = ...): """Wait until the mock object is called. `timeout` - time to wait for in seconds, waits forever otherwise. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/urllib/request.byi b/crates/ty_vendored/vendor/typeshed/stdlib/urllib/request.byi index 95ba87274d..56ef9d82f5 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/urllib/request.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/urllib/request.byi @@ -129,7 +129,7 @@ private type DataType = ReadableBuffer | SupportsRead[bytes] | Iterable[bytes] | if sys.version_info >= (3, 13): def urlopen( - url: str | Request, data: DataType? = None, timeout: int | float | None = ..., *, context: ssl.SSLContext? = None + url: str | Request, data: DataType? = None, timeout: (int | float)? = ..., *, context: ssl.SSLContext? = None ) -> UrlopenRet: """Open the URL url, which can be either a string or a Request object. @@ -176,7 +176,7 @@ else: def urlopen( url: str | Request, data: DataType? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., *, cafile: None = None, capath: None = None, @@ -239,7 +239,7 @@ else: def urlopen( url: str | Request, data: DataType? = None, - timeout: int | float | None = ..., + timeout: (int | float)? = ..., *, cafile: StrOrBytesPath? = None, capath: StrOrBytesPath? = None, @@ -352,7 +352,7 @@ class Request: unredirected_hdrs: dict[str, str] unverifiable: bool method: str? - timeout: int | float | None # Undocumented, only set after __init__() by OpenerDirector.open() + timeout: (int | float)? # Undocumented, only set after __init__() by OpenerDirector.open() init( self, url: str, @@ -381,7 +381,7 @@ class Request: class OpenerDirector: addheaders: list[(str, str)] def add_handler(self, handler: BaseHandler) - def open(self, fullurl: str | Request, data: DataType = None, timeout: int | float | None = ...) -> UrlopenRet + def open(self, fullurl: str | Request, data: DataType = None, timeout: (int | float)? = ...) -> UrlopenRet def error(self, proto: str, *args: dynamic) -> UrlopenRet def close(self) @@ -560,7 +560,7 @@ class ftpwrapper: # undocumented """Class used by open_ftp() for cache of open FTP connections.""" init( - self, user: str, passwd: str, host: str, port: int, dirs: str, timeout: int | float | None = None, persistent: bool = True + self, user: str, passwd: str, host: str, port: int, dirs: str, timeout: (int | float)? = None, persistent: bool = True ) def close(self) def endtransfer(self) diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/wave.byi b/crates/ty_vendored/vendor/typeshed/stdlib/wave.byi index c1c11e80b5..c0556ecd54 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/wave.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/wave.byi @@ -157,9 +157,9 @@ class Wave_read: def getcompname(self) -> str def getparams(self) -> _wave_params if sys.version_info < (3, 15): - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def getmarkers(self) -> None - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def getmark(self, id: dynamic) -> Never def setpos(self, pos: int) @@ -222,11 +222,11 @@ class Wave_write: def getparams(self) -> _wave_params if sys.version_info < (3, 15): - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def setmark(self, id: dynamic, pos: dynamic, name: dynamic) -> Never - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def getmark(self, id: dynamic) -> Never - @deprecated("Deprecated; will be removed in Python 3.15.") + @deprecated("Deprecated; removed in Python 3.15.") def getmarkers(self) -> None def tell(self) -> int diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/webbrowser.byi b/crates/ty_vendored/vendor/typeshed/stdlib/webbrowser.byi index 9b77383b2c..a5c74599ab 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/webbrowser.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/webbrowser.byi @@ -42,6 +42,8 @@ def open_new_tab(url: str) -> bool: If not possible, then the behavior becomes equivalent to open_new(). """ +def register_standard_browsers() + class BaseBrowser: """Parent class for all browsers. Do not use directly.""" @@ -114,7 +116,7 @@ if sys.platform == "win32": if sys.platform == "darwin": if sys.version_info < (3, 13): - @deprecated("Deprecated; removed in Python 3.13.") + @deprecated("Deprecated; removed in Python 3.13. Use `MacOSXOSAScript` instead.") class MacOSX(BaseBrowser): """Launcher class for Aqua browsers on Mac OS X @@ -129,10 +131,39 @@ if sys.platform == "darwin": init(self, name: str) override def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool - class MacOSXOSAScript(BaseBrowser): # In runtime this class does not have `name` and `basename` - if sys.version_info >= (3, 11): - def __init__(self, name: str = "default") -> None - else: - def __init__(self, name: str) -> None + if sys.version_info >= (3, 15): + class MacOS(BaseBrowser): + """Launcher class for macOS browsers, using /usr/bin/open. - override def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool + For http/https URLs with the default browser, /usr/bin/open is called + directly; macOS routes these to the registered browser. + + For all other URL schemes (e.g. file://) and for named browsers, + /usr/bin/open -b is used so that the URL is always passed + to a browser application rather than dispatched by the OS file handler. + This prevents file injection attacks where a file:// URL pointing to an + executable bundle could otherwise be launched by the OS. + + Named browsers with known bundle IDs use -b; unknown names fall back + to -a. + """ + + def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool + + @deprecated("Deprecated since Python 3.15; will be removed in Python 3.17. Use `MacOS` instead.") + class MacOSXOSAScript(BaseBrowser): # In runtime this class does not have `name` and `basename` + if sys.version_info >= (3, 11): + def __init__(self, name: str = "default") -> None + else: + def __init__(self, name: str) -> None + + def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool + + else: + class MacOSXOSAScript(BaseBrowser): # In runtime this class does not have `name` and `basename` + if sys.version_info >= (3, 11): + def __init__(self, name: str = "default") -> None + else: + def __init__(self, name: str) -> None + + override def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/wsgiref/handlers.byi b/crates/ty_vendored/vendor/typeshed/stdlib/wsgiref/handlers.byi index 6ab92f2139..5b3e7f892f 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/wsgiref/handlers.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/wsgiref/handlers.byi @@ -9,7 +9,7 @@ from .util import FileWrapper __all__ = ["BaseHandler", "SimpleHandler", "BaseCGIHandler", "CGIHandler", "IISCGIHandler", "read_environ"] -def format_date_time(timestamp: int | float | None) -> str # undocumented +def format_date_time(timestamp: (int | float)?) -> str # undocumented def read_environ() -> dict[str, str]: """Read environment, fixing HTTP variables""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementPath.byi b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementPath.byi index 5f9db1c553..9b6b70ad2e 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementPath.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementPath.byi @@ -18,7 +18,7 @@ def prepare_descendant(next: Next, token: Token) -> Callback? def prepare_parent(next: Next, token: Token) -> Callback def prepare_predicate(next: Next, token: Token) -> Callback? -final ops: dict[str, (Next, Token) -> (Callback?)] +final ops: dict[str, (Next, Token) -> Callback?] class _SelectorContext: parent_map: dict[Element, Element]? diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.byi b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.byi index 5b4e8a6a43..58fee30095 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.byi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.byi @@ -349,7 +349,7 @@ class ElementTree[in out Root in (Element, Element?) = Element?]: encoding: str? = None, xml_declaration: bool? = None, default_namespace: str? = None, - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, short_empty_elements: bool = True, ): @@ -399,7 +399,7 @@ def register_namespace(prefix: str, uri: str): def tostring( element: Element[dynamic], encoding: None = None, - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, @@ -421,7 +421,7 @@ def tostring( def tostring( element: Element[dynamic], encoding: "unicode", - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, @@ -430,7 +430,7 @@ def tostring( def tostring( element: Element[dynamic], encoding: str, - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, @@ -440,7 +440,7 @@ def tostring( def tostringlist( element: Element[dynamic], encoding: None = None, - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, @@ -449,7 +449,7 @@ def tostringlist( def tostringlist( element: Element[dynamic], encoding: "unicode", - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, @@ -458,7 +458,7 @@ def tostringlist( def tostringlist( element: Element[dynamic], encoding: str, - method: "xml" | "html" | "text" | "c14n" | None = None, + method: "xml" | "html" | "text" | "c14n"? = None, *, xml_declaration: bool? = None, default_namespace: str? = None, diff --git a/crates/ty_wasm/build.rs b/crates/ty_wasm/build.rs index 5355a7c669..57acd4db15 100644 --- a/crates/ty_wasm/build.rs +++ b/crates/ty_wasm/build.rs @@ -25,18 +25,17 @@ fn commit_info(workspace_root: &Path) { if let Some(git_head_path) = git_head(&git_dir) { println!("cargo:rerun-if-changed={}", git_head_path.display()); - let git_head_contents = fs::read_to_string(git_head_path); + let git_head_contents = fs::read_to_string(&git_head_path); if let Ok(git_head_contents) = git_head_contents { // The contents are either a commit or a reference in the following formats // - "" when the head is detached - // - "ref " when working on a branch + // - "ref: " when working on a branch // If a commit, checking if the HEAD file has changed is sufficient - // If a ref, we need to add the head file for that ref to rebuild on commit + // If a ref, we also need to watch where Git stores its current commit let mut git_ref_parts = git_head_contents.split_whitespace(); git_ref_parts.next(); if let Some(git_ref) = git_ref_parts.next() { - let git_ref_path = git_dir.join(git_ref); - println!("cargo:rerun-if-changed={}", git_ref_path.display()); + watch_git_ref(&git_head_path, git_ref); } } } @@ -64,27 +63,74 @@ fn commit_info(workspace_root: &Path) { fn git_head(git_dir: &Path) -> Option { // The typical case is a standard git repository. - let git_head_path = git_dir.join("HEAD"); - if git_head_path.exists() { - return Some(git_head_path); + if git_dir.is_dir() { + return Some(git_dir.join("HEAD")); } if !git_dir.is_file() { return None; } - // If `.git/HEAD` doesn't exist and `.git` is actually a file, - // then let's try to attempt to read it as a worktree. If it's - // a worktree, then its contents will look like this, e.g.: + + // Watch the pointer in case the worktree's Git directory changes. + println!("cargo:rerun-if-changed={}", git_dir.display()); + // A linked worktree has a `.git` file instead of a `.git` directory. + // Its contents point to the worktree-specific Git directory, e.g.: // - // gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2 + // gitdir: /home/andrew/astral/ruff/main/.git/worktrees/pr2 // // And the HEAD file we want to watch will be at: // - // /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD + // /home/andrew/astral/ruff/main/.git/worktrees/pr2/HEAD let contents = fs::read_to_string(git_dir).ok()?; let (label, worktree_path) = contents.split_once(':')?; if label != "gitdir" { return None; } - let worktree_path = worktree_path.trim(); - Some(PathBuf::from(worktree_path)) + // Relative `gitdir:` paths are relative to the directory containing `.git`. + let worktree_path = PathBuf::from(worktree_path.trim()); + let worktree_path = if worktree_path.is_absolute() { + worktree_path + } else { + git_dir.parent()?.join(worktree_path) + }; + Some(worktree_path.join("HEAD")) +} + +/// Watch the loose or packed Git reference for the current branch. +fn watch_git_ref(git_head_path: &Path, git_ref: &str) { + let Some(worktree_git_dir) = git_head_path.parent() else { + return; + }; + + // Worktrees have their own HEAD, but branch refs live in the shared Git directory. Their + // `commondir` file points to that directory, either absolutely or relative to this Git directory. + let common_dir_path = worktree_git_dir.join("commondir"); + let common_git_dir = if let Ok(common_dir) = fs::read_to_string(&common_dir_path) { + println!("cargo:rerun-if-changed={}", common_dir_path.display()); + let common_dir = PathBuf::from(common_dir.trim()); + if common_dir.is_absolute() { + common_dir + } else { + worktree_git_dir.join(common_dir) + } + } else { + worktree_git_dir.to_path_buf() + }; + + let git_ref_path = common_git_dir.join(git_ref); + if git_ref_path.exists() { + println!("cargo:rerun-if-changed={}", git_ref_path.display()); + } else { + // A packed branch ref has no loose ref file. Watch `packed-refs` instead of the missing + // loose ref, since Cargo would rebuild on every invocation for a nonexistent watched path. + let packed_refs = common_git_dir.join("packed-refs"); + if packed_refs.exists() { + println!("cargo:rerun-if-changed={}", packed_refs.display()); + } + // A later commit can recreate the loose ref, even when its parent directories do not exist + // yet. Watch the nearest existing ancestor so Cargo notices that transition. This can + // also rebuild when another ref in that directory changes. + if let Some(parent) = git_ref_path.ancestors().find(|parent| parent.is_dir()) { + println!("cargo:rerun-if-changed={}", parent.display()); + } + } } diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index db3f7b763b..0538f1022a 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -24,7 +24,7 @@ use ty_ide::{ }; use ty_ide::{NavigationTarget, NavigationTargets, hints, signature_help}; use ty_project::metadata::options::Options; -use ty_project::watch::{ChangeEvent, ChangedKind, CreatedKind, DeletedKind}; +use ty_project::watch::{ChangeEvent, ChangedKind, DeletedKind}; use ty_project::{CheckMode, ProjectMetadata}; use ty_project::{Db, ProjectDatabase, SemanticDb as _}; use ty_python_core::program::FallibleStrategy; @@ -199,10 +199,7 @@ impl Workspace { .write_file_all(&path, contents) .map_err(into_error)?; - self.db.apply_changes(&[ChangeEvent::Created { - path: path.clone(), - kind: CreatedKind::File, - }]); + self.db.apply_changes(&[ChangeEvent::Opened(path.clone())]); let file = system_path_to_file(&self.db, &path).expect("File to exist"); @@ -279,7 +276,7 @@ impl Workspace { #[wasm_bindgen(js_name = "hints")] pub fn hints(&self, file_id: &FileHandle) -> Result, Error> { - Ok(hints(&self.db, self.db.program_file(file_id.file)) + Ok(hints(&self.db, file_id.file) .into_iter() .map(|hint| Hint::from_ide_hint(&self.db, file_id.file, self.position_encoding, &hint)) .collect()) @@ -1622,6 +1619,7 @@ pub enum SemanticTokenKind { TypeParameter, Comment, Operator, + Regexp, } impl From for SemanticTokenKind { @@ -1644,6 +1642,7 @@ impl From for SemanticTokenKind { ty_ide::SemanticTokenType::TypeParameter => Self::TypeParameter, ty_ide::SemanticTokenType::Comment => Self::Comment, ty_ide::SemanticTokenType::Operator => Self::Operator, + ty_ide::SemanticTokenType::Regexp => Self::Regexp, } } } diff --git a/dist-workspace.toml b/dist-workspace.toml index ba546b53aa..8dc786135c 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -90,6 +90,12 @@ hosting = ["github"] # github-hosted runners so the release works without a depot account global = "ubuntu-latest" +# two edits to the generated `.github/workflows/release.yml` cannot be expressed here, so +# `dist generate` reverts them. reapply both after regenerating: +# - `custom-publish-pypi`'s `if:` carries `always() && needs.host.result == 'success' && +# needs.release-gate.result == 'success'`, so the release gate still holds +# - its `uses:` is the local `./.github/workflows/publish-pypi.yml`, not cargo-dist's `$/` form + [dist.github-action-commits] "actions/checkout" = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 "actions/upload-artifact" = "bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7.0.0 diff --git a/docs/basedpython/features/reified-generics.md b/docs/basedpython/features/reified-generics.md index 681ed0d02d..cbcc16dd82 100644 --- a/docs/basedpython/features/reified-generics.md +++ b/docs/basedpython/features/reified-generics.md @@ -388,18 +388,27 @@ f[int]() # T is int, Args is () a variadic never makes the specialization step mandatory the way a plain reified parameter does — supplying it nothing is a complete answer, not a -missing one — so a bare call stays legal and binds the empty run. the run is -not inferred from the call's arguments, so a non-empty one has to be written -out: +missing one — so a bare call stays legal. the run it binds is solved from the +arguments, the same way a lone type parameter and a keyword pack are, so +writing the step out and leaving it off reach the same answer: ```by def f[*Ts](*args: *Ts) -> None: print(Ts) -f(1, "a") # Ts is () +f(1, "a") # Ts is (int, str) f[int, str](1, "a") # Ts is (int, str) ``` +each element of the run is the argument's runtime type, so a literal widens to +its class under the file's numeric model — `2.0` binds `float`, not the +`int | float` that a float argument is merely *accepted* as + +inference can only fill the step with types that have a runtime spelling at the +call site. a class local to a function does not, so rather than bind a run +naming something the call cannot see, that call is rejected and the step has to +be written out + a [PEP 696] default is a run too, and fills the slot as one: ```by diff --git a/docs/formatter.md b/docs/formatter.md index ce932d3ee6..a5c05e23c9 100644 --- a/docs/formatter.md +++ b/docs/formatter.md @@ -303,7 +303,7 @@ support needs to be explicitly included by adding it to `types_or`: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.2 + rev: v0.16.6 hooks: - id: ruff-format types_or: [python, pyi, jupyter, markdown] diff --git a/docs/installation.md b/docs/installation.md index 12dc0849a1..2c7c96b315 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,8 +5,8 @@ Ruff is available as [`ruff`](https://pypi.org/project/ruff/) on PyPI. Ruff can be invoked directly with [`uvx`](https://docs.astral.sh/uv/): ```shell -uvx ruff check # Lint all files in the current directory. -uvx ruff format # Format all files in the current directory. +uvx ruff@0.16.6 check # Lint all files in the current directory. +uvx ruff@0.16.6 format # Format all files in the current directory. ``` Or installed with `uv` (recommended), `pip`, or `pipx`: diff --git a/docs/integrations.md b/docs/integrations.md index c4b1fdb036..735ef003d3 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -80,7 +80,7 @@ You can add the following configuration to `.gitlab-ci.yml` to run a `ruff forma stage: build interruptible: true image: - name: ghcr.io/astral-sh/ruff:0.16.2-alpine + name: ghcr.io/astral-sh/ruff:0.16.6-alpine before_script: - cd $CI_PROJECT_DIR - ruff --version @@ -106,7 +106,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.2 + rev: v0.16.6 hooks: # Run the linter. - id: ruff-check @@ -119,7 +119,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.2 + rev: v0.16.6 hooks: # Run the linter. - id: ruff-check @@ -133,7 +133,7 @@ To avoid running on Jupyter Notebooks, remove `jupyter` from the list of allowed ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.2 + rev: v0.16.6 hooks: # Run the linter. - id: ruff-check diff --git a/docs/linter.md b/docs/linter.md index bdf7783629..b0ae0e94cb 100644 --- a/docs/linter.md +++ b/docs/linter.md @@ -61,7 +61,7 @@ If you're wondering how to configure Ruff, here are some **recommended guideline - Prefer [`lint.select`](settings.md#lint_select) over [`lint.extend-select`](settings.md#lint_extend-select) to make your rule set explicit. - Use `ALL` with discretion. Enabling `ALL` will implicitly enable new rules whenever you upgrade. -- Start with a small set of rules (`select = ["E", "F"]`) and add a category at-a-time. For example, +- Start with a small set of rules (`select = ["E", "F"]`) and add a group at-a-time. For example, you might consider expanding to `select = ["E", "F", "B"]` to enable the popular flake8-bugbear extension. @@ -144,6 +144,129 @@ with the exception of `F401`. When [preview mode](preview.md) is enabled, rule selectors also accept the human-readable name of a rule (e.g., `unused-import`). +## Rule categories + +In [preview](preview.md), Ruff supports rule categories in addition to the Flake8-style linter +groups described above. These categories organize rules by the types of issues they detect and +determine whether rules are enabled by default. These categories and their descriptions, in +order of decreasing severity, are: + +- **Correctness**: These rules flag code that is outright wrong as written. If you encounter a + correctness issue, you should try to fix it rather than suppressing the error with `noqa` or + `ruff: ignore`. +- **Suspicious**: These rules are similar to `correctness` lints in that the code is likely wrong, + but `suspicious` lints acknowledge that there are valid reasons for the code to be written in this + way. You will still typically want to fix these issues, but using a suppression comment may + occasionally be necessary. Deprecations generally also fit into this category. +- **Complexity**: These rules detect code that can be written in a simpler or more readable way + without changing its semantics. +- **Performance**: These rules detect code that can be written in a more efficient way, without changing its semantics or significantly degrading readability. +- **Style**: These rules flag code that could be written more idiomatically and where the relevant + idiom has broad community acceptance. +- **Security**: These rules flag issues that could lead to security vulnerabilities, and as such, + bias heavily toward false positives to avoid false negatives. +- **Formatting**: These rules flag formatting issues and are generally redundant with a code + formatter. +- **Pedantic**: These rules are generally stylistic, like those in the `style` or similar + categories, but enforce styles that are too opinionated or are too prone to false positives to fit + into another category. +- **Restriction**: These rules restrict the usage of certain features in arbitrary ways. + +The first five categories compose the default rule set: + +=== "pyproject.toml" + + ```toml + [tool.ruff.lint] + preview = true + select = [ + "correctness", + "suspicious", + "complexity", + "performance", + "style", + ] + ``` + +=== "ruff.toml" + + ```toml + [lint] + preview = true + select = [ + "correctness", + "suspicious", + "complexity", + "performance", + "style", + ] + ``` + +while the remaining four (`security`, `formatting`, `pedantic`, and `restriction`) are off by +default. For certain projects, you may want to enable either `security` or `formatting` as entire +categories, but `pedantic` and `restriction` contain a wider variety of opinionated lints, and you +will typically only want to select individual rules from these categories directly. + +### Interaction with other selectors + +Categories can be freely mixed with linter groups, linter prefixes, rule codes, and rule names. In +addition to the priority relationships described above for settings like `lint.select`, +`lint.extend-select`, and `lint.ignore`, and those for various configuration sources like +`pyproject.toml` files and the CLI, the various selectors also have precedence relationships with +each other. In general, you can think of this precedence as increasing from the broadest selector +(`ALL`) to the narrowest single-rule selectors (e.g. `F401` or `unused-import`): + +```text +ALL < category < linter group < linter prefix < rule +``` + +As shown above, this means that configuration like: + +=== "pyproject.toml" + + ```toml + [tool.ruff.lint] + preview = true + select = ["E", "F"] + ignore = ["F401"] + ``` + +=== "ruff.toml" + + ```toml + [lint] + preview = true + select = ["E", "F"] + ignore = ["F401"] + ``` + +will select all `E` and `F` rules, with the exception of `F401`. Analogously, a selection with the +`suspicious` category like: + +=== "pyproject.toml" + + ```toml + [tool.ruff.lint] + preview = true + select = ["suspicious"] + ignore = ["UP"] + ``` + +=== "ruff.toml" + + ```toml + [lint] + preview = true + select = ["suspicious"] + ignore = ["UP"] + ``` + +would select all `suspicious` rules, except for the `UP` rules in that category. + +Note that we plan to deprecate and eventually remove the linter groups in the future. If you give +the new categories a try and run into situations where you need to fall back on linter groups, +please let us know on the [tracking issue](https://github.com/astral-sh/ruff/issues/27959). + ## Fixes Ruff supports automatic fixes for a variety of lint errors. For example, Ruff can remove unused diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index c26757440a..0000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -PyYAML==6.0.3 -ruff==0.16.2 -mkdocs==1.6.1 -mkdocs-material==9.7.7 -mkdocs-redirects==1.2.3 -mdformat==1.0.0 -mdformat-mkdocs==5.3.0 -mkdocs-github-admonitions-plugin @ git+https://github.com/PGijsbers/admonitions.git#7343d2f4a92e4d1491094530ef3d0d02d93afbb7 -mkdocs-llmstxt==0.2.0 diff --git a/docs/rule-proposals.md b/docs/rule-proposals.md new file mode 100644 index 0000000000..9f1fdbce8a --- /dev/null +++ b/docs/rule-proposals.md @@ -0,0 +1,191 @@ +# Proposing Lint Rules + +Rule suggestions can start out as brief issues. If a maintainer thinks the rule could be a good +addition to Ruff, they will apply the `needs-design` label to suggest filling out the steps +described below in support of the rule's acceptance. Some of this information will be easier to +obtain with a working rule implementation and doesn't need to be present in the initial proposal. + +A design for a new lint rule in Ruff should include the following components: + +- A proposed name that follows our [rule naming convention](#rule-naming-convention) + +- A proposed category that follows our [rule categorization guidelines](#rule-categorization-guidelines) + +- A draft of the rule documentation with the following sections: + + - "What it does": a one line description of what the rule checks + - "Why is this bad?": a longer explanation of the pattern flagged by the rule and why it causes problems in real projects + - "Example": a code example showing the problematic code, as well as a code block showing the fixed code + + Some rules benefit from additional documentation. These sections usually emerge through the + implementation process and aren't required in a design proposal. "Fix safety" and "Options" + sections are required for rules with unsafe fixes and that rely on any settings, respectively, + but the rest of these are fully optional. You can `grep` for each heading to see where they are + often used. + + - "Known problems": any known limitations of the rule, such as false positives or negatives + - "Fix availability": if the rule only has an autofix in some cases, explain why + - "Fix safety": if the rule’s fix is ever unsafe, explain why + - "Options": if the rule depends upon any configuration options, list them + - "See also": if there are other similar or synergistic rules, list them + - "References": if there are any relevant external references to Python or other documentation, list them + + A few examples of great rule documentation include [`mutable-argument-default` (`B006`)][b006], + [`quoted-annotation` (`UP037`)][up037], and [`used-dummy-variable` (`RUF052`)][ruf052]. + +- An example diagnostic including the proposed name, primary message, and fix title (if applicable) + + This is another nice bonus that isn't required for a design proposal but concisely reveals a lot + of helpful information about a rule. For example: + + ```markdown + my-new-rule: primary diagnostic message + --> example.py:1:1 + 1 | import math + | ^^^^ + help: fix title + ``` + + When choosing a diagnostic range (marked by `^^^^` above), also consider that the start of the + range determines where `noqa` comments will be valid + +## Rule naming convention + +Like Clippy, Ruff's rule names should make grammatical and logical sense when read as "ignore +${rule}" or "ignore ${rule} items", as in the context of suppression comments. + +For example, `AssertFalse` fits this convention: it flags `assert False` statements, and so a +suppression comment would be framed as "ignore `assert False`". + +As such, rule names should... + +- Highlight the pattern that is being linted against, rather than the preferred alternative. + For example, `AssertFalse` guards against `assert False` statements. + +- _Not_ contain instructions on how to fix the violation, which instead belong in the rule + documentation and the `fix_title`. + +- _Not_ contain a redundant prefix, like `Disallow` or `Banned`, which are already implied by the + convention. + +When re-implementing rules from other linters, we prioritize adhering to this convention over +preserving the original rule name. + +## Rule categorization guidelines + +Choosing a category is a crucial part of the rule proposal and acceptance process. To paraphrase the +[Clippy documentation](https://rust-lang.github.io/rfcs/2476-clippy-uno.html#what-lints-belong-in-clippy), +if a rule doesn't fit in the categories, it probably doesn't fit in Ruff. Descriptions of each category can +be found in the [rule category documentation](linter.md#rule-categories), +but the flow chart below is intended to facilitate category assignment. + +```mermaid +--- +config: + flowchart: + nodeSpacing: 20 + rankSpacing: 25 + padding: 8 + themeVariables: + fontSize: 13px +--- +flowchart TD + A("Formatting, security,
or language restriction?") + A -->|Yes| B["Formatting · Security · Restriction"] + A -->|No| C("Too noisy or opinionated?") + + C -->|Yes| D["Pedantic"] + C -->|No| E("Incorrect or deprecated?") + + E -->|Yes| F("Definitely wrong
today?") + F -->|Yes| G["Correctness"] + F -->|No| H["Suspicious"] + + E -->|No| I("Primary improvement?") + I -->|Simpler| J["Complexity"] + I -->|Faster| K["Performance"] + I -->|Idiomatic| L["Style"] +``` + +The first question filters out special categories of rules: those that relate to the visual +presentation of code (`formatting`), those that relate to `security` vulnerabilities, and those that +impose `restriction`s on certain features. "Restriction" here specifically means an arbitrary or +severe restriction, not the broad way in which any lint rule could be considered to restrict usage. +Examples of restriction lints are rules like `assert` (`S101`) and `print` (`T201`), which ban basic +language features across the board. + +If none of these special categories is quite right, the next question asks you to judge whether the +rule is too noisy or opinionated for general use. This is somewhat subjective, but an [ecosystem +report](https://docs.astral.sh/ruff/contributing/#ecosystem-report) can be helpful to see how many +diagnostics are emitted in real projects. +A large number of diagnostics doesn't immediately make a rule `pedantic`, but many false positives +or diagnostics that reasonable Python users would disagree with do. + +If a rule is not overly pedantic, we next consider the intention of the rule. If the main goal is +detecting code that is incorrect, the options narrow to `correctness` or `suspicious`. Rules in the +`correctness` category typically cause immediate issues like syntax or runtime errors, or silently +do something the user didn't intend. Similarly, `suspicious` lints flag the same kind of code, but +in cases where Ruff can't be sure what the user intended. A perfect example of a `suspicious` rule +is `mutable-argument-default` (`B006`). This classic footgun is almost always a mistake, but in some +cases, it may be intentional, in which case a `noqa` or `ruff: ignore` comment should be used. Such +suppression comments should essentially never be reasonable for a `correctness` lint but are fine +for `suspicious` lints. The `suspicious` category also includes deprecations, which aren't incorrect +today but will cause errors in the future. + +The final branch of the flow chart deals with stylistic lints, which are again somewhat subjective +to differentiate between changes that make code simpler often also make the code faster and +more idiomatic. Thus, the question prompts you to consider the _primary_ improvement. Rules that +primarily make code simpler are `complexity` lints, those that primarily make code faster or use +less memory are `performance` lints, and those that primarily make code more idiomatic are `style`. + +## Other guidelines + +Following these steps should generally ensure that a rule is a good fit for Ruff. A couple of +additional things to watch out for are: + +- Rules that conflict with other tools, or especially other rules + + Although we have many existing `formatting` rules that overlap and even conflict with our + formatter, we are not eager to add more. Similarly, we should avoid rules that mainly support + type checker usage, when type checkers themselves emit similar diagnostics. Most clearly, we + should avoid rules that overlap or conflict with other lint rules. This often suggests that the + existing rule should instead be made configurable to toggle between the two behaviors. + + Checking both the input and output examples from your rule proposal with `ALL` rules selected in + the [linter], with the [formatter], and with a [type checker] like ty is a good quick check for + conflicts. + +- Rules that apply to third-party libraries + + Most Ruff rules should be helpful for large numbers of Python developers. This means that rules + should generally apply to Python language features or functionality from the standard library. + However, rules for widely-used third-party libraries can also meet this bar and be good + candidates for inclusion in Ruff. + +- Rules that require additional configuration + + Most rules should function correctly once enabled without requiring additional settings. If this + isn’t possible, the rule should typically “fail safe” and avoid emitting diagnostics. + `banned-api` (`TID251`) is an example of such a rule that has no effect without configuring + `lint.flake8-tidy-imports.banned-api`. Avoid rules that emit a ton of diagnostics until some + kind of allowlist is configured. + +- Rules that are hard to explain + + This guideline is inspired by ESLint’s “Generic” [rule guideline](https://eslint.org/docs/latest/contribute/propose-new-rule#core-rule-guidelines): + + > Rules cannot be so specific that users will have trouble understanding when to use them. A + > rule is typically too specific if describing what it does requires more than two "and"s (if a + > and b and c and d, then this rule warns). + + Watch out for this kind of pattern when writing your `Why is this bad?` or `Known problems` + section, or if you have a hard time categorizing the rule. The `pedantic` category exists to + hold rules that are controversial or niche, but very niche rules may still not be good fits for + Ruff. + +[b006]: https://docs.astral.sh/ruff/rules/mutable-argument-default/ +[formatter]: https://play.ruff.rs/1265904d-f03c-4d22-aa87-1e6ca16708c2?secondary=Format +[linter]: https://play.ruff.rs/1265904d-f03c-4d22-aa87-1e6ca16708c2 +[ruf052]: https://docs.astral.sh/ruff/rules/used-dummy-variable/ +[type checker]: https://play.ty.dev/b2d4212e-1243-4d75-a340-ae6ff2e2a6ca +[up037]: https://docs.astral.sh/ruff/rules/quoted-annotation/ diff --git a/docs/tutorial.md b/docs/tutorial.md index c58e24ec8f..1f733f094c 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -372,7 +372,7 @@ This tutorial has focused on Ruff's command-line interface, but Ruff can also be ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.2 + rev: v0.16.6 hooks: # Run the linter. - id: ruff-check diff --git a/fuzz/README.md b/fuzz/README.md index fc912cd3a7..88d1855cd1 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -24,7 +24,7 @@ without it (very unlikely for the fuzzer to generate valid python code from "thi Once you have initialised the fuzzers, you can then execute any fuzzer with: ```bash -cargo fuzz run -s none name_of_fuzzer -- -timeout=1 +uv run --only-dev cargo fuzz run -s none name_of_fuzzer -- -timeout=1 ``` > [!NOTE] @@ -33,10 +33,10 @@ cargo fuzz run -s none name_of_fuzzer -- -timeout=1 > command, as this architecture does not support fuzzing without a sanitizer. > > ```shell -> cargo +nightly fuzz run name_of_fuzzer -- -timeout=1 +> uv run --only-dev cargo +nightly fuzz run name_of_fuzzer -- -timeout=1 > ``` -You can view the names of the available fuzzers with `cargo fuzz list`. +You can view the names of the available fuzzers with `uv run --only-dev cargo fuzz list`. For specific details about how each fuzzer works, please read this document in its entirety. > [!NOTE] @@ -53,7 +53,7 @@ triggered with a smaller input. `cargo-fuzz` supports this out of the box with: ```bash -cargo fuzz tmin -s none name_of_fuzzer artifacts/name_of_fuzzer/crash-... +uv run --only-dev cargo fuzz tmin -s none name_of_fuzzer artifacts/name_of_fuzzer/crash-... ``` From here, you will need to analyse the input and potentially the behaviour of the program. diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index d86e988072..0416895942 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -19,6 +19,7 @@ use ty_module_resolver::{Db as ModuleResolverDb, SearchPathSettings}; use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, ProgramSettings}; use ty_python_core::{Db as _, ProgramFile, TestProgramDb}; +use ty_python_semantic::dependency::DependencyMetadata; use ty_python_semantic::lint::LintRegistry; use ty_python_semantic::types::check_types; use ty_python_semantic::{ @@ -139,6 +140,10 @@ impl SemanticDb for TestDb { &self.analysis_settings } + fn dependency_metadata(&self, _file: File) -> Option<&DependencyMetadata> { + None + } + fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } diff --git a/fuzz/init-fuzzer.sh b/fuzz/init-fuzzer.sh index 4e972e472e..56054f9550 100755 --- a/fuzz/init-fuzzer.sh +++ b/fuzz/init-fuzzer.sh @@ -6,11 +6,6 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) cd "$SCRIPT_DIR" -if ! cargo fuzz --help >&/dev/null; then - echo "Installing cargo-fuzz..." - cargo install --git https://github.com/rust-fuzz/cargo-fuzz.git -fi - if [ ! -d corpus/common ]; then mkdir -p corpus/common @@ -44,9 +39,9 @@ if [ ! -d corpus/common ]; then echo "Minifying the corpus dataset..." if [[ "$OSTYPE" == "darwin"* ]]; then - cargo +nightly fuzz cmin ruff_fix_validity corpus/common -- -timeout=5 + uv run --only-dev cargo +nightly fuzz cmin ruff_fix_validity corpus/common -- -timeout=5 else - cargo fuzz cmin -s none ruff_fix_validity corpus/common -- -timeout=5 + uv run --only-dev cargo fuzz cmin -s none ruff_fix_validity corpus/common -- -timeout=5 fi fi diff --git a/hawk.toml b/hawk.toml new file mode 100644 index 0000000000..a85e7fae6e --- /dev/null +++ b/hawk.toml @@ -0,0 +1,1347 @@ +# Keep intentional uniform field visibility audited instead of excluding whole files. + +preserve-uniform-field-visibility = true + +[[production]] +package = "ruff" +bin = "buff" +reason = "shipped Ruff binary" + +[[production]] +package = "ty" +bin = "by" +reason = "shipped ty type checker binary" + +[[production]] +package = "ruff_python_formatter" +bin = "ruff_python_formatter" +reason = "workspace formatter development binary" + +[[production]] +package = "ruff_dev" +bin = "ruff_dev" +reason = "workspace development binary" + +[[production]] +package = "ty_completion_bench" +bin = "ty_completion_bench" +reason = "workspace completion benchmark binary" + +[[production]] +package = "ty_completion_eval" +bin = "ty_completion_eval" +reason = "workspace completion evaluation binary" + +# Keep this list in sync with workspace packages that define doctests. + +[[doctest]] +package = "ruff_annotate_snippets" + +[[doctest]] +package = "ruff_cache" + +[[doctest]] +package = "ruff_db" + +[[doctest]] +package = "ruff_formatter" + +[[doctest]] +package = "ruff_linter" + +[[doctest]] +package = "ruff_notebook" + +[[doctest]] +package = "ruff_options_metadata" + +[[doctest]] +package = "ruff_python_ast" + +[[doctest]] +package = "ruff_python_parser" + +[[doctest]] +package = "ruff_python_stdlib" + +[[doctest]] +package = "ruff_python_trivia" + +[[doctest]] +package = "ruff_source_file" + +[[doctest]] +package = "ruff_text_size" + +[[doctest]] +package = "ruff_workspace" + +[[doctest]] +package = "ty_module_resolver" + +[[doctest]] +package = "ty_python_semantic" + +# Keep APIs used by targets outside Hawk's workspace analysis. + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_codegen" +item = "generator::Generator::<'a>::unparse_suite" +kind = "inherent_method" +level = "expect" +reason = "the standalone fuzz crate uses this method" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_trivia" +item = "cursor::EOF_CHAR" +kind = "constant" +level = "expect" +reason = "public cursor documentation links to this end-of-file sentinel" + +# Keep APIs referenced by public documentation or paired with sibling APIs. + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "format_element::BestFittingVariants::most_expanded" +kind = "inherent_method" +level = "expect" +reason = "public best-fitting macro documentation links to this variant accessor" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "printer::printer_options::LineEnding::as_str" +kind = "inherent_method" +level = "expect" +reason = "public line-ending settings documentation links to this representation accessor" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "settings::TargetVersion::linter_version" +kind = "inherent_method" +level = "expect" +reason = "preserves the public parser and linter target-version accessor pair" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_parser" +item = "Parsed::::has_no_syntax_errors" +kind = "inherent_method" +level = "expect" +reason = "preserves the public syntax-validity predicate API" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_workspace" +item = "settings::FormatterSettings::resolve_target_version" +kind = "inherent_method" +level = "expect" +reason = "public formatter settings fields direct callers to this resolver" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_python_semantic" +item = "semantic_model::SemanticModel::<'db>::scope" +kind = "inherent_method" +level = "expect" +reason = "preserves the documented semantic-model scope navigation API" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_python_semantic" +item = "types::set_theoretic::UnionType::<'db>::from_two_elements" +kind = "inherent_method" +level = "expect" +reason = "public union construction documentation recommends this optimized constructor" + +# Keep the public code-component accessor API symmetric. + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_linter" +item = "codes::NoqaCode::prefix" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric prefix and suffix accessor API" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "codes::NoqaCode::suffix" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric prefix and suffix accessor API" + +# Keep the public edit-operation predicate API symmetric. + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_diagnostics" +item = "edit::Edit::is_deletion" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric edit-operation predicate API" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_diagnostics" +item = "edit::Edit::is_insertion" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric edit-operation predicate API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_diagnostics" +item = "edit::Edit::is_replacement" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric edit-operation predicate API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "IndentStyle::is_space" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional symmetric indent-style predicate API" + +# Keep the public printer-options builder API uniform. + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "printer::printer_options::PrinterOptions::with_line_width" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional uniform printer-options builder API" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "printer::printer_options::PrinterOptions::with_indent" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional uniform printer-options builder API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "printer::printer_options::PrinterOptions::with_tab_width" +kind = "inherent_method" +level = "expect" +reason = "preserves the intentional uniform printer-options builder API" + +# Keep public APIs retained during cleanup review. + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "buffer::VecBuffer::<'a, Context>::take_vec" +kind = "inherent_method" +level = "expect" +reason = "formatter buffers support both consuming and reusable element extraction" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "format_element::LineMode::is_hard" +kind = "inherent_method" +level = "expect" +reason = "public line modes retain their classification predicate" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "format_element::FormatElement::is_tag" +kind = "inherent_method" +level = "expect" +reason = "public format elements retain their tag inspection API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "format_element::FormatElement::is_start_tag" +kind = "inherent_method" +level = "expect" +reason = "public format elements retain their tag inspection API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "format_element::tag::Condition::if_fits_on_line" +kind = "inherent_method" +level = "expect" +reason = "conditional formatting constructors intentionally cover fits and breaks variants" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "format_element::tag::VerbatimKind::is_bogus" +kind = "inherent_method" +level = "expect" +reason = "public verbatim kinds retain their classification predicate" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "SimpleFormatContext::with_source_code" +kind = "inherent_method" +level = "expect" +reason = "the public simple formatting context supports source-backed downstream tests and examples" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::new_empty" +kind = "inherent_method" +level = "expect" +reason = "public formatter results retain their empty constructor" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::range" +kind = "inherent_method" +level = "expect" +reason = "public formatter results expose their covered source range" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::into_sourcemap" +kind = "inherent_method" +level = "expect" +reason = "formatter results expose borrowed, owned, and take-based source map access" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::take_sourcemap" +kind = "inherent_method" +level = "expect" +reason = "formatter results expose borrowed, owned, and take-based source map access" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::verbatim" +kind = "inherent_method" +level = "expect" +reason = "public formatter results expose verbatim text and range metadata" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::verbatim_ranges" +kind = "inherent_method" +level = "expect" +reason = "formatter results expose borrowed and take-based verbatim range access" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "Printed::take_verbatim_ranges" +kind = "inherent_method" +level = "expect" +reason = "formatter results expose borrowed and take-based verbatim range access" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "FormatOwnedWithRule::::with_item" +kind = "inherent_method" +level = "expect" +reason = "the public owned-format adapter retains its builder API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "FormatOwnedWithRule::::with_options" +kind = "inherent_method" +level = "expect" +reason = "the public owned-format adapter retains its builder API" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "printer::printer_options::PrintWidth::new" +kind = "inherent_method" +level = "expect" +reason = "the public print-width wrapper retains its explicit constructor" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "printer::printer_options::SourceMapGeneration::is_disabled" +kind = "inherent_method" +level = "expect" +reason = "source map generation exposes symmetric enabled and disabled predicates" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_ast" +item = "helpers::map_starred" +kind = "function" +level = "expect" +reason = "AST helpers consistently unwrap callable, subscripted, and starred expressions" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_parser" +item = "error::ParseError::error" +kind = "inherent_method" +level = "expect" +reason = "downstream parser clients can consume a parse error into its error kind" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_parser" +item = "Parsed::::as_result" +kind = "inherent_method" +level = "expect" +reason = "downstream parser clients retain borrowed result conversion" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_parser" +item = "Parsed::::into_expr" +kind = "inherent_method" +level = "expect" +reason = "parsed expressions retain borrowed and consuming accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "binding::Binding::<'a>::is_deleted" +kind = "inherent_method" +level = "expect" +reason = "the public binding API retains its deleted-state query" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "cfg::graph::ControlFlowGraph::<'stmt>::predecessors" +kind = "inherent_method" +level = "expect" +reason = "the public control-flow graph supports downstream predecessor inspection" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "model::SemanticModel::<'a>::node" +kind = "inherent_method" +level = "expect" +reason = "the public semantic model retains node navigation for downstream analyses" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "model::SemanticModel::<'a>::in_runtime_required_annotation" +kind = "inherent_method" +level = "expect" +reason = "semantic model predicates intentionally expose each annotation context" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "model::SemanticModel::<'a>::in_simple_string_type_definition" +kind = "inherent_method" +level = "expect" +reason = "semantic model predicates intentionally expose each string type context" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "model::SemanticModel::<'a>::in_dunder_all_definition" +kind = "inherent_method" +level = "expect" +reason = "semantic model predicates retain the documented dunder-all context query" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "model::TypingOnlyBindingsStatus::is_allowed" +kind = "inherent_method" +level = "expect" +reason = "typing-only binding status exposes symmetric allowed and disallowed predicates" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "reference::ResolvedReference::in_annotated_type_alias_value" +kind = "inherent_method" +level = "expect" +reason = "resolved references expose their complete semantic context interface" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "semantic_model::SemanticModel::<'db>::file_path" +kind = "inherent_method" +level = "expect" +reason = "the public semantic model exposes its file identity and source locations" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "semantic_model::SemanticModel::<'db>::line_index" +kind = "inherent_method" +level = "expect" +reason = "the public semantic model exposes its file identity and source locations" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "formatter::Formatter::<'_, Context>::state_snapshot" +kind = "inherent_method" +level = "expect" +reason = "formatter clients use snapshots to restore state after speculative formatting" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "formatter::Formatter::<'_, Context>::restore_state_snapshot" +kind = "inherent_method" +level = "expect" +reason = "formatter clients use snapshots to restore state after speculative formatting" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_formatter" +item = "formatter::FormatterSnapshot" +kind = "struct" +level = "expect" +reason = "formatter clients use snapshots to restore state after speculative formatting" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "binding::Binding::<'a>::is_deferred_type_alias" +kind = "inherent_method" +level = "expect" +reason = "binding predicates intentionally expose the distinct type-alias classifications" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_semantic" +item = "binding::Binding::<'a>::is_type_alias" +kind = "inherent_method" +level = "expect" +reason = "binding predicates intentionally expose the distinct type-alias classifications" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_source_file" +item = "line_index::OneIndexed::checked_add" +kind = "inherent_method" +level = "expect" +reason = "one-indexed arithmetic intentionally provides checked and saturating operations" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_source_file" +item = "line_index::OneIndexed::checked_sub" +kind = "inherent_method" +level = "expect" +reason = "one-indexed arithmetic intentionally provides checked and saturating operations" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_text_size" +item = "size::TextSize::saturating_add" +kind = "inherent_method" +level = "expect" +reason = "TextSize intentionally provides both checked and saturating arithmetic" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_text_size" +item = "size::TextSize::saturating_sub" +kind = "inherent_method" +level = "expect" +reason = "TextSize intentionally provides both checked and saturating arithmetic" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_project" +item = "db::changes::ChangeResult::custom_stdlib_changed" +kind = "inherent_method" +level = "expect" +reason = "change results expose both project and custom-stdlib invalidation state" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_ast" +item = "expression::StringLikePart::<'a>::as_string_literal" +kind = "inherent_method" +level = "expect" +reason = "string-like AST parts intentionally expose consistent typed accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_ast" +item = "name::Name::join" +kind = "inherent_method" +level = "expect" +reason = "the AST name wrapper intentionally mirrors common string operations" + + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_python_ast" +item = "token::tokens::TokenIterWithContext::<'a>::nesting" +kind = "inherent_method" +level = "expect" +reason = "token iterator context intentionally exposes all tracked parser state" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_ide" +item = "symbols::FlatSymbols::get" +kind = "inherent_method" +level = "expect" +reason = "flat and hierarchical symbol collections intentionally share lookup APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_ide" +item = "symbols::FlatSymbols::len" +kind = "inherent_method" +level = "expect" +reason = "flat and hierarchical symbol collections intentionally share collection APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_ide" +item = "symbols::HierarchicalSymbols::get" +kind = "inherent_method" +level = "expect" +reason = "flat and hierarchical symbol collections intentionally share lookup APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_ide" +item = "symbols::HierarchicalSymbols::len" +kind = "inherent_method" +level = "expect" +reason = "flat and hierarchical symbol collections intentionally share collection APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_module_resolver" +item = "module_glob::ModuleNameMatch::is_none" +kind = "inherent_method" +level = "expect" +reason = "module glob match predicates intentionally cover every result variant" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::Level::is_error" +kind = "inherent_method" +level = "expect" +reason = "lint level predicates intentionally cover every severity" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::Level::is_warn" +kind = "inherent_method" +level = "expect" +reason = "lint level predicates intentionally cover every severity" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::Level::is_ignore" +kind = "inherent_method" +level = "expect" +reason = "lint level predicates intentionally cover every severity" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::LintStatus::deprecated" +kind = "inherent_method" +level = "expect" +reason = "lint status constructors intentionally cover every lifecycle state" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::LintRegistryBuilder::register_alias" +kind = "inherent_method" +level = "expect" +reason = "the lint registry intentionally supports aliases before the first alias is added" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::LintRegistry::aliases" +kind = "inherent_method" +level = "expect" +reason = "lint registry queries intentionally cover aliases and removed rules" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::LintRegistry::removed" +kind = "inherent_method" +level = "expect" +reason = "lint registry queries intentionally cover aliases and removed rules" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::RuleSelection::enabled" +kind = "inherent_method" +level = "expect" +reason = "rule selections intentionally support iteration with and without severity" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_semantic" +item = "lint::RuleSelection::iter" +kind = "inherent_method" +level = "expect" +reason = "rule selections intentionally support iteration with and without severity" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "frozen::FrozenMap::::iter_mut" +kind = "inherent_method" +level = "expect" +reason = "the frozen map intentionally mirrors standard map iteration APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "frozen::FrozenMap::::values" +kind = "inherent_method" +level = "expect" +reason = "the frozen map intentionally mirrors standard map iteration APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "rank::RankBitBox::is_empty" +kind = "inherent_method" +level = "expect" +reason = "the rank bit collection intentionally provides matching len and is_empty APIs" + +[[override]] +lint = "hawk::dead_public" +crate = "ruff_graph" +item = "ModuleImports::is_empty" +kind = "inherent_method" +level = "expect" +reason = "module import collections intentionally provide matching len and is_empty APIs" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "line_width::LineLengthFromIntError::0" +kind = "field" +level = "expect" +reason = "the conversion error exposes the rejected line length to callers" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "rules::pep8_naming::settings::IgnoreNames::matches" +kind = "inherent_method" +level = "expect" +reason = "public settings support downstream configuration and validation" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "rules::pep8_naming::settings::IgnoreNames::from_patterns" +kind = "inherent_method" +level = "expect" +reason = "public settings support downstream configuration and validation" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "settings::LinterSettings::with_target_version" +kind = "inherent_method" +level = "expect" +reason = "public test helpers use this builder from downstream crates" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "settings::LinterSettings::with_preview_mode" +kind = "inherent_method" +level = "expect" +reason = "public test helpers use this builder from downstream crates" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "settings::LinterSettings::with_external_rules" +kind = "inherent_method" +level = "expect" +reason = "public test helpers use this builder from downstream crates" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_workspace" +item = "configuration::FormatConfiguration::from_options" +kind = "inherent_method" +level = "expect" +reason = "workspace configuration types retain public constructors for downstream integrations" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_workspace" +item = "configuration::AnalyzeConfiguration::from_options" +kind = "inherent_method" +level = "expect" +reason = "workspace configuration types retain public constructors for downstream integrations" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_project" +item = "db::testing::TestDb::take_salsa_events" +kind = "inherent_method" +level = "expect" +reason = "the testing feature exposes database event assertions to downstream tests" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "completion::Completion::builtin" +kind = "field" +level = "expect" +reason = "completion metadata remains visible to downstream test and protocol adapters" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "completion::Completion::is_type_check_only" +kind = "field" +level = "expect" +reason = "completion metadata remains visible to downstream test and protocol adapters" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "completion::Completion::is_context_specific" +kind = "field" +level = "expect" +reason = "completion metadata remains visible to downstream test and protocol adapters" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "symbols::HierarchicalSymbols::is_empty" +kind = "inherent_method" +level = "expect" +reason = "symbol collections intentionally provide matching len and is_empty APIs" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "symbols::SymbolKind::to_string" +kind = "inherent_method" +level = "expect" +reason = "symbol kinds expose their protocol-facing names to downstream adapters" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_static" +item = "env_vars::EnvVars::XDG_CONFIG_HOME" +kind = "inherent_associated_constant" +level = "expect" +reason = "consumed by the generated environment-variable reference" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_db" +item = "system::command::Command::get_current_dir" +kind = "inherent_method" +level = "expect" +reason = "command descriptions expose a complete argument builder and inspection API" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "expression::Expression::<'db>::program" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "predicate::PatternPredicate::<'db>::file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "predicate::PatternPredicate::<'db>::python_file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "predicate::PatternPredicate::<'db>::program" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "statement::StatementInner::<'db>::file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "statement::StatementInner::<'db>::python_file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "statement::StatementInner::<'db>::program" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "unpack::Unpack::<'db>::file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "unpack::Unpack::<'db>::python_file" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +[[override]] +lint = "hawk::dead_public" +crate = "ty_python_core" +item = "unpack::Unpack::<'db>::program" +kind = "inherent_method" +level = "expect" +reason = "semantic ingredients expose consistent file, scope, and program accessors" + +# Keep public APIs whose narrower visibility would break a uniform interface or +# activate compiler lints that Rust intentionally suppresses for public APIs. + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "format_element::PrintMode::is_flat" +kind = "inherent_method" +level = "expect" +reason = "print mode predicates intentionally expose a consistent public interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "format_element::PrintMode::is_expanded" +kind = "inherent_method" +level = "expect" +reason = "print mode predicates intentionally expose a consistent public interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "format_element::tag::GroupMode::is_flat" +kind = "inherent_method" +level = "expect" +reason = "group mode predicates intentionally expose a consistent public interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "formatter::Formatter::<'buf, Context>::intern_vec" +kind = "inherent_method" +level = "expect" +reason = "the public formatter API supports interning prebuilt format elements" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_formatter" +item = "IndentStyle::as_str" +kind = "inherent_method" +level = "expect" +reason = "public indentation settings expose their stable string representation" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "registry::::url" +kind = "inherent_method" +level = "expect" +reason = "public rule metadata includes its documentation URL" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_linter" +item = "settings::types::PythonVersion::as_tuple" +kind = "inherent_method" +level = "expect" +reason = "public Python version settings expose their numeric representation" + + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_codegen" +item = "stylist::Indentation::new" +kind = "inherent_method" +level = "expect" +reason = "the public indentation wrapper retains its explicit constructor" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_formatter" +item = "AsFormat" +kind = "reexport" +level = "expect" +reason = "generated formatter implementations expose this public extension trait" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_formatter" +item = "FormattedIter" +kind = "reexport" +level = "expect" +reason = "generated formatter implementations expose this public iterator adapter" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_formatter" +item = "FormattedIterExt" +kind = "reexport" +level = "expect" +reason = "generated formatter implementations expose this public extension trait" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_formatter" +item = "IntoFormat" +kind = "reexport" +level = "expect" +reason = "generated formatter implementations expose this public extension trait" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_formatter" +item = "options::QuoteStyle::as_str" +kind = "inherent_method" +level = "expect" +reason = "public quote-style settings expose their stable string representation" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_parser" +item = "semantic_errors::YieldOutsideFunctionKind::is_await" +kind = "inherent_method" +level = "expect" +reason = "semantic error variants intentionally expose their classification predicate" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_semantic" +item = "cfg::visualize::draw_cfg" +kind = "function" +level = "expect" +reason = "the control-flow graph visualizer remains available to downstream debugging tools" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_semantic" +item = "cfg::visualize::MermaidNode::with_content" +kind = "inherent_method" +level = "expect" +reason = "the public control-flow graph visualization API retains its node constructor" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_python_semantic" +item = "cfg::visualize::DirectedGraph" +kind = "trait" +level = "expect" +reason = "the control-flow graph visualizer remains available to downstream debugging tools" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_source_file" +item = "newlines::LineEnding::len" +kind = "inherent_method" +level = "expect" +reason = "line-ending values expose consistent byte and text length APIs" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ruff_source_file" +item = "newlines::LineEnding::text_len" +kind = "inherent_method" +level = "expect" +reason = "line-ending values expose consistent byte and text length APIs" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::name_in_file" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::qualified" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::kind" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::deprecated" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::module" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols::AllSymbolInfo::<'db>::file" +kind = "inherent_method" +level = "expect" +reason = "auto-import symbol results intentionally expose their complete metadata interface" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "AllSymbolInfo" +kind = "reexport" +level = "expect" +reason = "the public auto-import API re-exports its result type" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "ty_ide" +item = "all_symbols" +kind = "reexport" +level = "expect" +reason = "the public auto-import API re-exports its query function" + +# Keep the generated AST and its hand-written companion APIs consistent across +# node types, visitors, and string literal kinds. + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/generated.rs" +reason = "generated from crates/ruff_python_ast/ast.toml" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/nodes.rs" +reason = "AST node APIs intentionally provide a consistent interface across node types" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/comparable.rs" +reason = "the comparable AST intentionally mirrors the full AST hierarchy" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/statement_visitor.rs" +reason = "AST walk functions intentionally provide a consistent interface across node types" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/visitor.rs" +reason = "AST walk functions intentionally provide a consistent interface across node types" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/visitor/source_order.rs" +reason = "AST walk functions intentionally provide a consistent interface across node types" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/visitor/transformer.rs" +reason = "AST walk functions intentionally provide a consistent interface across node types" + +[[exclude]] +crate = "ruff_python_ast" +file = "crates/ruff_python_ast/src/str_prefix.rs" +reason = "string prefix APIs intentionally provide a consistent interface across literal kinds" + +[[exclude]] +crate = "ruff_python_literal" +file = "crates/ruff_python_literal/src/escape.rs" +reason = "string and bytes escape APIs intentionally provide a consistent interface" + +[[exclude]] +crate = "ruff_python_literal" +file = "crates/ruff_python_literal/src/cformat.rs" +reason = "C-style string and bytes format APIs intentionally provide a consistent interface" + +# Preserve complete wrapper and DSL interfaces whose methods intentionally +# mirror their underlying abstractions. + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/files/path.rs" +reason = "file path variant accessors intentionally cover every backing path type" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/system/path.rs" +reason = "system path wrappers intentionally mirror the supported UTF-8 path API" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/vendored/path.rs" +reason = "vendored path wrappers intentionally mirror the supported UTF-8 path API" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/system.rs" +reason = "system abstractions intentionally expose a consistent interface across backends" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/system/memory_fs.rs" +reason = "the in-memory system intentionally mirrors the public filesystem interface" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/system/walk_directory.rs" +reason = "directory walking intentionally exposes a complete backend-neutral interface" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/testing.rs" +reason = "public test helpers are consumed by downstream crates with the testing feature" + +[[exclude]] +crate = "ruff_db" +file = "crates/ruff_db/src/vendored.rs" +reason = "the vendored filesystem intentionally mirrors the public filesystem interface" + +[[exclude]] +crate = "ruff_index" +file = "crates/ruff_index/src/slice.rs" +reason = "indexed slices intentionally mirror the supported slice API" + +[[exclude]] +crate = "ruff_index" +file = "crates/ruff_index/src/vec.rs" +reason = "indexed vectors intentionally mirror the supported vector API" + +[[exclude]] +crate = "ruff_linter" +file = "crates/ruff_linter/src/source_kind.rs" +reason = "source kind accessors intentionally cover every source representation" + +[[exclude]] +crate = "ruff_linter" +file = "crates/ruff_linter/src/test.rs" +reason = "public test helpers are consumed by downstream crates with the testing feature" + +# Preserve interfaces that are consumed outside the native Rust workspace. + +[[exclude]] +crate = "ruff_wasm" +file = "crates/ruff_wasm/src/lib.rs" +reason = "public exports are consumed by JavaScript" + +[[exclude]] +crate = "ty_wasm" +file = "crates/ty_wasm/src/lib.rs" +reason = "public exports are consumed by JavaScript" + +# Keep generated and upstream-compatible APIs in sync with their sources. + +[[exclude]] +crate = "ruff_notebook" +file = "crates/ruff_notebook/src/schema.rs" +reason = "generated from the Jupyter Notebook schema" + +[[exclude]] +crate = "ruff_formatter" +file = "crates/ruff_formatter/src/prelude.rs" +reason = "the public formatter prelude intentionally provides a complete downstream import surface" + +[[exclude]] +crate = "annotate_snippets" +file = "crates/ruff_annotate_snippets/src/lib.rs" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +file = "crates/ruff_annotate_snippets/src/level.rs" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +file = "crates/ruff_annotate_snippets/src/renderer/mod.rs" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +module = "renderer::render" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +module = "renderer::source_map" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +module = "renderer::styled_buffer" +reason = "kept compatible with the upstream annotate-snippets crate" + +[[exclude]] +crate = "annotate_snippets" +file = "crates/ruff_annotate_snippets/src/snippet.rs" +reason = "kept compatible with the upstream annotate-snippets crate" diff --git a/mkdocs.template.yml b/mkdocs.template.yml index ab96e8e210..c3f041214f 100644 --- a/mkdocs.template.yml +++ b/mkdocs.template.yml @@ -52,6 +52,9 @@ markdown_extensions: anchor_linenums: true - pymdownx.inlinehilite: - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid - markdown.extensions.attr_list: - pymdownx.keys: - pymdownx.tasklist: diff --git a/playground/.npmrc b/playground/.npmrc new file mode 100644 index 0000000000..a22c5b4c3b --- /dev/null +++ b/playground/.npmrc @@ -0,0 +1,3 @@ +engine-strict = true +ignore-scripts = true +min-release-age = 7 diff --git a/playground/.oxlintrc.json b/playground/.oxlintrc.json new file mode 100644 index 0000000000..016a705d00 --- /dev/null +++ b/playground/.oxlintrc.json @@ -0,0 +1,32 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "react"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "options": { + "denyWarnings": true, + "reportUnusedDisableDirectives": "warn" + }, + "rules": { + "eqeqeq": ["warn", "always", { "null": "never" }], + "no-array-constructor": "warn", + "no-console": "warn", + "no-shadow": "off", + "no-unused-vars": ["error", { "reportVarsOnlyUsedAsTypes": true }], + "no-var": "warn", + "prefer-const": "warn", + "prefer-rest-params": "warn", + "prefer-spread": "warn", + "react/react-in-jsx-scope": "off", + "react/rules-of-hooks": "warn", + "react/unsupported-syntax": "warn", + "typescript/ban-ts-comment": "warn", + "typescript/no-empty-object-type": "warn", + "typescript/no-namespace": "warn", + "typescript/no-require-imports": "warn", + "typescript/no-unsafe-function-type": "warn", + "unicorn/consistent-function-scoping": "off" + } +} diff --git a/playground/README.md b/playground/README.md index 4a5ce1c309..eaffd2b189 100644 --- a/playground/README.md +++ b/playground/README.md @@ -4,6 +4,8 @@ In-browser playground for Ruff. Available [https://play.ruff.rs/](https://play.r ## Getting started +Use npm 11.10.0 or newer so that the dependency cooldown in `.npmrc` is enforced. + Install the NPM dependencies with `npm ci --ignore-scripts`, and run the development server with `npm start --workspace ruff-playground` or `npm start --workspace ty-playground`. You may need to restart the server after making changes to Ruff or ty to re-build the WASM @@ -11,10 +13,9 @@ module. To run the datastore, which is based on [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/), -install the [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/), -then run `npx wrangler dev --local` from the `./playground/api` directory. Note that the datastore -is -only required to generate shareable URLs for code snippets. The development datastore does not +run `npm ci --ignore-scripts` and `npm start -- --local` from the `./playground/api` directory +to use the locked [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/). +The datastore is only required to generate shareable URLs for code snippets. The development datastore does not require Cloudflare authentication or login, but in turn only persists data locally. ## Architecture diff --git a/playground/api/.npmrc b/playground/api/.npmrc new file mode 100644 index 0000000000..a22c5b4c3b --- /dev/null +++ b/playground/api/.npmrc @@ -0,0 +1,3 @@ +engine-strict = true +ignore-scripts = true +min-release-age = 7 diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 14ceceb5d6..2a1812b5ad 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -17,6 +17,9 @@ "miniflare": "^4.20260706.0", "typescript": "^7.0.0", "wrangler": "^4.107.1" + }, + "engines": { + "npm": ">=11.10.0" } }, "node_modules/@cloudflare/kv-asset-handler": { diff --git a/playground/api/package.json b/playground/api/package.json index fe3d313c7a..808ba03d5d 100644 --- a/playground/api/package.json +++ b/playground/api/package.json @@ -1,6 +1,9 @@ { "name": "api", "version": "0.0.0", + "engines": { + "npm": ">=11.10.0" + }, "devDependencies": { "@cloudflare/workers-types": "^5.0.0", "miniflare": "^4.20260706.0", diff --git a/playground/deploy/.npmrc b/playground/deploy/.npmrc new file mode 100644 index 0000000000..a22c5b4c3b --- /dev/null +++ b/playground/deploy/.npmrc @@ -0,0 +1,3 @@ +engine-strict = true +ignore-scripts = true +min-release-age = 7 diff --git a/playground/deploy/package-lock.json b/playground/deploy/package-lock.json new file mode 100644 index 0000000000..e1f805f2fd --- /dev/null +++ b/playground/deploy/package-lock.json @@ -0,0 +1,1580 @@ +{ + "name": "playground-deploy", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playground-deploy", + "version": "0.0.0", + "devDependencies": { + "wrangler": "4.118.0" + }, + "engines": { + "npm": ">=11.10.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.4.tgz", + "integrity": "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", + "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.7.tgz", + "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", + "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260730.1", + "@cloudflare/workerd-darwin-arm64": "1.20260730.1", + "@cloudflare/workerd-linux-64": "1.20260730.1", + "@cloudflare/workerd-linux-arm64": "1.20260730.1", + "@cloudflare/workerd-windows-64": "1.20260730.1" + } + }, + "node_modules/wrangler": { + "version": "4.118.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.118.0.tgz", + "integrity": "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260730.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260730.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260730.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "5.20260730.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260730.0-alpha.tgz", + "integrity": "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260730.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/playground/deploy/package.json b/playground/deploy/package.json new file mode 100644 index 0000000000..092d038680 --- /dev/null +++ b/playground/deploy/package.json @@ -0,0 +1,11 @@ +{ + "name": "playground-deploy", + "private": true, + "version": "0.0.0", + "engines": { + "npm": ">=11.10.0" + }, + "devDependencies": { + "wrangler": "4.118.0" + } +} diff --git a/playground/eslint.config.mjs b/playground/eslint.config.mjs deleted file mode 100644 index 1c2ff89af4..0000000000 --- a/playground/eslint.config.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import react from "eslint-plugin-react"; -import tseslint from "typescript-eslint"; -import reactHooks from "eslint-plugin-react-hooks"; -import importPlugin from "eslint-plugin-import"; - -export default tseslint.config( - tseslint.configs.eslintRecommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - importPlugin.flatConfigs.recommended, - importPlugin.flatConfigs.typescript, - { - ...react.configs.flat.recommended, - ...react.configs.flat["jsx-runtime"], - files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], - languageOptions: { - ...react.configs.flat.recommended.languageOptions, - ecmaVersion: "latest", - sourceType: "module", - }, - rules: { - eqeqeq: [ - "error", - "always", - { - null: "never", - }, - ], - "@typescript-eslint/no-explicit-any": "off", - // Handled by typescript. It doesn't support shared? - "import/no-unresolved": "off", - "no-console": "error", - }, - }, - { - ignores: ["src/pkg/**"], - }, -); diff --git a/playground/package-lock.json b/playground/package-lock.json index ea9c77d9a5..317656220b 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -13,419 +13,20 @@ "shared" ], "devDependencies": { - "@eslint/js": "^9.21.0", "@tailwindcss/vite": "^4.2.2", "@types/react": "^19.0.11", "@types/react-dom": "^19.0.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^9.22.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-react": "^7.31.11", - "eslint-plugin-react-hooks": "^7.0.0", + "oxlint": "^1.79.0", "prettier": "^3.5.3", "tailwindcss": "^4.0.14", "typescript": "npm:@typescript/typescript6@^6.0.2", - "typescript-eslint": "^8.26.1", "vite": "^8.0.0", "wasm-pack": "^0.15.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "npm": ">=11.10.0" } }, "node_modules/@formatjs/ecma402-abstract": { @@ -479,72 +80,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@internationalized/date": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", @@ -688,226 +223,371 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@react-aria/autocomplete": { - "version": "3.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz", - "integrity": "sha512-uymUNJ8NW+dX7lmgkHE+SklAbxwktycAJcI5lBBw6KPZyc0EdMHC+/Fc5CUz3enIAhNwd2oxxogcSHknquMzQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/combobox": "^3.15.0", - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/listbox": "^3.15.3", - "@react-aria/searchfield": "^3.8.12", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/autocomplete": "3.0.0-beta.4", - "@react-stately/combobox": "^3.13.0", - "@react-types/autocomplete": "3.0.0-alpha.38", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/breadcrumbs": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.32.tgz", - "integrity": "sha512-S61vh5DJ2PXiXUwD7gk+pvS/b4VPrc3ZJOUZ0yVRLHkVESr5LhIZH+SAVgZkm1lzKyMRG+BH+fiRH/DZRSs7SA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/link": "^3.8.9", - "@react-aria/utils": "^3.33.1", - "@react-types/breadcrumbs": "^3.7.19", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz", + "integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/button": { - "version": "3.14.5", - "resolved": "https://registry.npmjs.org/@react-aria/button/-/button-3.14.5.tgz", - "integrity": "sha512-ZuLx+wQj9VQhH9BYe7t0JowmKnns2XrFHFNvIVBb5RwxL+CIycIOL7brhWKg2rGdxvlOom7jhVbcjSmtAaSyaQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/toolbar": "3.0.0-beta.24", - "@react-aria/utils": "^3.33.1", - "@react-stately/toggle": "^3.9.5", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz", + "integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/calendar": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-aria/calendar/-/calendar-3.9.5.tgz", - "integrity": "sha512-k0kvceYdZZu+DoeqephtlmIvh1CxqdFyoN52iqVzTz9O0pe5Xfhq7zxPGbeCp4pC61xzp8Lu/6uFA/YNfQQNag==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.33.1", - "@react-stately/calendar": "^3.9.3", - "@react-types/button": "^3.15.1", - "@react-types/calendar": "^3.8.3", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz", + "integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/checkbox": { - "version": "3.16.5", - "resolved": "https://registry.npmjs.org/@react-aria/checkbox/-/checkbox-3.16.5.tgz", - "integrity": "sha512-ZhUT7ELuD52hb+Zpzw0ElLQiVOd5sKYahrh+PK3vq13Wk5TedBscALpjuXetI4pwFfdmAM1Lhgcsrd8+6AmyvA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/form": "^3.1.5", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/toggle": "^3.12.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/checkbox": "^3.7.5", - "@react-stately/form": "^3.2.4", - "@react-stately/toggle": "^3.9.5", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz", + "integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/collections": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@react-aria/collections/-/collections-3.0.3.tgz", - "integrity": "sha512-lbC5DEbHeVFvVr4ke9y8D9Nynnr8G8UjVEBoFGRylpAaScU7SX1TN84QI+EjMbsdZ0/5P2H7gUTS+MYd+6U3Rg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz", + "integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/color": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@react-aria/color/-/color-3.1.5.tgz", - "integrity": "sha512-eysWdBRzE8WDhBzh1nfjyUgzseMokXGHjIoJo880T7IPJ8tTavfQni49pU1B2qWrNOWPyrwx4Bd9pzHyboxJSA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/numberfield": "^3.12.5", - "@react-aria/slider": "^3.8.5", - "@react-aria/spinbutton": "^3.7.2", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/color": "^3.9.5", - "@react-stately/form": "^3.2.4", - "@react-types/color": "^3.1.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz", + "integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/combobox": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@react-aria/combobox/-/combobox-3.15.0.tgz", - "integrity": "sha512-qSjQTFwKl3x1jCP2NRSJ6doZqAp6c2GTfoiFwWjaWg1IewwLsglaW6NnzqRDFiqFbDGgXPn4MqtC1VYEJ3NEjA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/listbox": "^3.15.3", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/menu": "^3.21.0", - "@react-aria/overlays": "^3.31.2", - "@react-aria/selection": "^3.27.2", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/combobox": "^3.13.0", - "@react-stately/form": "^3.2.4", - "@react-types/button": "^3.15.1", - "@react-types/combobox": "^3.14.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz", + "integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/datepicker": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-aria/datepicker/-/datepicker-3.16.1.tgz", - "integrity": "sha512-6BltCVWt09yefTkGjb2gViGCwoddx9HKJiZbY9u6Es/Q+VhwNJQRtczbnZ3K32p262hIknukNf/5nZaCOI1AKA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-aria/focus": "^3.21.5", - "@react-aria/form": "^3.1.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/spinbutton": "^3.7.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/datepicker": "^3.16.1", - "@react-stately/form": "^3.2.4", - "@react-types/button": "^3.15.1", - "@react-types/calendar": "^3.8.3", - "@react-types/datepicker": "^3.13.5", - "@react-types/dialog": "^3.5.24", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz", + "integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@react-aria/dialog": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@react-aria/dialog/-/dialog-3.5.34.tgz", - "integrity": "sha512-/x53Q5ynpW5Kv9637WYu7SrDfj3woSp6jJRj8l6teGnWW/iNZWYJETgzHfbxx+HPKYATCZesRoIeO2LnYIXyEA==", + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz", + "integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz", + "integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz", + "integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz", + "integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz", + "integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz", + "integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz", + "integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz", + "integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz", + "integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz", + "integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz", + "integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@react-aria/autocomplete": { + "version": "3.0.0-rc.6", + "resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz", + "integrity": "sha512-uymUNJ8NW+dX7lmgkHE+SklAbxwktycAJcI5lBBw6KPZyc0EdMHC+/Fc5CUz3enIAhNwd2oxxogcSHknquMzQA==", "license": "Apache-2.0", "dependencies": { + "@react-aria/combobox": "^3.15.0", + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/overlays": "^3.31.2", + "@react-aria/listbox": "^3.15.3", + "@react-aria/searchfield": "^3.8.12", + "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", - "@react-types/dialog": "^3.5.24", + "@react-stately/autocomplete": "3.0.0-beta.4", + "@react-stately/combobox": "^3.13.0", + "@react-types/autocomplete": "3.0.0-alpha.38", + "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -916,16 +596,17 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/disclosure": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@react-aria/disclosure/-/disclosure-3.1.3.tgz", - "integrity": "sha512-S3k7Wqrj+x0sWcP88Z1stSr5TIZmKEmx2rU7RB1O1/jPpbw5mgKnjtiriOlTh+kwdK11FkeqgxyHzAcBAR+FMQ==", + "node_modules/@react-aria/breadcrumbs": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.32.tgz", + "integrity": "sha512-S61vh5DJ2PXiXUwD7gk+pvS/b4VPrc3ZJOUZ0yVRLHkVESr5LhIZH+SAVgZkm1lzKyMRG+BH+fiRH/DZRSs7SA==", "license": "Apache-2.0", "dependencies": { - "@react-aria/ssr": "^3.9.10", + "@react-aria/i18n": "^3.12.16", + "@react-aria/link": "^3.8.9", "@react-aria/utils": "^3.33.1", - "@react-stately/disclosure": "^3.0.11", - "@react-types/button": "^3.15.1", + "@react-types/breadcrumbs": "^3.7.19", + "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -933,20 +614,16 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/dnd": { - "version": "3.11.6", - "resolved": "https://registry.npmjs.org/@react-aria/dnd/-/dnd-3.11.6.tgz", - "integrity": "sha512-4YLHUeYJleF+moAYaYt8UZqujudPvpoaHR+QMkWIFzhfridVUhCr6ZjGWrzpSZY3r68k46TG7YCsi4IEiNnysw==", + "node_modules/@react-aria/button": { + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@react-aria/button/-/button-3.14.5.tgz", + "integrity": "sha512-ZuLx+wQj9VQhH9BYe7t0JowmKnns2XrFHFNvIVBb5RwxL+CIycIOL7brhWKg2rGdxvlOom7jhVbcjSmtAaSyaQ==", "license": "Apache-2.0", "dependencies": { - "@internationalized/string": "^3.2.7", - "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/overlays": "^3.31.2", + "@react-aria/toolbar": "3.0.0-beta.24", "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/dnd": "^3.7.4", + "@react-stately/toggle": "^3.9.5", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" @@ -956,32 +633,43 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/focus": { - "version": "3.21.5", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz", - "integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==", + "node_modules/@react-aria/calendar": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/@react-aria/calendar/-/calendar-3.9.5.tgz", + "integrity": "sha512-k0kvceYdZZu+DoeqephtlmIvh1CxqdFyoN52iqVzTz9O0pe5Xfhq7zxPGbeCp4pC61xzp8Lu/6uFA/YNfQQNag==", "license": "Apache-2.0", "dependencies": { + "@internationalized/date": "^3.12.0", + "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", + "@react-aria/live-announcer": "^3.4.4", "@react-aria/utils": "^3.33.1", + "@react-stately/calendar": "^3.9.3", + "@react-types/button": "^3.15.1", + "@react-types/calendar": "^3.8.3", "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" + "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/form": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@react-aria/form/-/form-3.1.5.tgz", - "integrity": "sha512-BWlONgHn8hmaMkcS6AgMSLQeNqVBwqPNLhdqjDO/PCfzvV7O8NZw/dFeIzJwfG4aBfSpbHHRdXGdfrk3d8dylQ==", + "node_modules/@react-aria/checkbox": { + "version": "3.16.5", + "resolved": "https://registry.npmjs.org/@react-aria/checkbox/-/checkbox-3.16.5.tgz", + "integrity": "sha512-ZhUT7ELuD52hb+Zpzw0ElLQiVOd5sKYahrh+PK3vq13Wk5TedBscALpjuXetI4pwFfdmAM1Lhgcsrd8+6AmyvA==", "license": "Apache-2.0", "dependencies": { + "@react-aria/form": "^3.1.5", "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/toggle": "^3.12.5", "@react-aria/utils": "^3.33.1", + "@react-stately/checkbox": "^3.7.5", "@react-stately/form": "^3.2.4", + "@react-stately/toggle": "^3.9.5", + "@react-types/checkbox": "^3.10.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -990,111 +678,14 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/grid": { - "version": "3.14.8", - "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.14.8.tgz", - "integrity": "sha512-X6rRFKDu/Kh6Sv8FBap3vjcb+z4jXkSOwkYnexIJp5kMTo5/Dqo55cCBio5B70Tanfv32Ev/6SpzYG7ryxnM9w==", + "node_modules/@react-aria/collections": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@react-aria/collections/-/collections-3.0.3.tgz", + "integrity": "sha512-lbC5DEbHeVFvVr4ke9y8D9Nynnr8G8UjVEBoFGRylpAaScU7SX1TN84QI+EjMbsdZ0/5P2H7gUTS+MYd+6U3Rg==", "license": "Apache-2.0", "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/grid": "^3.11.9", - "@react-stately/selection": "^3.20.9", - "@react-types/checkbox": "^3.10.4", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/gridlist": { - "version": "3.14.4", - "resolved": "https://registry.npmjs.org/@react-aria/gridlist/-/gridlist-3.14.4.tgz", - "integrity": "sha512-C/SbwC0qagZatoBrCjx8iZUex9apaJ8o8iRJ9eVHz0cpj7mXg6HuuotYGmDy9q67A2hve4I693RM1Cuwqwm+PQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/grid": "^3.14.8", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/list": "^3.13.4", - "@react-stately/tree": "^3.9.6", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/i18n": { - "version": "3.12.16", - "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.16.tgz", - "integrity": "sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/message": "^3.1.8", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/interactions": { - "version": "3.27.1", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz", - "integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==", - "license": "Apache-2.0", - "dependencies": { "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/label": { - "version": "3.7.25", - "resolved": "https://registry.npmjs.org/@react-aria/label/-/label-3.7.25.tgz", - "integrity": "sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/landmark": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.10.tgz", - "integrity": "sha512-GpNjJaI8/a6WxYDZgzTCLYSzPM6xp2pxCIQ4udiGbTCtxx13Trmm0cPABvPtzELidgolCf05em9Phr+3G0eE8A==", - "license": "Apache-2.0", - "dependencies": { "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", @@ -1105,36 +696,23 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/link": { - "version": "3.8.9", - "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.8.9.tgz", - "integrity": "sha512-UaAFBfs84/Qq6TxlMWkREqqNY6SFLukot+z2Aa1kC+VyStv1kWG6sE5QLjm4SBn1Q3CGRsefhB/5+taaIbB4Pw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-types/link": "^3.6.7", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/listbox": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.15.3.tgz", - "integrity": "sha512-C6YgiyrHS5sbS5UBdxGMhEs+EKJYotJgGVtl9l0ySXpBUXERiHJWLOyV7a8PwkUOmepbB4FaLD7Y9EUzGkrGlw==", + "node_modules/@react-aria/color": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@react-aria/color/-/color-3.1.5.tgz", + "integrity": "sha512-eysWdBRzE8WDhBzh1nfjyUgzseMokXGHjIoJo880T7IPJ8tTavfQni49pU1B2qWrNOWPyrwx4Bd9pzHyboxJSA==", "license": "Apache-2.0", "dependencies": { + "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/selection": "^3.27.2", + "@react-aria/numberfield": "^3.12.5", + "@react-aria/slider": "^3.8.5", + "@react-aria/spinbutton": "^3.7.2", + "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/list": "^3.13.4", - "@react-types/listbox": "^3.7.6", + "@react-aria/visually-hidden": "^3.8.31", + "@react-stately/color": "^3.9.5", + "@react-stately/form": "^3.2.4", + "@react-types/color": "^3.1.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -1143,49 +721,27 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/live-announcer": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/@react-aria/live-announcer/-/live-announcer-3.4.4.tgz", - "integrity": "sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@react-aria/menu": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@react-aria/menu/-/menu-3.21.0.tgz", - "integrity": "sha512-CKTVZ4izSE1eKIti6TbTtzJAUo+WT8O4JC0XZCYDBpa0f++lD19Kz9aY+iY1buv5xGI20gAfpO474E9oEd4aQA==", + "node_modules/@react-aria/combobox": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@react-aria/combobox/-/combobox-3.15.0.tgz", + "integrity": "sha512-qSjQTFwKl3x1jCP2NRSJ6doZqAp6c2GTfoiFwWjaWg1IewwLsglaW6NnzqRDFiqFbDGgXPn4MqtC1VYEJ3NEjA==", "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", + "@react-aria/listbox": "^3.15.3", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/menu": "^3.21.0", "@react-aria/overlays": "^3.31.2", "@react-aria/selection": "^3.27.2", + "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", - "@react-stately/menu": "^3.9.11", - "@react-stately/selection": "^3.20.9", - "@react-stately/tree": "^3.9.6", + "@react-stately/combobox": "^3.13.0", + "@react-stately/form": "^3.2.4", "@react-types/button": "^3.15.1", - "@react-types/menu": "^3.10.7", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/meter": { - "version": "3.4.30", - "resolved": "https://registry.npmjs.org/@react-aria/meter/-/meter-3.4.30.tgz", - "integrity": "sha512-ZmANKW7s/Z4QGylHi46nhwtQ47T1bfMsU9MysBu7ViXXNJ03F4b6JXCJlKL5o2goQ3NbfZ68GeWamIT0BWSgtw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/progress": "^3.4.30", - "@react-types/meter": "^3.4.15", + "@react-types/combobox": "^3.14.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -1194,22 +750,28 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/numberfield": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@react-aria/numberfield/-/numberfield-3.12.5.tgz", - "integrity": "sha512-Fi41IUWXEHLFIeJ/LHuZ9Azs8J/P563fZi37GSBkIq5P1pNt1rPgJJng5CNn4KsHxwqadTRUlbbZwbZraWDtRg==", + "node_modules/@react-aria/datepicker": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/@react-aria/datepicker/-/datepicker-3.16.1.tgz", + "integrity": "sha512-6BltCVWt09yefTkGjb2gViGCwoddx9HKJiZbY9u6Es/Q+VhwNJQRtczbnZ3K32p262hIknukNf/5nZaCOI1AKA==", "license": "Apache-2.0", "dependencies": { + "@internationalized/date": "^3.12.0", + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-aria/focus": "^3.21.5", + "@react-aria/form": "^3.1.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", + "@react-aria/label": "^3.7.25", "@react-aria/spinbutton": "^3.7.2", - "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", + "@react-stately/datepicker": "^3.16.1", "@react-stately/form": "^3.2.4", - "@react-stately/numberfield": "^3.11.0", "@react-types/button": "^3.15.1", - "@react-types/numberfield": "^3.8.18", + "@react-types/calendar": "^3.8.3", + "@react-types/datepicker": "^3.13.5", + "@react-types/dialog": "^3.5.24", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -1218,22 +780,16 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/overlays": { - "version": "3.31.2", - "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.31.2.tgz", - "integrity": "sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q==", + "node_modules/@react-aria/dialog": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@react-aria/dialog/-/dialog-3.5.34.tgz", + "integrity": "sha512-/x53Q5ynpW5Kv9637WYu7SrDfj3woSp6jJRj8l6teGnWW/iNZWYJETgzHfbxx+HPKYATCZesRoIeO2LnYIXyEA==", "license": "Apache-2.0", "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/ssr": "^3.9.10", + "@react-aria/overlays": "^3.31.2", "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/flags": "^3.1.2", - "@react-stately/overlays": "^3.6.23", - "@react-types/button": "^3.15.1", - "@react-types/overlays": "^3.9.4", + "@react-types/dialog": "^3.5.24", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, @@ -1242,17 +798,16 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/progress": { - "version": "3.4.30", - "resolved": "https://registry.npmjs.org/@react-aria/progress/-/progress-3.4.30.tgz", - "integrity": "sha512-S6OWVGgluSWYSd/A6O8CVjz83eeMUfkuWSra0ewAV9bmxZ7TP9pUmD3bGdqHZEl97nt5vHGjZ3eq/x8eCmzKhA==", + "node_modules/@react-aria/disclosure": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-aria/disclosure/-/disclosure-3.1.3.tgz", + "integrity": "sha512-S3k7Wqrj+x0sWcP88Z1stSr5TIZmKEmx2rU7RB1O1/jPpbw5mgKnjtiriOlTh+kwdK11FkeqgxyHzAcBAR+FMQ==", "license": "Apache-2.0", "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/label": "^3.7.25", + "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", - "@react-types/progress": "^3.5.18", - "@react-types/shared": "^3.33.1", + "@react-stately/disclosure": "^3.0.11", + "@react-types/button": "^3.15.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -1260,4918 +815,2883 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/radio": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@react-aria/radio/-/radio-3.12.5.tgz", - "integrity": "sha512-8CCJKJzfozEiWBPO9QAATG1rBGJEJ+xoqvHf9LKU2sPFGsA2/SRnLs6LB9fCG5R3spvaK1xz0any1fjWPl7x8A==", + "node_modules/@react-aria/dnd": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@react-aria/dnd/-/dnd-3.11.6.tgz", + "integrity": "sha512-4YLHUeYJleF+moAYaYt8UZqujudPvpoaHR+QMkWIFzhfridVUhCr6ZjGWrzpSZY3r68k46TG7YCsi4IEiNnysw==", "license": "Apache-2.0", "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/form": "^3.1.5", + "@internationalized/string": "^3.2.7", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/overlays": "^3.31.2", "@react-aria/utils": "^3.33.1", - "@react-stately/radio": "^3.11.5", - "@react-types/radio": "^3.9.4", + "@react-stately/collections": "^3.12.10", + "@react-stately/dnd": "^3.7.4", + "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/searchfield": { - "version": "3.8.12", - "resolved": "https://registry.npmjs.org/@react-aria/searchfield/-/searchfield-3.8.12.tgz", - "integrity": "sha512-kYlUHD/+mWzNroHoR8ojUxYBoMviRZn134WaKPFjfNUGZDOEuh4XzOoj+cjdJfe6N3mwTaYu6rJQtunSHIAfhA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/searchfield": "^3.5.19", - "@react-types/button": "^3.15.1", - "@react-types/searchfield": "^3.6.8", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/select": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/@react-aria/select/-/select-3.17.3.tgz", - "integrity": "sha512-u0UFWw0S7q9oiSbjetDpRoLLIcC+L89uYlm+YfCrdT8ntbQgABNiJRxdVvxnhR0fR6MC9ASTTvuQnNHNn52+1A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/form": "^3.1.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/listbox": "^3.15.3", - "@react-aria/menu": "^3.21.0", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/select": "^3.9.2", - "@react-types/button": "^3.15.1", - "@react-types/select": "^3.12.2", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/selection": { - "version": "3.27.2", - "resolved": "https://registry.npmjs.org/@react-aria/selection/-/selection-3.27.2.tgz", - "integrity": "sha512-GbUSSLX/ciXix95KW1g+SLM9np7iXpIZrFDSXkC6oNx1uhy18eAcuTkeZE25+SY5USVUmEzjI3m/3JoSUcebbg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/selection": "^3.20.9", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/separator": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/@react-aria/separator/-/separator-3.4.16.tgz", - "integrity": "sha512-RCUtQhDGnPxKzyG8KM79yOB0fSiEf8r/rxShidOVnGLiBW2KFmBa22/Gfc4jnqg/keN3dxvkSGoqmeXgctyp6g==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/slider": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/@react-aria/slider/-/slider-3.8.5.tgz", - "integrity": "sha512-gqkJxznk141mE0JamXF5CXml9PDbPkBz8dyKlihtWHWX4yhEbVYdC9J0otE7iCR3zx69Bm7WHoTGL0BsdpKzVA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-stately/slider": "^3.7.5", - "@react-types/shared": "^3.33.1", - "@react-types/slider": "^3.8.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/spinbutton": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.7.2.tgz", - "integrity": "sha512-adjE1wNCWlugvAtVXlXWPtIG9JWurEgYVn1Eeyh19x038+oXGvOsOAoKCXM+SnGleTWQ9J7pEZITFoEI3cVfAw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.33.1", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/switch": { - "version": "3.7.11", - "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.7.11.tgz", - "integrity": "sha512-dYVX71HiepBsKyeMaQgHbhqI+MQ3MVoTd5EnTbUjefIBnmQZavYj1/e4NUiUI4Ix+/C0HxL8ibDAv4NlSW3eLQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/toggle": "^3.12.5", - "@react-stately/toggle": "^3.9.5", - "@react-types/shared": "^3.33.1", - "@react-types/switch": "^3.5.17", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/table": { - "version": "3.17.11", - "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.17.11.tgz", - "integrity": "sha512-GkYmWPiW3OM+FUZxdS33teHXHXde7TjHuYgDDaG9phvg6cQTQjGilJozrzA3OfftTOq5VB8XcKTIQW3c0tpYsQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/grid": "^3.14.8", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/collections": "^3.12.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/table": "^3.15.4", - "@react-types/checkbox": "^3.10.4", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@react-types/table": "^3.13.6", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tabs": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@react-aria/tabs/-/tabs-3.11.1.tgz", - "integrity": "sha512-3Ppz7yaEDW9L7p9PE9yNOl5caLwNnnLQqI+MX/dwbWlw9HluHS7uIjb21oswNl6UbSxAWyENOka45+KN4Fkh7A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/tabs": "^3.8.9", - "@react-types/shared": "^3.33.1", - "@react-types/tabs": "^3.3.22", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tag": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@react-aria/tag/-/tag-3.8.1.tgz", - "integrity": "sha512-VonpO++F8afXGDWc9VUxAc2wefyJpp1n9OGpbnB7zmqWiuPwO/RixjUdcH7iJkiC4vADwx9uLnhyD6kcwGV2ig==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/gridlist": "^3.14.4", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/list": "^3.13.4", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/textfield": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/@react-aria/textfield/-/textfield-3.18.5.tgz", - "integrity": "sha512-ttwVSuwoV3RPaG2k2QzEXKeQNQ3mbdl/2yy6I4Tjrn1ZNkYHfVyJJ26AjenfSmj1kkTQoSAfZ8p+7rZp4n0xoQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/form": "^3.1.5", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@react-types/textfield": "^3.12.8", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toast": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.11.tgz", - "integrity": "sha512-2DjZjBAvm8/CWbnZ6s7LjkYCkULKtjMve6GvhPTq98AthuEDLEiBvM1wa3xdecCRhZyRT1g6DXqVca0EfZ9fJA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/landmark": "^3.0.10", - "@react-aria/utils": "^3.33.1", - "@react-stately/toast": "^3.1.3", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toggle": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.12.5.tgz", - "integrity": "sha512-XXVFLzcV8fr9mz7y/wfxEAhWvaBZ9jSfhCMuxH2bsivO7nTcMJ1jb4g2xJNwZgne17bMWNc7mKvW5dbsdlI6BA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/toggle": "^3.9.5", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toolbar": { - "version": "3.0.0-beta.24", - "resolved": "https://registry.npmjs.org/@react-aria/toolbar/-/toolbar-3.0.0-beta.24.tgz", - "integrity": "sha512-B2Rmpko7Ghi2RbNfsGdbR7I+RQBDhPGVE4bU3/EwHz+P/vNe5LyGPTeSwqaOMsQTF9lKNCkY8424dVTCr6RUMg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tooltip": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@react-aria/tooltip/-/tooltip-3.9.2.tgz", - "integrity": "sha512-VrgkPwHiEnAnBhoQ4W7kfry/RfVuRWrUPaJSp0+wKM6u0gg2tmn7OFRDXTxBAm/omQUguIdIjRWg7sf3zHH82A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/tooltip": "^3.5.11", - "@react-types/shared": "^3.33.1", - "@react-types/tooltip": "^3.5.2", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tree": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@react-aria/tree/-/tree-3.1.7.tgz", - "integrity": "sha512-C54yH5NmsOFa2Q+cg6B1BPr5KUlU9vLIoBnVrgrH237FRSXQPIbcM4VpmITAHq1VR7w6ayyS1hgTwFxo67ykWQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/gridlist": "^3.14.4", - "@react-aria/i18n": "^3.12.16", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/tree": "^3.9.6", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz", - "integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/virtualizer": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@react-aria/virtualizer/-/virtualizer-4.1.13.tgz", - "integrity": "sha512-d5KS+p8GXGNRbGPRE/N6jtth3et3KssQIz52h2+CAoAh7C3vvR64kkTaGdeywClvM+fSo8FxJuBrdfQvqC2ktQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/virtualizer": "^4.4.6", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/visually-hidden": { - "version": "3.8.31", - "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.31.tgz", - "integrity": "sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/autocomplete": { - "version": "3.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.4.tgz", - "integrity": "sha512-K2Uy7XEdseFvgwRQ8CyrYEHMupjVKEszddOapP8deNz4hntYvT1aRm0m+sKa5Kl/4kvg9c/3NZpQcrky/vRZIg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/calendar": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.9.3.tgz", - "integrity": "sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-stately/utils": "^3.11.0", - "@react-types/calendar": "^3.8.3", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/checkbox": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@react-stately/checkbox/-/checkbox-3.7.5.tgz", - "integrity": "sha512-K5R5ted7AxLB3sDkuVAazUdyRMraFT1imVqij2GuAiOUFvsZvbuocnDuFkBVKojyV3GpqLBvViV8IaCMc4hNIw==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/collections": { - "version": "3.12.10", - "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.10.tgz", - "integrity": "sha512-wmF9VxJDyBujBuQ76vXj2g/+bnnj8fx5DdXgRmyfkkYhPB46+g2qnjbVGEvipo7bJuGxDftCUC4SN7l7xqUWfg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/color": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.9.5.tgz", - "integrity": "sha512-8pZxzXWDRuglzDwyTG7mLw2LQMCHIVNbVc9YmbsxbOjAL+lOqszo60KzyaFKVxeDQczSvrNTHcQZqlbNIC0eyQ==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-stately/form": "^3.2.4", - "@react-stately/numberfield": "^3.11.0", - "@react-stately/slider": "^3.7.5", - "@react-stately/utils": "^3.11.0", - "@react-types/color": "^3.1.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/combobox": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@react-stately/combobox/-/combobox-3.13.0.tgz", - "integrity": "sha512-dX9g/cK1hjLRjcbWVF6keHxTQDGhKGB2QAgPhWcBmOK3qJv+2dQqsJ6YCGWn/Y2N2acoEseLrAA7+Qe4HWV9cg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/form": "^3.2.4", - "@react-stately/list": "^3.13.4", - "@react-stately/overlays": "^3.6.23", - "@react-stately/utils": "^3.11.0", - "@react-types/combobox": "^3.14.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/data": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/@react-stately/data/-/data-3.15.2.tgz", - "integrity": "sha512-BsmeeGgFwOGwo0g9Waprdyt+846n3KhKggZfpEnp5+sC4dE4uW1VIYpdyupMfr3bQcmX123q6TegfNP3eszrUA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/datepicker": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-stately/datepicker/-/datepicker-3.16.1.tgz", - "integrity": "sha512-BtAMDvxd1OZxkxjqq5tN5TYmp6Hm8+o3+IDA4qmem2/pfQfVbOZeWS2WitcPBImj4n4T+W1A5+PI7mT/6DUBVg==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-stately/form": "^3.2.4", - "@react-stately/overlays": "^3.6.23", - "@react-stately/utils": "^3.11.0", - "@react-types/datepicker": "^3.13.5", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/disclosure": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@react-stately/disclosure/-/disclosure-3.0.11.tgz", - "integrity": "sha512-/KjB/0HkxGWbhFAPztCP411LUKZCx9k8cKukrlGqrUWyvrcXlmza90j0g/CuxACBoV+DJP9V+4q+8ide0x750A==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/dnd": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@react-stately/dnd/-/dnd-3.7.4.tgz", - "integrity": "sha512-YD0TVR5JkvTqskc1ouBpVKs6t/QS4RYCIyu8Ug8RgO122iIizuf2pfKnRLjYMdu5lXzBXGaIgd49dvnLzEXHIw==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/selection": "^3.20.9", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/flags": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", - "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@react-stately/form": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@react-stately/form/-/form-3.2.4.tgz", - "integrity": "sha512-qNBzun8SbLdgahryhKLqL1eqP+MXY6as82sVXYOOvUYLzgU5uuN8mObxYlxJgMI5akSdQJQV3RzyfVobPRE7Kw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/grid": { - "version": "3.11.9", - "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.11.9.tgz", - "integrity": "sha512-qQY6F+27iZRn30dt0ZOrSetUmbmNJ0pLe9Weuqw3+XDVSuWT+2O/rO1UUYeK+mO0Acjzdv+IWiYbu9RKf2wS9w==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/selection": "^3.20.9", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/layout": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@react-stately/layout/-/layout-4.6.0.tgz", - "integrity": "sha512-kBenEsP03nh5rKgfqlVMPcoKTJv0v92CTvrAb5gYY8t9g8LOwzdL89Yannq7f5xv8LFck/MmRQlotpMt2InETg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/table": "^3.15.4", - "@react-stately/virtualizer": "^4.4.6", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@react-types/table": "^3.13.6", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/list": { - "version": "3.13.4", - "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.4.tgz", - "integrity": "sha512-HHYSjA9VG7FPSAtpXAjQyM/V7qFHWGg88WmMrDt5QDlTBexwPuH0oFLnW0qaVZpAIxuWIsutZfxRAnme/NhhAA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/menu": { - "version": "3.9.11", - "resolved": "https://registry.npmjs.org/@react-stately/menu/-/menu-3.9.11.tgz", - "integrity": "sha512-vYkpO9uV2OUecsIkrOc+Urdl/s1xw/ibNH/UXsp4PtjMnS6mK9q2kXZTM3WvMAKoh12iveUO+YkYCZQshmFLHQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/overlays": "^3.6.23", - "@react-types/menu": "^3.10.7", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/numberfield": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/numberfield/-/numberfield-3.11.0.tgz", - "integrity": "sha512-rxfC047vL0LP4tanjinfjKAriAvdVL57Um5RUL5nHML8IOWCB3TBxegQkJ6to6goScC/oZhd0/Y2LSaiRuKbNw==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/number": "^3.6.5", - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/numberfield": "^3.8.18", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/overlays": { - "version": "3.6.23", - "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.23.tgz", - "integrity": "sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/overlays": "^3.9.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/radio": { - "version": "3.11.5", - "resolved": "https://registry.npmjs.org/@react-stately/radio/-/radio-3.11.5.tgz", - "integrity": "sha512-QxA779S4ea5icQ0ja7CeiNzY1cj7c9G9TN0m7maAIGiTSinZl2Ia8naZJ0XcbRRp+LBll7RFEdekne15TjvS/w==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/radio": "^3.9.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/searchfield": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@react-stately/searchfield/-/searchfield-3.5.19.tgz", - "integrity": "sha512-URllgjbtTQEaOCfddbHpJSPKOzG3pE3ajQHJ7Df8qCoHTjKfL6hnm/vp7X5sxPaZaN7VLZ5kAQxTE8hpo6s0+A==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/searchfield": "^3.6.8", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/select": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@react-stately/select/-/select-3.9.2.tgz", - "integrity": "sha512-oWn0bijuusp8YI7FRM/wgtPVqiIrgU/ZUfLKe/qJUmT8D+JFaMAJnyrAzKpx98TrgamgtXynF78ccpopPhgrKQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/form": "^3.2.4", - "@react-stately/list": "^3.13.4", - "@react-stately/overlays": "^3.6.23", - "@react-stately/utils": "^3.11.0", - "@react-types/select": "^3.12.2", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/selection": { - "version": "3.20.9", - "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.20.9.tgz", - "integrity": "sha512-RhxRR5Wovg9EVi3pq7gBPK2BoKmP59tOXDMh2r1PbnGevg/7TNdR67DCEblcmXwHuBNS46ELfKdd0XGHqmS8nQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/slider": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@react-stately/slider/-/slider-3.7.5.tgz", - "integrity": "sha512-OrQMNR5xamLYH52TXtvTgyw3EMwv+JI+1istQgEj1CHBjC9eZZqn5iNCN20tzm+uDPTH0EIGULFjjPIumqYUQg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@react-types/slider": "^3.8.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/table": { - "version": "3.15.4", - "resolved": "https://registry.npmjs.org/@react-stately/table/-/table-3.15.4.tgz", - "integrity": "sha512-fGaNyw3wv7JgRCNzgyDzpaaTFuSy5f4Qekch4UheMXDJX7dOeaMhUXeOfvnXCVg+BGM4ey/D82RvDOGvPy1Nww==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/grid": "^3.11.9", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@react-types/table": "^3.13.6", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tabs": { - "version": "3.8.9", - "resolved": "https://registry.npmjs.org/@react-stately/tabs/-/tabs-3.8.9.tgz", - "integrity": "sha512-AQ4Xrn6YzIolaVShCV9cnwOjBKPAOGP/PTp7wpSEtQbQ0HZzUDG2RG/M4baMeUB2jZ33b7ifXyPcK78o0uOftg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/list": "^3.13.4", - "@react-types/shared": "^3.33.1", - "@react-types/tabs": "^3.3.22", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/toast": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz", - "integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/toggle": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.9.5.tgz", - "integrity": "sha512-PVzXc788q3jH98Kvw1LYDL+wpVC14dCEKjOku8cSaqhEof6AJGaLR9yq+EF1yYSL2dxI6z8ghc0OozY8WrcFcA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tooltip": { - "version": "3.5.11", - "resolved": "https://registry.npmjs.org/@react-stately/tooltip/-/tooltip-3.5.11.tgz", - "integrity": "sha512-o8PnFXbvDCuVZ4Ht9ahfS6KHwIZjXopvoQ2vUPxv920irdgWEeC+4omgDOnJ/xFvcpmmJAmSsrQsTQrTguDUQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/overlays": "^3.6.23", - "@react-types/tooltip": "^3.5.2", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tree": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/@react-stately/tree/-/tree-3.9.6.tgz", - "integrity": "sha512-JCuhGyX2A+PAMsx2pRSwArfqNFZJ9JSPkDaOQJS8MFPAsBe5HemvXsdmv9aBIMzlbCYcVq6EsrFnzbVVTBt/6w==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", - "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/virtualizer": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@react-stately/virtualizer/-/virtualizer-4.4.6.tgz", - "integrity": "sha512-9SfXgLFB61/8SXNLfg5ARx9jAK4m03Aw6/Cg8mdZN24SYarL4TKNRpfw8K/HHVU/bi6WHSJypk6Z/z19o/ztrg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/autocomplete": { - "version": "3.0.0-alpha.38", - "resolved": "https://registry.npmjs.org/@react-types/autocomplete/-/autocomplete-3.0.0-alpha.38.tgz", - "integrity": "sha512-0XrlVC8drzcrCNzybbkZdLcTofXEzBsHuaFevt5awW1J0xBJ+SMLIQMDeUYrvKjjwXUBlCtjJJpOvitGt4Z+KA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/combobox": "^3.14.0", - "@react-types/searchfield": "^3.6.8", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/breadcrumbs": { - "version": "3.7.19", - "resolved": "https://registry.npmjs.org/@react-types/breadcrumbs/-/breadcrumbs-3.7.19.tgz", - "integrity": "sha512-AnkyYYmzaM2QFi/N0P/kQLM8tHOyFi7p397B/jEMucXDfwMw5Ny1ObCXeIEqbh8KrIa2Xp8SxmQlCV+8FPs4LA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/link": "^3.6.7", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/button": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.1.tgz", - "integrity": "sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/calendar": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/@react-types/calendar/-/calendar-3.8.3.tgz", - "integrity": "sha512-fpH6WNXotzH0TlKHXXxtjeLZ7ko0sbyHmwDAwmDFyP7T0Iwn1YQZ+lhceLifvynlxuOgX6oBItyUKmkHQ0FouQ==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/checkbox": { - "version": "3.10.4", - "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.4.tgz", - "integrity": "sha512-tYCG0Pd1usEz5hjvBEYcqcA0youx930Rss1QBIse9TgMekA1c2WmPDNupYV8phpO8Zuej3DL1WfBeXcgavK8aw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/color": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@react-types/color/-/color-3.1.4.tgz", - "integrity": "sha512-s+Xj4pvNBlJPpQ1Gr7bO1j4/tuwMUfdS9xIVFuiW5RvDsSybKTUJ/gqPzTxms94VDCRhLFocVn2STNdD2Erf6A==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@react-types/slider": "^3.8.4" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/combobox": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@react-types/combobox/-/combobox-3.14.0.tgz", - "integrity": "sha512-zmSSS7BcCOD8rGT8eGbVy7UlL5qq1vm88fFn4WgFe+lfK33ne+E7yTzTxcPY2TCGSo5fY6xMj3OG79FfVNGbSg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/datepicker": { - "version": "3.13.5", - "resolved": "https://registry.npmjs.org/@react-types/datepicker/-/datepicker-3.13.5.tgz", - "integrity": "sha512-j28Vz+xvbb4bj7+9Xbpc4WTvSitlBvt7YEaEGM/8ZQ5g4Jr85H2KwkmDwjzmMN2r6VMQMMYq9JEcemq5wWpfUQ==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-types/calendar": "^3.8.3", - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/dialog": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.5.24.tgz", - "integrity": "sha512-NFurEP/zV0dA/41422lV1t+0oh6f/13n+VmLHZG8R13m1J3ql/kAXZ49zBSqkqANBO1ojyugWebk99IiR4pYOw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/form": { - "version": "3.7.18", - "resolved": "https://registry.npmjs.org/@react-types/form/-/form-3.7.18.tgz", - "integrity": "sha512-0sBJW0+I9nJcF4SmKrYFEWAlehiebSTy7xqriqAXtqfTEdvzAYLGaAK2/7gx+wlNZeDTdW43CDRJ4XAhyhBqnw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/grid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.8.tgz", - "integrity": "sha512-zJvXH8gc1e1VH2H3LRnHH/W2HIkLkZMH3Cu5pLcj0vDuLBSWpcr3Ikh3jZ+VUOZF0G1Jt1lO8pKIaqFzDLNmLQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/link": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/@react-types/link/-/link-3.6.7.tgz", - "integrity": "sha512-1apXCFJgMC1uydc2KNENrps1qR642FqDpwlNWe254UTpRZn/hEZhA6ImVr8WhomfLJu672WyWA0rUOv4HT+/pQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/listbox": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.7.6.tgz", - "integrity": "sha512-335NYElKEByXMalAmeRPyulKIDd2cjOCQhLwvv2BtxO5zaJfZnBbhZs+XPd9zwU6YomyOxODKSHrwbNDx+Jf3w==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/menu": { - "version": "3.10.7", - "resolved": "https://registry.npmjs.org/@react-types/menu/-/menu-3.10.7.tgz", - "integrity": "sha512-+p7ixZdvPDJZhisqdtWiiuJ9pteNfK5i19NB6wzAw5XkljbEzodNhwLv6rI96DY5XpbFso2kcjw7IWi+rAAGGQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/meter": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@react-types/meter/-/meter-3.4.15.tgz", - "integrity": "sha512-9WjNphhLLM+TA4Ev1y2MkpugJ5JjTXseHh7ZWWx2veq5DrXMZYclkRpfUrUdLVKvaBIPQCgpQIj0TcQi+quR9A==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/progress": "^3.5.18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/numberfield": { - "version": "3.8.18", - "resolved": "https://registry.npmjs.org/@react-types/numberfield/-/numberfield-3.8.18.tgz", - "integrity": "sha512-nLzk7YAG9yAUtSv+9R8LgCHsu8hJq8/A+m1KsKxvc8WmNJjIujSFgWvT21MWBiUgPBzJKGzAqpMDDa087mltJQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/overlays": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.4.tgz", - "integrity": "sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/progress": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@react-types/progress/-/progress-3.5.18.tgz", - "integrity": "sha512-mKeQn+KrHr1y0/k7KtrbeDGDaERH6i4f6yBwj/ZtYDCTNKMO3tPHJY6nzF0w/KKZLplIO+BjUbHXc2RVm8ovwQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/radio": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/@react-types/radio/-/radio-3.9.4.tgz", - "integrity": "sha512-TkMRY3sA1PcFZhhclu4IUzUTIir6MzNJj8h6WT8vO6Nug2kXJ72qigugVFBWJSE472mltduOErEAo0rtAYWbQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/searchfield": { - "version": "3.6.8", - "resolved": "https://registry.npmjs.org/@react-types/searchfield/-/searchfield-3.6.8.tgz", - "integrity": "sha512-M2p7OVdMTMDmlBcHd4N2uCBwg3uJSNM4lmEyf09YD44N5wDAI0yogk52QBwsnhpe+i2s65UwCYgunB+QltRX8A==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@react-types/textfield": "^3.12.8" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/select": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@react-types/select/-/select-3.12.2.tgz", - "integrity": "sha512-AseOjfr3qM1W1qIWcbAe6NFpwZluVeQX/dmu9BYxjcnVvtoBLPMbE5zX/BPbv+N5eFYjoMyj7Ug9dqnI+LrlGw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/shared": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz", - "integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/slider": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.8.4.tgz", - "integrity": "sha512-C+xFVvfKREai9S/ekBDCVaGPOQYkNUAsQhjQnNsUAATaox4I6IYLmcIgLmljpMQWqAe+gZiWsIwacRYMez2Tew==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/switch": { - "version": "3.5.17", - "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.17.tgz", - "integrity": "sha512-2GTPJvBCYI8YZ3oerHtXg+qikabIXCMJ6C2wcIJ5Xn0k9XOovowghfJi10OPB2GGyOiLBU74CczP5nx8adG90Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/table": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.13.6.tgz", - "integrity": "sha512-eluL+iFfnVmFm7OSZrrFG9AUjw+tcv898zbv+NsZACa8oXG1v9AimhZfd+Mo8q/5+sX/9hguWNXFkSvmTjuVPQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tabs": { - "version": "3.3.22", - "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.3.22.tgz", - "integrity": "sha512-HGwLD9dA3k3AGfRKGFBhNgxU9/LyRmxN0kxVj1ghA4L9S/qTOzS6GhrGNkGzsGxyVLV4JN8MLxjWN2o9QHnLEg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/textfield": { - "version": "3.12.8", - "resolved": "https://registry.npmjs.org/@react-types/textfield/-/textfield-3.12.8.tgz", - "integrity": "sha512-wt6FcuE5AyntxsnPika/h3nf/DPmeAVbI018L9o6h+B/IL4sMWWdx663wx2KOOeHH8ejKGZQNPLhUKs4s1mVQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tooltip": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/@react-types/tooltip/-/tooltip-3.5.2.tgz", - "integrity": "sha512-FvSuZ2WP08NEWefrpCdBYpEEZh/5TvqvGjq0wqGzWg2OPwpc14HjD8aE7I3MOuylXkD4MSlMjl7J4DlvlcCs3Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", - "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", - "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", - "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", - "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", - "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", - "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", - "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", - "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", - "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", - "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", - "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", - "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", - "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", - "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", - "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">=14.0.0" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@react-aria/focus": { + "version": "3.21.5", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz", + "integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==", + "license": "Apache-2.0", "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@react-aria/form": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@react-aria/form/-/form-3.1.5.tgz", + "integrity": "sha512-BWlONgHn8hmaMkcS6AgMSLQeNqVBwqPNLhdqjDO/PCfzvV7O8NZw/dFeIzJwfG4aBfSpbHHRdXGdfrk3d8dylQ==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-stately/form": "^3.2.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@react-aria/grid": { + "version": "3.14.8", + "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.14.8.tgz", + "integrity": "sha512-X6rRFKDu/Kh6Sv8FBap3vjcb+z4jXkSOwkYnexIJp5kMTo5/Dqo55cCBio5B70Tanfv32Ev/6SpzYG7ryxnM9w==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/collections": "^3.12.10", + "@react-stately/grid": "^3.11.9", + "@react-stately/selection": "^3.20.9", + "@react-types/checkbox": "^3.10.4", + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@react-aria/gridlist": { + "version": "3.14.4", + "resolved": "https://registry.npmjs.org/@react-aria/gridlist/-/gridlist-3.14.4.tgz", + "integrity": "sha512-C/SbwC0qagZatoBrCjx8iZUex9apaJ8o8iRJ9eVHz0cpj7mXg6HuuotYGmDy9q67A2hve4I693RM1Cuwqwm+PQ==", + "license": "Apache-2.0", "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "@react-aria/focus": "^3.21.5", + "@react-aria/grid": "^3.14.8", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/list": "^3.13.4", + "@react-stately/tree": "^3.9.6", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@react-aria/i18n": { + "version": "3.12.16", + "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.16.tgz", + "integrity": "sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" + "@internationalized/date": "^3.12.0", + "@internationalized/message": "^3.1.8", + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/interactions": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz", + "integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==", + "license": "Apache-2.0", "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.33.1", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@types/emscripten": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", - "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/label": { + "version": "3.7.25", + "resolved": "https://registry.npmjs.org/@react-aria/label/-/label-3.7.25.tgz", + "integrity": "sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg==", + "license": "Apache-2.0", "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", - "dev": true, - "license": "MIT", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, "peerDependencies": { - "@types/react": "^19.2.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", - "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/type-utils": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node_modules/@react-aria/landmark": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.10.tgz", + "integrity": "sha512-GpNjJaI8/a6WxYDZgzTCLYSzPM6xp2pxCIQ4udiGbTCtxx13Trmm0cPABvPtzELidgolCf05em9Phr+3G0eE8A==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.66.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/link": { + "version": "3.8.9", + "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.8.9.tgz", + "integrity": "sha512-UaAFBfs84/Qq6TxlMWkREqqNY6SFLukot+z2Aa1kC+VyStv1kWG6sE5QLjm4SBn1Q3CGRsefhB/5+taaIbB4Pw==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-types/link": "^3.6.7", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/listbox": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.15.3.tgz", + "integrity": "sha512-C6YgiyrHS5sbS5UBdxGMhEs+EKJYotJgGVtl9l0ySXpBUXERiHJWLOyV7a8PwkUOmepbB4FaLD7Y9EUzGkrGlw==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/collections": "^3.12.10", + "@react-stately/list": "^3.13.4", + "@react-types/listbox": "^3.7.6", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/live-announcer": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@react-aria/live-announcer/-/live-announcer-3.4.4.tgz", + "integrity": "sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@swc/helpers": "^0.5.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node_modules/@react-aria/menu": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@react-aria/menu/-/menu-3.21.0.tgz", + "integrity": "sha512-CKTVZ4izSE1eKIti6TbTtzJAUo+WT8O4JC0XZCYDBpa0f++lD19Kz9aY+iY1buv5xGI20gAfpO474E9oEd4aQA==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/overlays": "^3.31.2", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/collections": "^3.12.10", + "@react-stately/menu": "^3.9.11", + "@react-stately/selection": "^3.20.9", + "@react-stately/tree": "^3.9.6", + "@react-types/button": "^3.15.1", + "@react-types/menu": "^3.10.7", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", - "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/meter": { + "version": "3.4.30", + "resolved": "https://registry.npmjs.org/@react-aria/meter/-/meter-3.4.30.tgz", + "integrity": "sha512-ZmANKW7s/Z4QGylHi46nhwtQ47T1bfMsU9MysBu7ViXXNJ03F4b6JXCJlKL5o2goQ3NbfZ68GeWamIT0BWSgtw==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@react-aria/progress": "^3.4.30", + "@react-types/meter": "^3.4.15", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node_modules/@react-aria/numberfield": { + "version": "3.12.5", + "resolved": "https://registry.npmjs.org/@react-aria/numberfield/-/numberfield-3.12.5.tgz", + "integrity": "sha512-Fi41IUWXEHLFIeJ/LHuZ9Azs8J/P563fZi37GSBkIq5P1pNt1rPgJJng5CNn4KsHxwqadTRUlbbZwbZraWDtRg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/spinbutton": "^3.7.2", + "@react-aria/textfield": "^3.18.5", + "@react-aria/utils": "^3.33.1", + "@react-stately/form": "^3.2.4", + "@react-stately/numberfield": "^3.11.0", + "@react-types/button": "^3.15.1", + "@react-types/numberfield": "^3.8.18", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/overlays": { + "version": "3.31.2", + "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.31.2.tgz", + "integrity": "sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.33.1", + "@react-aria/visually-hidden": "^3.8.31", + "@react-stately/flags": "^3.1.2", + "@react-stately/overlays": "^3.6.23", + "@react-types/button": "^3.15.1", + "@react-types/overlays": "^3.9.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/progress": { + "version": "3.4.30", + "resolved": "https://registry.npmjs.org/@react-aria/progress/-/progress-3.4.30.tgz", + "integrity": "sha512-S6OWVGgluSWYSd/A6O8CVjz83eeMUfkuWSra0ewAV9bmxZ7TP9pUmD3bGdqHZEl97nt5vHGjZ3eq/x8eCmzKhA==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^4.0.2" + "@react-aria/i18n": "^3.12.16", + "@react-aria/label": "^3.7.25", + "@react-aria/utils": "^3.33.1", + "@react-types/progress": "^3.5.18", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": "20 || >=22" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/@react-aria/radio": { + "version": "3.12.5", + "resolved": "https://registry.npmjs.org/@react-aria/radio/-/radio-3.12.5.tgz", + "integrity": "sha512-8CCJKJzfozEiWBPO9QAATG1rBGJEJ+xoqvHf9LKU2sPFGsA2/SRnLs6LB9fCG5R3spvaK1xz0any1fjWPl7x8A==", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" + "@react-aria/focus": "^3.21.5", + "@react-aria/form": "^3.1.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/utils": "^3.33.1", + "@react-stately/radio": "^3.11.5", + "@react-types/radio": "^3.9.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@react-aria/searchfield": { + "version": "3.8.12", + "resolved": "https://registry.npmjs.org/@react-aria/searchfield/-/searchfield-3.8.12.tgz", + "integrity": "sha512-kYlUHD/+mWzNroHoR8ojUxYBoMviRZn134WaKPFjfNUGZDOEuh4XzOoj+cjdJfe6N3mwTaYu6rJQtunSHIAfhA==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/textfield": "^3.18.5", + "@react-aria/utils": "^3.33.1", + "@react-stately/searchfield": "^3.5.19", + "@react-types/button": "^3.15.1", + "@react-types/searchfield": "^3.6.8", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/select": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/@react-aria/select/-/select-3.17.3.tgz", + "integrity": "sha512-u0UFWw0S7q9oiSbjetDpRoLLIcC+L89uYlm+YfCrdT8ntbQgABNiJRxdVvxnhR0fR6MC9ASTTvuQnNHNn52+1A==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@react-aria/form": "^3.1.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/listbox": "^3.15.3", + "@react-aria/menu": "^3.21.0", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-aria/visually-hidden": "^3.8.31", + "@react-stately/select": "^3.9.2", + "@react-types/button": "^3.15.1", + "@react-types/select": "^3.12.2", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", - "dev": true, - "license": "MIT", + "node_modules/@react-aria/selection": { + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/@react-aria/selection/-/selection-3.27.2.tgz", + "integrity": "sha512-GbUSSLX/ciXix95KW1g+SLM9np7iXpIZrFDSXkC6oNx1uhy18eAcuTkeZE25+SY5USVUmEzjI3m/3JoSUcebbg==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-stately/selection": "^3.20.9", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, + "node_modules/@react-aria/separator": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/@react-aria/separator/-/separator-3.4.16.tgz", + "integrity": "sha512-RCUtQhDGnPxKzyG8KM79yOB0fSiEf8r/rxShidOVnGLiBW2KFmBa22/Gfc4jnqg/keN3dxvkSGoqmeXgctyp6g==", "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "dependencies": { + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/native": { - "name": "typescript", - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, + "node_modules/@react-aria/slider": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/@react-aria/slider/-/slider-3.8.5.tgz", + "integrity": "sha512-gqkJxznk141mE0JamXF5CXml9PDbPkBz8dyKlihtWHWX4yhEbVYdC9J0otE7iCR3zx69Bm7WHoTGL0BsdpKzVA==", "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/utils": "^3.33.1", + "@react-stately/slider": "^3.7.5", + "@react-types/shared": "^3.33.1", + "@react-types/slider": "^3.8.4", + "@swc/helpers": "^0.5.0" }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/old": { - "name": "typescript", - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "node_modules/@react-aria/spinbutton": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.7.2.tgz", + "integrity": "sha512-adjE1wNCWlugvAtVXlXWPtIG9JWurEgYVn1Eeyh19x038+oXGvOsOAoKCXM+SnGleTWQ9J7pEZITFoEI3cVfAw==", "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/utils": "^3.33.1", + "@react-types/button": "^3.15.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">=14.17" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@swc/helpers": "^0.5.0" + }, "engines": { - "node": ">=16.20.0" + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-aria/switch": { + "version": "3.7.11", + "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.7.11.tgz", + "integrity": "sha512-dYVX71HiepBsKyeMaQgHbhqI+MQ3MVoTd5EnTbUjefIBnmQZavYj1/e4NUiUI4Ix+/C0HxL8ibDAv4NlSW3eLQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/toggle": "^3.12.5", + "@react-stately/toggle": "^3.9.5", + "@react-types/shared": "^3.33.1", + "@react-types/switch": "^3.5.17", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-aria/table": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.17.11.tgz", + "integrity": "sha512-GkYmWPiW3OM+FUZxdS33teHXHXde7TjHuYgDDaG9phvg6cQTQjGilJozrzA3OfftTOq5VB8XcKTIQW3c0tpYsQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/focus": "^3.21.5", + "@react-aria/grid": "^3.14.8", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/live-announcer": "^3.4.4", + "@react-aria/utils": "^3.33.1", + "@react-aria/visually-hidden": "^3.8.31", + "@react-stately/collections": "^3.12.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/table": "^3.15.4", + "@react-types/checkbox": "^3.10.4", + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1", + "@react-types/table": "^3.13.6", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-aria/tabs": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@react-aria/tabs/-/tabs-3.11.1.tgz", + "integrity": "sha512-3Ppz7yaEDW9L7p9PE9yNOl5caLwNnnLQqI+MX/dwbWlw9HluHS7uIjb21oswNl6UbSxAWyENOka45+KN4Fkh7A==", "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/tabs": "^3.8.9", + "@react-types/shared": "^3.33.1", + "@react-types/tabs": "^3.3.22", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-aria/tag": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@react-aria/tag/-/tag-3.8.1.tgz", + "integrity": "sha512-VonpO++F8afXGDWc9VUxAc2wefyJpp1n9OGpbnB7zmqWiuPwO/RixjUdcH7iJkiC4vADwx9uLnhyD6kcwGV2ig==", "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/gridlist": "^3.14.4", + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/list": "^3.13.4", + "@react-types/button": "^3.15.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@react-aria/textfield": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/@react-aria/textfield/-/textfield-3.18.5.tgz", + "integrity": "sha512-ttwVSuwoV3RPaG2k2QzEXKeQNQ3mbdl/2yy6I4Tjrn1ZNkYHfVyJJ26AjenfSmj1kkTQoSAfZ8p+7rZp4n0xoQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/form": "^3.1.5", + "@react-aria/interactions": "^3.27.1", + "@react-aria/label": "^3.7.25", + "@react-aria/utils": "^3.33.1", + "@react-stately/form": "^3.2.4", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@react-types/textfield": "^3.12.8", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-aria/toast": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.11.tgz", + "integrity": "sha512-2DjZjBAvm8/CWbnZ6s7LjkYCkULKtjMve6GvhPTq98AthuEDLEiBvM1wa3xdecCRhZyRT1g6DXqVca0EfZ9fJA==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/landmark": "^3.0.10", + "@react-aria/utils": "^3.33.1", + "@react-stately/toast": "^3.1.3", + "@react-types/button": "^3.15.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, + "node_modules/@react-aria/toggle": { + "version": "3.12.5", + "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.12.5.tgz", + "integrity": "sha512-XXVFLzcV8fr9mz7y/wfxEAhWvaBZ9jSfhCMuxH2bsivO7nTcMJ1jb4g2xJNwZgne17bMWNc7mKvW5dbsdlI6BA==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-stately/toggle": "^3.9.5", + "@react-types/checkbox": "^3.10.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, + "node_modules/@react-aria/toolbar": { + "version": "3.0.0-beta.24", + "resolved": "https://registry.npmjs.org/@react-aria/toolbar/-/toolbar-3.0.0-beta.24.tgz", + "integrity": "sha512-B2Rmpko7Ghi2RbNfsGdbR7I+RQBDhPGVE4bU3/EwHz+P/vNe5LyGPTeSwqaOMsQTF9lKNCkY8424dVTCr6RUMg==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/focus": "^3.21.5", + "@react-aria/i18n": "^3.12.16", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@react-aria/tooltip": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@react-aria/tooltip/-/tooltip-3.9.2.tgz", + "integrity": "sha512-VrgkPwHiEnAnBhoQ4W7kfry/RfVuRWrUPaJSp0+wKM6u0gg2tmn7OFRDXTxBAm/omQUguIdIjRWg7sf3zHH82A==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-stately/tooltip": "^3.5.11", + "@react-types/shared": "^3.33.1", + "@react-types/tooltip": "^3.5.2", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, + "node_modules/@react-aria/tree": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@react-aria/tree/-/tree-3.1.7.tgz", + "integrity": "sha512-C54yH5NmsOFa2Q+cg6B1BPr5KUlU9vLIoBnVrgrH237FRSXQPIbcM4VpmITAHq1VR7w6ayyS1hgTwFxo67ykWQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/gridlist": "^3.14.4", + "@react-aria/i18n": "^3.12.16", + "@react-aria/selection": "^3.27.2", + "@react-aria/utils": "^3.33.1", + "@react-stately/tree": "^3.9.6", + "@react-types/button": "^3.15.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, + "node_modules/@react-aria/utils": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz", + "integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-aria/virtualizer": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@react-aria/virtualizer/-/virtualizer-4.1.13.tgz", + "integrity": "sha512-d5KS+p8GXGNRbGPRE/N6jtth3et3KssQIz52h2+CAoAh7C3vvR64kkTaGdeywClvM+fSo8FxJuBrdfQvqC2ktQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-aria/i18n": "^3.12.16", + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-stately/virtualizer": "^4.4.6", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/visually-hidden": { + "version": "3.8.31", + "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.31.tgz", + "integrity": "sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-stately/autocomplete": { + "version": "3.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.4.tgz", + "integrity": "sha512-K2Uy7XEdseFvgwRQ8CyrYEHMupjVKEszddOapP8deNz4hntYvT1aRm0m+sKa5Kl/4kvg9c/3NZpQcrky/vRZIg==", "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-stately/utils": "^3.11.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-stately/calendar": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.9.3.tgz", + "integrity": "sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg==", "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@internationalized/date": "^3.12.0", + "@react-stately/utils": "^3.11.0", + "@react-types/calendar": "^3.8.3", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-stately/checkbox": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@react-stately/checkbox/-/checkbox-3.7.5.tgz", + "integrity": "sha512-K5R5ted7AxLB3sDkuVAazUdyRMraFT1imVqij2GuAiOUFvsZvbuocnDuFkBVKojyV3GpqLBvViV8IaCMc4hNIw==", "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-stately/form": "^3.2.4", + "@react-stately/utils": "^3.11.0", + "@react-types/checkbox": "^3.10.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-stately/collections": { + "version": "3.12.10", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.10.tgz", + "integrity": "sha512-wmF9VxJDyBujBuQ76vXj2g/+bnnj8fx5DdXgRmyfkkYhPB46+g2qnjbVGEvipo7bJuGxDftCUC4SN7l7xqUWfg==", "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-stately/color": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.9.5.tgz", + "integrity": "sha512-8pZxzXWDRuglzDwyTG7mLw2LQMCHIVNbVc9YmbsxbOjAL+lOqszo60KzyaFKVxeDQczSvrNTHcQZqlbNIC0eyQ==", "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-stately/form": "^3.2.4", + "@react-stately/numberfield": "^3.11.0", + "@react-stately/slider": "^3.7.5", + "@react-stately/utils": "^3.11.0", + "@react-types/color": "^3.1.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@react-stately/combobox": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@react-stately/combobox/-/combobox-3.13.0.tgz", + "integrity": "sha512-dX9g/cK1hjLRjcbWVF6keHxTQDGhKGB2QAgPhWcBmOK3qJv+2dQqsJ6YCGWn/Y2N2acoEseLrAA7+Qe4HWV9cg==", "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-stately/collections": "^3.12.10", + "@react-stately/form": "^3.2.4", + "@react-stately/list": "^3.13.4", + "@react-stately/overlays": "^3.6.23", + "@react-stately/utils": "^3.11.0", + "@react-types/combobox": "^3.14.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@react-stately/data": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/@react-stately/data/-/data-3.15.2.tgz", + "integrity": "sha512-BsmeeGgFwOGwo0g9Waprdyt+846n3KhKggZfpEnp5+sC4dE4uW1VIYpdyupMfr3bQcmX123q6TegfNP3eszrUA==", "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/datepicker": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/@react-stately/datepicker/-/datepicker-3.16.1.tgz", + "integrity": "sha512-BtAMDvxd1OZxkxjqq5tN5TYmp6Hm8+o3+IDA4qmem2/pfQfVbOZeWS2WitcPBImj4n4T+W1A5+PI7mT/6DUBVg==", + "license": "Apache-2.0", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "@internationalized/date": "^3.12.0", + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-stately/form": "^3.2.4", + "@react-stately/overlays": "^3.6.23", + "@react-stately/utils": "^3.11.0", + "@react-types/datepicker": "^3.13.5", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/disclosure": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@react-stately/disclosure/-/disclosure-3.0.11.tgz", + "integrity": "sha512-/KjB/0HkxGWbhFAPztCP411LUKZCx9k8cKukrlGqrUWyvrcXlmza90j0g/CuxACBoV+DJP9V+4q+8ide0x750A==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "node_modules/@react-stately/dnd": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@react-stately/dnd/-/dnd-3.7.4.tgz", + "integrity": "sha512-YD0TVR5JkvTqskc1ouBpVKs6t/QS4RYCIyu8Ug8RgO122iIizuf2pfKnRLjYMdu5lXzBXGaIgd49dvnLzEXHIw==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/selection": "^3.20.9", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "@swc/helpers": "^0.5.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/form": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@react-stately/form/-/form-3.2.4.tgz", + "integrity": "sha512-qNBzun8SbLdgahryhKLqL1eqP+MXY6as82sVXYOOvUYLzgU5uuN8mObxYlxJgMI5akSdQJQV3RzyfVobPRE7Kw==", + "license": "Apache-2.0", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", + "node_modules/@react-stately/grid": { + "version": "3.11.9", + "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.11.9.tgz", + "integrity": "sha512-qQY6F+27iZRn30dt0ZOrSetUmbmNJ0pLe9Weuqw3+XDVSuWT+2O/rO1UUYeK+mO0Acjzdv+IWiYbu9RKf2wS9w==", + "license": "Apache-2.0", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@react-stately/collections": "^3.12.10", + "@react-stately/selection": "^3.20.9", + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/layout": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@react-stately/layout/-/layout-4.6.0.tgz", + "integrity": "sha512-kBenEsP03nh5rKgfqlVMPcoKTJv0v92CTvrAb5gYY8t9g8LOwzdL89Yannq7f5xv8LFck/MmRQlotpMt2InETg==", + "license": "Apache-2.0", "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/collections": "^3.12.10", + "@react-stately/table": "^3.15.4", + "@react-stately/virtualizer": "^4.4.6", + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1", + "@react-types/table": "^3.13.6", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/list": { + "version": "3.13.4", + "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.4.tgz", + "integrity": "sha512-HHYSjA9VG7FPSAtpXAjQyM/V7qFHWGg88WmMrDt5QDlTBexwPuH0oFLnW0qaVZpAIxuWIsutZfxRAnme/NhhAA==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/collections": "^3.12.10", + "@react-stately/selection": "^3.20.9", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/menu": { + "version": "3.9.11", + "resolved": "https://registry.npmjs.org/@react-stately/menu/-/menu-3.9.11.tgz", + "integrity": "sha512-vYkpO9uV2OUecsIkrOc+Urdl/s1xw/ibNH/UXsp4PtjMnS6mK9q2kXZTM3WvMAKoh12iveUO+YkYCZQshmFLHQ==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/overlays": "^3.6.23", + "@react-types/menu": "^3.10.7", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/numberfield": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-stately/numberfield/-/numberfield-3.11.0.tgz", + "integrity": "sha512-rxfC047vL0LP4tanjinfjKAriAvdVL57Um5RUL5nHML8IOWCB3TBxegQkJ6to6goScC/oZhd0/Y2LSaiRuKbNw==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@internationalized/number": "^3.6.5", + "@react-stately/form": "^3.2.4", + "@react-stately/utils": "^3.11.0", + "@react-types/numberfield": "^3.8.18", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/overlays": { + "version": "3.6.23", + "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.23.tgz", + "integrity": "sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/utils": "^3.11.0", + "@react-types/overlays": "^3.9.4", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/radio": { + "version": "3.11.5", + "resolved": "https://registry.npmjs.org/@react-stately/radio/-/radio-3.11.5.tgz", + "integrity": "sha512-QxA779S4ea5icQ0ja7CeiNzY1cj7c9G9TN0m7maAIGiTSinZl2Ia8naZJ0XcbRRp+LBll7RFEdekne15TjvS/w==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/form": "^3.2.4", + "@react-stately/utils": "^3.11.0", + "@react-types/radio": "^3.9.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/searchfield": { + "version": "3.5.19", + "resolved": "https://registry.npmjs.org/@react-stately/searchfield/-/searchfield-3.5.19.tgz", + "integrity": "sha512-URllgjbtTQEaOCfddbHpJSPKOzG3pE3ajQHJ7Df8qCoHTjKfL6hnm/vp7X5sxPaZaN7VLZ5kAQxTE8hpo6s0+A==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" + "@react-stately/utils": "^3.11.0", + "@react-types/searchfield": "^3.6.8", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/select": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@react-stately/select/-/select-3.9.2.tgz", + "integrity": "sha512-oWn0bijuusp8YI7FRM/wgtPVqiIrgU/ZUfLKe/qJUmT8D+JFaMAJnyrAzKpx98TrgamgtXynF78ccpopPhgrKQ==", + "license": "Apache-2.0", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/form": "^3.2.4", + "@react-stately/list": "^3.13.4", + "@react-stately/overlays": "^3.6.23", + "@react-stately/utils": "^3.11.0", + "@react-types/select": "^3.12.2", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/selection": { + "version": "3.20.9", + "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.20.9.tgz", + "integrity": "sha512-RhxRR5Wovg9EVi3pq7gBPK2BoKmP59tOXDMh2r1PbnGevg/7TNdR67DCEblcmXwHuBNS46ELfKdd0XGHqmS8nQ==", + "license": "Apache-2.0", "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/collections": "^3.12.10", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, + "node_modules/@react-stately/slider": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@react-stately/slider/-/slider-3.7.5.tgz", + "integrity": "sha512-OrQMNR5xamLYH52TXtvTgyw3EMwv+JI+1istQgEj1CHBjC9eZZqn5iNCN20tzm+uDPTH0EIGULFjjPIumqYUQg==", "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "dependencies": { + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@react-types/slider": "^3.8.4", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/@react-stately/table": { + "version": "3.15.4", + "resolved": "https://registry.npmjs.org/@react-stately/table/-/table-3.15.4.tgz", + "integrity": "sha512-fGaNyw3wv7JgRCNzgyDzpaaTFuSy5f4Qekch4UheMXDJX7dOeaMhUXeOfvnXCVg+BGM4ey/D82RvDOGvPy1Nww==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/collections": "^3.12.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/grid": "^3.11.9", + "@react-stately/selection": "^3.20.9", + "@react-stately/utils": "^3.11.0", + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1", + "@react-types/table": "^3.13.6", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/tabs": { + "version": "3.8.9", + "resolved": "https://registry.npmjs.org/@react-stately/tabs/-/tabs-3.8.9.tgz", + "integrity": "sha512-AQ4Xrn6YzIolaVShCV9cnwOjBKPAOGP/PTp7wpSEtQbQ0HZzUDG2RG/M4baMeUB2jZ33b7ifXyPcK78o0uOftg==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@react-stately/list": "^3.13.4", + "@react-types/shared": "^3.33.1", + "@react-types/tabs": "^3.3.22", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/toast": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz", + "integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==", + "license": "Apache-2.0", "dependencies": { - "fill-range": "^7.1.1" + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", + "node_modules/@react-stately/toggle": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.9.5.tgz", + "integrity": "sha512-PVzXc788q3jH98Kvw1LYDL+wpVC14dCEKjOku8cSaqhEof6AJGaLR9yq+EF1yYSL2dxI6z8ghc0OozY8WrcFcA==", + "license": "Apache-2.0", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" + "@react-stately/utils": "^3.11.0", + "@react-types/checkbox": "^3.10.4", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/tooltip": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@react-stately/tooltip/-/tooltip-3.5.11.tgz", + "integrity": "sha512-o8PnFXbvDCuVZ4Ht9ahfS6KHwIZjXopvoQ2vUPxv920irdgWEeC+4omgDOnJ/xFvcpmmJAmSsrQsTQrTguDUQA==", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" + "@react-stately/overlays": "^3.6.23", + "@react-types/tooltip": "^3.5.2", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/tree": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/@react-stately/tree/-/tree-3.9.6.tgz", + "integrity": "sha512-JCuhGyX2A+PAMsx2pRSwArfqNFZJ9JSPkDaOQJS8MFPAsBe5HemvXsdmv9aBIMzlbCYcVq6EsrFnzbVVTBt/6w==", + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@react-stately/collections": "^3.12.10", + "@react-stately/selection": "^3.20.9", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/utils": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", + "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", + "node_modules/@react-stately/virtualizer": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@react-stately/virtualizer/-/virtualizer-4.4.6.tgz", + "integrity": "sha512-9SfXgLFB61/8SXNLfg5ARx9jAK4m03Aw6/Cg8mdZN24SYarL4TKNRpfw8K/HHVU/bi6WHSJypk6Z/z19o/ztrg==", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/autocomplete": { + "version": "3.0.0-alpha.38", + "resolved": "https://registry.npmjs.org/@react-types/autocomplete/-/autocomplete-3.0.0-alpha.38.tgz", + "integrity": "sha512-0XrlVC8drzcrCNzybbkZdLcTofXEzBsHuaFevt5awW1J0xBJ+SMLIQMDeUYrvKjjwXUBlCtjJJpOvitGt4Z+KA==", + "license": "Apache-2.0", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "@react-types/combobox": "^3.14.0", + "@react-types/searchfield": "^3.6.8", + "@react-types/shared": "^3.33.1" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/@react-types/breadcrumbs": { + "version": "3.7.19", + "resolved": "https://registry.npmjs.org/@react-types/breadcrumbs/-/breadcrumbs-3.7.19.tgz", + "integrity": "sha512-AnkyYYmzaM2QFi/N0P/kQLM8tHOyFi7p397B/jEMucXDfwMw5Ny1ObCXeIEqbh8KrIa2Xp8SxmQlCV+8FPs4LA==", + "license": "Apache-2.0", "dependencies": { - "is-glob": "^4.0.1" + "@react-types/link": "^3.6.7", + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/button": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.1.tgz", + "integrity": "sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ==", + "license": "Apache-2.0", "dependencies": { - "color-name": "~1.1.4" + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">=7.0.0" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/calendar": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/@react-types/calendar/-/calendar-3.8.3.tgz", + "integrity": "sha512-fpH6WNXotzH0TlKHXXxtjeLZ7ko0sbyHmwDAwmDFyP7T0Iwn1YQZ+lhceLifvynlxuOgX6oBItyUKmkHQ0FouQ==", + "license": "Apache-2.0", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@internationalized/date": "^3.12.0", + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/checkbox": { + "version": "3.10.4", + "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.4.tgz", + "integrity": "sha512-tYCG0Pd1usEz5hjvBEYcqcA0youx930Rss1QBIse9TgMekA1c2WmPDNupYV8phpO8Zuej3DL1WfBeXcgavK8aw==", + "license": "Apache-2.0", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/color": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@react-types/color/-/color-3.1.4.tgz", + "integrity": "sha512-s+Xj4pvNBlJPpQ1Gr7bO1j4/tuwMUfdS9xIVFuiW5RvDsSybKTUJ/gqPzTxms94VDCRhLFocVn2STNdD2Erf6A==", + "license": "Apache-2.0", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1", + "@react-types/slider": "^3.8.4" }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/combobox": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@react-types/combobox/-/combobox-3.14.0.tgz", + "integrity": "sha512-zmSSS7BcCOD8rGT8eGbVy7UlL5qq1vm88fFn4WgFe+lfK33ne+E7yTzTxcPY2TCGSo5fY6xMj3OG79FfVNGbSg==", + "license": "Apache-2.0", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/datepicker": { + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/@react-types/datepicker/-/datepicker-3.13.5.tgz", + "integrity": "sha512-j28Vz+xvbb4bj7+9Xbpc4WTvSitlBvt7YEaEGM/8ZQ5g4Jr85H2KwkmDwjzmMN2r6VMQMMYq9JEcemq5wWpfUQ==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "@internationalized/date": "^3.12.0", + "@react-types/calendar": "^3.8.3", + "@react-types/overlays": "^3.9.4", + "@react-types/shared": "^3.33.1" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/dialog": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.5.24.tgz", + "integrity": "sha512-NFurEP/zV0dA/41422lV1t+0oh6f/13n+VmLHZG8R13m1J3ql/kAXZ49zBSqkqANBO1ojyugWebk99IiR4pYOw==", + "license": "Apache-2.0", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "@react-types/overlays": "^3.9.4", + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/form": { + "version": "3.7.18", + "resolved": "https://registry.npmjs.org/@react-types/form/-/form-3.7.18.tgz", + "integrity": "sha512-0sBJW0+I9nJcF4SmKrYFEWAlehiebSTy7xqriqAXtqfTEdvzAYLGaAK2/7gx+wlNZeDTdW43CDRJ4XAhyhBqnw==", + "license": "Apache-2.0", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, + "node_modules/@react-types/grid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.8.tgz", + "integrity": "sha512-zJvXH8gc1e1VH2H3LRnHH/W2HIkLkZMH3Cu5pLcj0vDuLBSWpcr3Ikh3jZ+VUOZF0G1Jt1lO8pKIaqFzDLNmLQ==", "license": "Apache-2.0", - "engines": { - "node": ">=8" + "dependencies": { + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, + "node_modules/@react-types/link": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@react-types/link/-/link-3.6.7.tgz", + "integrity": "sha512-1apXCFJgMC1uydc2KNENrps1qR642FqDpwlNWe254UTpRZn/hEZhA6ImVr8WhomfLJu672WyWA0rUOv4HT+/pQ==", "license": "Apache-2.0", "dependencies": { - "esutils": "^2.0.2" + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">=0.10.0" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "node_modules/@react-types/listbox": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.7.6.tgz", + "integrity": "sha512-335NYElKEByXMalAmeRPyulKIDd2cjOCQhLwvv2BtxO5zaJfZnBbhZs+XPd9zwU6YomyOxODKSHrwbNDx+Jf3w==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/menu": { + "version": "3.10.7", + "resolved": "https://registry.npmjs.org/@react-types/menu/-/menu-3.10.7.tgz", + "integrity": "sha512-+p7ixZdvPDJZhisqdtWiiuJ9pteNfK5i19NB6wzAw5XkljbEzodNhwLv6rI96DY5XpbFso2kcjw7IWi+rAAGGQ==", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "@react-types/overlays": "^3.9.4", + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/meter": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@react-types/meter/-/meter-3.4.15.tgz", + "integrity": "sha512-9WjNphhLLM+TA4Ev1y2MkpugJ5JjTXseHh7ZWWx2veq5DrXMZYclkRpfUrUdLVKvaBIPQCgpQIj0TcQi+quR9A==", + "license": "Apache-2.0", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "@react-types/progress": "^3.5.18" }, - "engines": { - "node": ">=10.13.0" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/numberfield": { + "version": "3.8.18", + "resolved": "https://registry.npmjs.org/@react-types/numberfield/-/numberfield-3.8.18.tgz", + "integrity": "sha512-nLzk7YAG9yAUtSv+9R8LgCHsu8hJq8/A+m1KsKxvc8WmNJjIujSFgWvT21MWBiUgPBzJKGzAqpMDDa087mltJQ==", + "license": "Apache-2.0", "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node_modules/@react-types/overlays": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.4.tgz", + "integrity": "sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node_modules/@react-types/progress": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@react-types/progress/-/progress-3.5.18.tgz", + "integrity": "sha512-mKeQn+KrHr1y0/k7KtrbeDGDaERH6i4f6yBwj/ZtYDCTNKMO3tPHJY6nzF0w/KKZLplIO+BjUbHXc2RVm8ovwQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/radio": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/@react-types/radio/-/radio-3.9.4.tgz", + "integrity": "sha512-TkMRY3sA1PcFZhhclu4IUzUTIir6MzNJj8h6WT8vO6Nug2kXJ72qigugVFBWJSE472mltduOErEAo0rtAYWbQA==", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/searchfield": { + "version": "3.6.8", + "resolved": "https://registry.npmjs.org/@react-types/searchfield/-/searchfield-3.6.8.tgz", + "integrity": "sha512-M2p7OVdMTMDmlBcHd4N2uCBwg3uJSNM4lmEyf09YD44N5wDAI0yogk52QBwsnhpe+i2s65UwCYgunB+QltRX8A==", + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0" + "@react-types/shared": "^3.33.1", + "@react-types/textfield": "^3.12.8" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/select": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@react-types/select/-/select-3.12.2.tgz", + "integrity": "sha512-AseOjfr3qM1W1qIWcbAe6NFpwZluVeQX/dmu9BYxjcnVvtoBLPMbE5zX/BPbv+N5eFYjoMyj7Ug9dqnI+LrlGw==", + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz", + "integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/slider": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.8.4.tgz", + "integrity": "sha512-C+xFVvfKREai9S/ekBDCVaGPOQYkNUAsQhjQnNsUAATaox4I6IYLmcIgLmljpMQWqAe+gZiWsIwacRYMez2Tew==", + "license": "Apache-2.0", "dependencies": { - "hasown": "^2.0.2" + "@react-types/shared": "^3.33.1" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/switch": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.17.tgz", + "integrity": "sha512-2GTPJvBCYI8YZ3oerHtXg+qikabIXCMJ6C2wcIJ5Xn0k9XOovowghfJi10OPB2GGyOiLBU74CczP5nx8adG90Q==", + "license": "Apache-2.0", "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "node_modules/@react-types/table": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.13.6.tgz", + "integrity": "sha512-eluL+iFfnVmFm7OSZrrFG9AUjw+tcv898zbv+NsZACa8oXG1v9AimhZfd+Mo8q/5+sX/9hguWNXFkSvmTjuVPQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/grid": "^3.3.8", + "@react-types/shared": "^3.33.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/tabs": { + "version": "3.3.22", + "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.3.22.tgz", + "integrity": "sha512-HGwLD9dA3k3AGfRKGFBhNgxU9/LyRmxN0kxVj1ghA4L9S/qTOzS6GhrGNkGzsGxyVLV4JN8MLxjWN2o9QHnLEg==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "@react-types/shared": "^3.33.1" }, "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/textfield": { + "version": "3.12.8", + "resolved": "https://registry.npmjs.org/@react-types/textfield/-/textfield-3.12.8.tgz", + "integrity": "sha512-wt6FcuE5AyntxsnPika/h3nf/DPmeAVbI018L9o6h+B/IL4sMWWdx663wx2KOOeHH8ejKGZQNPLhUKs4s1mVQA==", + "license": "Apache-2.0", "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", + "node_modules/@react-types/tooltip": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@react-types/tooltip/-/tooltip-3.5.2.tgz", + "integrity": "sha512-FvSuZ2WP08NEWefrpCdBYpEEZh/5TvqvGjq0wqGzWg2OPwpc14HjD8aE7I3MOuylXkD4MSlMjl7J4DlvlcCs3Q==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.1" + "@react-types/overlays": "^3.9.4", + "@react-types/shared": "^3.33.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=4.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "tslib": "^2.8.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 20" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16" + "node": ">= 20" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 20" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">= 20" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { - "node": ">= 0.4" + "node": ">=14.0.0" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", "dev": true, + "inBundle": true, "license": "MIT", + "optional": true, "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", "dev": true, - "license": "ISC", + "inBundle": true, + "license": "MIT", + "optional": true, "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" + "tslib": "^2.4.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", "dev": true, + "inBundle": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", "dev": true, + "inBundle": true, "license": "MIT", + "optional": true, "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "@tybys/wasm-util": "^0.10.1" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", "dev": true, + "inBundle": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", "dev": true, - "license": "ISC" + "inBundle": true, + "license": "0BSD", + "optional": true }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">= 20" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "csstype": "^3.2.2" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@types/react": "^19.2.0" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" + "optional": true + }, + "node_modules/@typescript/native": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" }, "engines": { - "node": ">= 0.4" + "node": ">=16.20.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/@typescript/old": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" + "node": ">=14.17" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 4" + "node": ">=16.20.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=16.20.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.8.19" + "node": ">=16.20.0" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - } - }, - "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "license": "BSD-3-Clause", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" + "node": ">=16.20.0" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=16.20.0" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=16.20.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=16.20.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16.20.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.12.0" + "node": ">=16.20.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "fill-range": "^7.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 8.10.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "is-glob": "^4.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.13.0" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=4.0" + "node": ">=0.10.0" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "engines": { + "node": ">=0.12.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/lightningcss": { @@ -6435,59 +3955,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lru-cache/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -6507,300 +3974,104 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/monaco-editor": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", - "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", - "license": "MIT", - "dependencies": { - "dompurify": "3.4.8", - "marked": "14.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "bin": { + "marked": "bin/marked.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 18" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" + "dompurify": "3.4.8", + "marked": "14.0.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/oxlint": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz", + "integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==", "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" + "bin": { + "oxlint": "bin/oxlint" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.79.0", + "@oxlint/binding-android-arm64": "1.79.0", + "@oxlint/binding-darwin-arm64": "1.79.0", + "@oxlint/binding-darwin-x64": "1.79.0", + "@oxlint/binding-freebsd-x64": "1.79.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", + "@oxlint/binding-linux-arm-musleabihf": "1.79.0", + "@oxlint/binding-linux-arm64-gnu": "1.79.0", + "@oxlint/binding-linux-arm64-musl": "1.79.0", + "@oxlint/binding-linux-ppc64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-musl": "1.79.0", + "@oxlint/binding-linux-s390x-gnu": "1.79.0", + "@oxlint/binding-linux-x64-gnu": "1.79.0", + "@oxlint/binding-linux-x64-musl": "1.79.0", + "@oxlint/binding-openharmony-arm64": "1.79.0", + "@oxlint/binding-win32-arm64-msvc": "1.79.0", + "@oxlint/binding-win32-ia32-msvc": "1.79.0", + "@oxlint/binding-win32-x64-msvc": "1.79.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } } }, "node_modules/p-map": { @@ -6816,46 +4087,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6876,16 +4107,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -6915,16 +4136,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -6941,28 +4152,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pyodide": { "version": "314.0.0", "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-314.0.0.tgz", @@ -7092,13 +4281,6 @@ "react": "^19.1.1" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/react-resizable-panels": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.0.16.tgz", @@ -7159,81 +4341,6 @@ "node": ">=8.10.0" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rolldown": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", @@ -7275,228 +4382,15 @@ "resolved": "ruff", "link": true }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shared": { - "resolved": "shared", - "link": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "resolved": "shared", + "link": true }, "node_modules/smol-toml": { "version": "1.4.1", @@ -7526,167 +4420,6 @@ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -7769,32 +4502,6 @@ "node": ">=8.0" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7809,97 +4516,6 @@ "resolved": "ty", "link": true }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "name": "@typescript/typescript6", "version": "6.0.2", @@ -7914,90 +4530,6 @@ "tsc6": "bin/tsc6" } }, - "node_modules/typescript-eslint": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", - "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.66.0", - "@typescript-eslint/parser": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -8471,121 +5003,6 @@ "node": ">=18" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", @@ -8607,42 +5024,6 @@ } } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "ruff": { "name": "ruff-playground", "version": "0.0.0", diff --git a/playground/package.json b/playground/package.json index ec15c9f22c..afb3d9341e 100644 --- a/playground/package.json +++ b/playground/package.json @@ -3,13 +3,16 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "npm": ">=11.10.0" + }, "scripts": { "check": "npm run dev:wasm && npm run lint && npm run tsc", "dev:wasm": "npm run dev:wasm --workspace ty-playground && npm run dev:wasm --workspace ruff-playground", "dev:build": "npm run dev:build --workspace ty-playground && npm run dev:build --workspace ruff-playground", "fmt": "prettier --cache -w .", "fmt:check": "prettier --cache --check .", - "lint": "eslint --cache --ext .ts,.tsx ruff/src ty/src", + "lint": "oxlint ruff/src ty/src", "tsc": "tsc" }, "workspaces": [ @@ -21,20 +24,15 @@ "trailingComma": "all" }, "devDependencies": { - "@eslint/js": "^9.21.0", "@tailwindcss/vite": "^4.2.2", "@types/react": "^19.0.11", "@types/react-dom": "^19.0.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^9.22.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-react": "^7.31.11", - "eslint-plugin-react-hooks": "^7.0.0", + "oxlint": "^1.79.0", "prettier": "^3.5.3", "tailwindcss": "^4.0.14", "typescript": "npm:@typescript/typescript6@^6.0.2", - "typescript-eslint": "^8.26.1", "vite": "^8.0.0", "wasm-pack": "^0.15.0" } diff --git a/playground/ruff/package.json b/playground/ruff/package.json index a89da89c4e..15c1b67783 100644 --- a/playground/ruff/package.json +++ b/playground/ruff/package.json @@ -4,14 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "prebuild": "npm run build:wasm", - "build": "vite build", + "build": "npm run build:wasm && vite build", "build:wasm": "wasm-pack build ../../crates/ruff_wasm --target web --out-dir ../../playground/ruff/ruff_wasm", "dev:wasm": "wasm-pack build ../../crates/ruff_wasm --dev --target web --out-dir ../../playground/ruff/ruff_wasm", - "predev:build": "npm run dev:wasm", - "dev:build": "vite build", - "prestart": "npm run dev:wasm", - "start": "vite", + "dev:build": "npm run dev:wasm && vite build", + "start": "npm run dev:wasm && vite", "preview": "vite preview" }, "dependencies": { diff --git a/playground/ruff/src/Editor/Chrome.tsx b/playground/ruff/src/Editor/Chrome.tsx index a3d84d4a3e..8eb94535ec 100644 --- a/playground/ruff/src/Editor/Chrome.tsx +++ b/playground/ruff/src/Editor/Chrome.tsx @@ -71,7 +71,7 @@ export default function Chrome() { setRevision(1); }) .catch((error) => { - // eslint-disable-next-line no-console + // oxlint-disable-next-line no-console console.error("Failed to initialize playground.", error); }); } diff --git a/playground/ruff/src/Editor/SettingsEditor.tsx b/playground/ruff/src/Editor/SettingsEditor.tsx index 3087848d0a..25bc2656e1 100644 --- a/playground/ruff/src/Editor/SettingsEditor.tsx +++ b/playground/ruff/src/Editor/SettingsEditor.tsx @@ -91,7 +91,7 @@ export default function SettingsEditor({ model.setValue(JSON.stringify(cleansed, null, 4)); } catch (e) { // Turned out to not be TOML after all. - // eslint-disable-next-line no-console + // oxlint-disable-next-line no-console console.warn("Failed to parse settings as TOML", e); } }); diff --git a/playground/shared/src/ShareButton.tsx b/playground/shared/src/ShareButton.tsx index 1da2b27a45..6a3816d3dc 100644 --- a/playground/shared/src/ShareButton.tsx +++ b/playground/shared/src/ShareButton.tsx @@ -42,7 +42,7 @@ export default function ShareButton({ break; } } catch (error) { - // eslint-disable-next-line no-console + // oxlint-disable-next-line no-console console.error("Failed to share playground.", error); return "failed"; } diff --git a/playground/ty/package.json b/playground/ty/package.json index e96e136b79..c7d4ff2b27 100644 --- a/playground/ty/package.json +++ b/playground/ty/package.json @@ -4,14 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "prebuild": "npm run build:wasm", - "build": "vite build", + "build": "npm run build:wasm && vite build", "build:wasm": "wasm-pack build ../../crates/ty_wasm --target web --out-dir ../../playground/ty/ty_wasm", "dev:wasm": "wasm-pack build ../../crates/ty_wasm --dev --target web --out-dir ../../playground/ty/ty_wasm", - "predev:build": "npm run dev:wasm", - "dev:build": "vite build", - "prestart": "npm run dev:wasm", - "start": "vite", + "dev:build": "npm run dev:wasm && vite build", + "start": "npm run dev:wasm && vite", "preview": "vite preview" }, "dependencies": { diff --git a/playground/ty/src/Editor/Chrome.tsx b/playground/ty/src/Editor/Chrome.tsx index 8c3642bad5..02d5141352 100644 --- a/playground/ty/src/Editor/Chrome.tsx +++ b/playground/ty/src/Editor/Chrome.tsx @@ -355,7 +355,7 @@ function useCheckResult( } // Monaco document edits mutate the workspace in place. The deferred // revision is an invalidation token for this memoized check. - // eslint-disable-next-line react-hooks/exhaustive-deps + // oxlint-disable-next-line react/exhaustive-deps }, [ files, workspace, diff --git a/playground/ty/src/Editor/Editor.tsx b/playground/ty/src/Editor/Editor.tsx index 89abb3de58..08537d918e 100644 --- a/playground/ty/src/Editor/Editor.tsx +++ b/playground/ty/src/Editor/Editor.tsx @@ -367,9 +367,9 @@ class PlaygroundServer provideSignatureHelp( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _context: languages.SignatureHelpContext, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -392,7 +392,7 @@ class PlaygroundServer provideDocumentHighlights( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -414,7 +414,7 @@ class PlaygroundServer provideInlayHints( model: editor.ITextModel, range: Range, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -477,9 +477,9 @@ class PlaygroundServer } resolveInlayHint( - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _hint: languages.InlayHint, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { return undefined; @@ -631,9 +631,9 @@ class PlaygroundServer provideCodeActions( model: editor.ITextModel, range: Range, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _context: languages.CodeActionContext, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { const actions: languages.CodeAction[] = []; @@ -693,9 +693,9 @@ class PlaygroundServer provideHover( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars context?: languages.HoverContext | undefined, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -721,7 +721,7 @@ class PlaygroundServer provideTypeDefinition( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -740,7 +740,7 @@ class PlaygroundServer provideDeclaration( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -759,7 +759,7 @@ class PlaygroundServer provideDefinition( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -778,9 +778,9 @@ class PlaygroundServer provideReferences( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars context: languages.ReferenceContext, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -871,7 +871,7 @@ class PlaygroundServer resolveRenameLocation( model: editor.ITextModel, position: Position, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); @@ -900,7 +900,7 @@ class PlaygroundServer model: editor.ITextModel, position: Position, newName: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars _token: CancellationToken, ): languages.ProviderResult { const fileHandle = this.getFileHandleForModel(model); diff --git a/playground/ty/src/Playground.tsx b/playground/ty/src/Playground.tsx index 5bdb44077a..154bbcbd66 100644 --- a/playground/ty/src/Playground.tsx +++ b/playground/ty/src/Playground.tsx @@ -60,7 +60,7 @@ export default function Playground() { // This is safe as this is only called once on startup. // We need useRef to avoid duplicate initialization when // running locally due to react rendering - // eslint-disable-next-line + // oxlint-disable-next-line react/refs const sessionPromise = sessionPromiseRef.current; const fileName = useMemo(() => { @@ -472,7 +472,7 @@ function filesReducer( : state.order[position + 1]) ?? null; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line no-unused-vars const { [id]: _metadata, ...metadata } = state.metadata; return { @@ -772,7 +772,7 @@ function restoreWorkspace( const workspace = session.workspace; let hasSettings = false; - // eslint-disable-next-line prefer-const + // oxlint-disable-next-line prefer-const for (let [name, content] of Object.entries(state.files)) { let handle = null; diff --git a/pyproject.toml b/pyproject.toml index 6afdf773c2..5ad37daa95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,24 +70,113 @@ strip = true [dependency-groups] dev = [ + "astral-dev-toolchain-cargo-codspeed>=5.0.1", + "astral-dev-toolchain-cargo-fuzz>=0.13.2", + "astral-dev-toolchain-cargo-insta>=1.48.0", + "astral-dev-toolchain-cargo-nextest>=0.9.143", + "astral-dev-toolchain-cargo-shear>=1.13.4", + "astral-dev-toolchain-hyperfine>=1.20.0", "prek==0.4.12", ] -docs = [ +basedpython-docs = [ "basedpython-pygments", "zensical", ] release = [ "rooster==0.1.1", ] +docs = [ + "mkdocs>=1.6.1", + "mkdocs-github-admonitions-plugin>=0.1.1", + "mkdocs-llmstxt>=0.2.0", + "mkdocs-material>=9.7.7", + "mkdocs-redirects>=1.2.3", + "pyyaml>=6.0.3", +] +# The compatibility tests use the Ruff binary built from this checkout. +ruff-lsp-test = [ + "lsprotocol>=2023.0.0", + "packaging>=23.1", + # ruff-lsp uses the pygls 1.x server API. + "pygls>=1.1.0,<2", + "pytest>=9.0.3,<10", + "pytest-asyncio>=0.21.2", + "python-lsp-jsonrpc>=1.0.0", + "typing-extensions>=4.7.1", +] +ty-ecosystem = [ + "ecosystem-analyzer", +] +typeshed-docstrings = [ + "docstring-adder", +] +typeshed-formatting = [ + "black", +] -[tool.uv.sources] -# the pygments lexer that highlights `by` code blocks in the docs -basedpython-pygments = { path = "python/basedpython-pygments" } +[tool.uv] +no-build = true +# CI installs the local ecosystem runner and tools pinned to Git revisions. +no-binary-package = [ + # a local path dependency, so it is always built from source and `no-build` above + # would otherwise refuse it + "basedpython-pygments", + "black", + "docstring-adder", + "ecosystem-analyzer", + "mypy-primer", + "ruff-ecosystem", + "typeshed-client", +] +# Pin the isolated build environment for source-build exceptions. +build-constraint-dependencies = [ + "flit-core==4.0.2", + "hatch-fancy-pypi-readme==25.1.0", + "hatch-vcs==0.5.0", + "hatchling==1.32.0", + "packaging==26.3", + "pathspec==1.1.1", + "pluggy==1.6.0", + "setuptools==84.0.0", + "setuptools-scm==10.2.1", + "tomli==2.4.1; python_version < '3.11'", + "tomlkit==0.15.1", + "trove-classifiers==2026.6.1.19", + "typing-extensions==4.16.0; python_version < '3.11'", + "uv-build==0.12.3", + "vcs-versioning==2.2.4", + "wheel==0.47.0", +] +exclude-newer = "P7D" + +[tool.uv.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" [tool.uv.dependency-groups] dev = { requires-python = ">=3.12" } +basedpython-docs = { requires-python = ">=3.12" } docs = { requires-python = ">=3.12" } release = { requires-python = ">=3.12" } +ruff-lsp-test = { requires-python = ">=3.12" } +ty-ecosystem = { requires-python = ">=3.13" } +typeshed-docstrings = { requires-python = ">=3.10" } +typeshed-formatting = { requires-python = ">=3.10" } + +[tool.uv.sources] +# the pygments lexer that highlights `by` code blocks in the docs +basedpython-pygments = { path = "python/basedpython-pygments" } +docstring-adder = { git = "https://github.com/astral-sh/docstring-adder.git" } +# Switch to a released version once it includes +# https://github.com/psf/black/commit/f665703258bfa1916f8a6c1cbab0bc384b90cbac. +black = { git = "https://github.com/psf/black.git" } +# Keep scripts/setup_primer_project.py and its lockfile synchronized with +# ecosystem-analyzer's mypy-primer pin when updating its locked revision. +ecosystem-analyzer = { git = "https://github.com/astral-sh/ecosystem-analyzer" } [tool.ruff] target-version = "py38" @@ -106,29 +195,109 @@ extend-exclude = [ ] [tool.ruff.lint] +preview = true select = [ - "E", # pycodestyle (error) - "F", # pyflakes - "B", # bugbear - "B9", - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "I", # isort - "UP", # pyupgrade - "PIE", # flake8-pie - "PGH", # pygrep-hooks - "PYI", # flake8-pyi - "RUF", - "S602", # flake8-bandit: subprocess-popen-with-shell-equals-true + # Categories + "correctness", + "suspicious", + "complexity", + "performance", + "style", + + # E # pycodestyle (error) + "ambiguous-class-name", + "ambiguous-function-name", + "ambiguous-variable-name", + "lambda-assignment", + "module-import-not-at-top-of-file", + "multiple-imports-on-one-line", + "multiple-leading-hashes-for-block-comment", + "no-indented-block", + "none-comparison", + "not-in-test", + "not-is-test", + "true-false-comparison", + "type-comparison", + "unexpected-indentation", + + # F # pyflakes + "forward-annotation-syntax-error", + "undefined-local-with-import-star", + "undefined-local-with-import-star-usage", + + # B # bugbear + "batched-without-explicit-strict", + "class-as-data-structure", + "map-without-explicit-strict", + "no-explicit-stacklevel", + "raise-without-from-inside-except", + "re-sub-positional-args", + "unused-loop-control-variable", + "zip-without-explicit-strict", + + # C4 # flake8-comprehensions + "unnecessary-comprehension", + + # SIM # flake8-simplify + "compare-with-tuple", + "dict-get-with-none-default", + "if-else-block-instead-of-dict-lookup", + "if-else-block-instead-of-if-exp", + "if-expr-with-twisted-arms", + "suppressible-exception", + "uncapitalized-environment-variables", + "yoda-conditions", + + # I # isort + "missing-required-import", + + # UP # pyupgrade + "convert-typed-dict-functional-to-class", + "redundant-open-modes", + + # PGH # pygrep-hooks + "blanket-noqa", + "blanket-type-ignore", + + # PYI # flake8-pyi + "argument-default-in-stub", + "collections-named-tuple", + "docstring-in-stub", + "numeric-literal-too-long", + "redundant-literal-union", + "string-or-bytes-too-long", + "typed-argument-default-in-stub", + "unsupported-method-call-on-all", + + # RUF + "ambiguous-unicode-character-comment", + "ambiguous-unicode-character-docstring", + "ambiguous-unicode-character-string", + "asyncio-dangling-task", + "collection-literal-concatenation", + "incorrectly-parenthesized-tuple-in-subscript", + "non-empty-init-module", + "noqa-comments", + "parenthesize-chained-operators", + "rule-codes-in-selectors", + "unnecessary-assign-before-yield", + "unraw-re-pattern", + "unused-async", + "used-dummy-variable", + + # S + "subprocess-popen-with-shell-equals-true", ] ignore = [ # only relevant if you run a script with `python -0`, # which seems unlikely for any of the scripts in this repo - "B011", + "assert-false", # Leave it to the formatter to split long lines and # the judgement of all of us. - "E501" + "line-too-long", + # This is often more readable in our scripts. + "repeated-append", ] [tool.ruff.lint.per-file-ignores] @@ -137,6 +306,14 @@ ignore = [ # be correct at runtime but unique to this lexer among every lexer pygments has "python/basedpython-pygments/**" = ["RUF012"] +# a lexer's keyword tables are its module-level api, which is what the module is +# for — there is nowhere else for them to live +"python/basedpython-pygments/basedpython_pygments/__init__.py" = ["RUF067"] + +# developer scripts read a subprocess's outcome themselves, catch whatever a tool +# throws so one failure does not end a sweep, and touch the filesystem directly +"scripts/**" = ["S603", "PLW1510", "BLE001", "ASYNC240", "ISC004"] + [tool.ruff.lint.isort] required-imports = ["from __future__ import annotations"] combine-as-imports = true @@ -144,6 +321,7 @@ combine-as-imports = true [tool.ruff.per-file-target-version] "crates/ty_python_semantic/mdtest.py" = "py310" "crates/ty_vendored/ty_extensions/*.pyi" = "py312" +"scripts/*.py" = "py312" [tool.black] force-exclude = ''' @@ -166,6 +344,7 @@ version_files = [ "pyproject.toml", # Might become unneeded once Markdown formatting is stabilized. "docs/formatter.md", + "docs/installation.md", "docs/integrations.md", "docs/tutorial.md", "crates/ruff/Cargo.toml", diff --git a/python/basedpython-pygments/README.md b/python/basedpython-pygments/README.md index d3055e96d8..e0877f805a 100644 --- a/python/basedpython-pygments/README.md +++ b/python/basedpython-pygments/README.md @@ -4,11 +4,11 @@ a [pygments](https://pygments.org) lexer for basedpython, so that ```` ```by ``` blocks in the documentation are syntax highlighted pygments picks the lexer up from an entry point, so nothing needs to reference it — -installing the package is enough. it is pulled into the `docs` dependency group of the +installing the package is enough. it is pulled into the `basedpython-docs` dependency group of the repository root, which is what the docs build installs ```sh -uv sync --group docs --no-install-project +uv sync --group basedpython-docs --no-install-project uv run --no-sync zensical serve ``` diff --git a/python/py-fuzzer/pyproject.toml b/python/py-fuzzer/pyproject.toml index 1e173d0e2e..b9613904da 100644 --- a/python/py-fuzzer/pyproject.toml +++ b/python/py-fuzzer/pyproject.toml @@ -1,6 +1,7 @@ [project] name = "py-fuzzer" version = "0.0.0" +description = "Run Ruff or ty on randomly generated Python source files." readme = "README.md" requires-python = ">=3.12" dependencies = [ @@ -15,14 +16,14 @@ dependencies = [ fuzz = "fuzz:main" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["flit-core>=3.12,<4"] +build-backend = "flit_core.buildapi" [dependency-groups] dev = ["mypy", "ruff", "ty"] -[tool.hatch.build.targets.wheel] -include = ["fuzz.py"] +[tool.flit.module] +name = "fuzz" [tool.mypy] files = "fuzz.py" @@ -82,6 +83,13 @@ unfixable = [ combine-as-imports = true split-on-trailing-comma = false +[tool.uv] +no-build = true +no-binary-package = ["py-fuzzer"] +# Pin the isolated build environment for source-build exceptions. +build-constraint-dependencies = ["flit-core==3.12.0"] +exclude-newer = "P7D" + # these files come from upstream ruff, where a call written for its effect alone is # ordinary style. writing the discard out at each site would put a conflict in every # one of them on the next upstream sync, so the rule is off for them rather than for diff --git a/python/py-fuzzer/uv.lock b/python/py-fuzzer/uv.lock index 096049a945..8d4c83ab63 100644 --- a/python/py-fuzzer/uv.lock +++ b/python/py-fuzzer/uv.lock @@ -2,6 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.12" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[manifest] +build-constraints = [{ name = "flit-core", specifier = "==3.12.0" }] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -110,11 +117,11 @@ dev = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] diff --git a/python/ruff-ecosystem/pyproject.toml b/python/ruff-ecosystem/pyproject.toml index 8b8d2deea0..c260c92be6 100644 --- a/python/ruff-ecosystem/pyproject.toml +++ b/python/ruff-ecosystem/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["uv_build>=0.12.3,<0.13"] +build-backend = "uv_build" [project] name = "ruff-ecosystem" @@ -12,5 +12,19 @@ dependencies = ["unidiff==1.0.0", "tomli_w==1.2.0", "tomli==2.4.1"] ruff-ecosystem = "ruff_ecosystem.cli:entrypoint" [tool.ruff.lint] -ignore = ["T100"] +ignore = [ + # We're not worried enough about briefly blocking the event loop to add more dependencies. + "blocking-path-method-in-async-function", + "debugger", + "repeated-append", +] preview = true + +[tool.uv] +no-build = true +no-binary-package = ["ruff-ecosystem"] +# Pin the isolated build environment for source-build exceptions. +build-constraint-dependencies = ["uv-build==0.12.3"] + +[tool.uv.build-backend] +module-root = "" diff --git a/python/ruff-ecosystem/ruff_ecosystem/projects.py b/python/ruff-ecosystem/ruff_ecosystem/projects.py index 2e86360817..40166cb089 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/projects.py +++ b/python/ruff-ecosystem/ruff_ecosystem/projects.py @@ -30,9 +30,12 @@ class Project(Serializable): """ repo: Repository + # ruff: disable[unnecessary-lambda] False positive on types defined later in the file + # See https://github.com/astral-sh/ruff/issues/24704 check_options: CheckOptions = field(default_factory=lambda: CheckOptions()) format_options: FormatOptions = field(default_factory=lambda: FormatOptions()) config_overrides: ConfigOverrides = field(default_factory=lambda: ConfigOverrides()) + # ruff: enable[unnecessary-lambda] def with_preview_enabled(self: Self) -> Self: return type(self)( @@ -72,7 +75,7 @@ def __post_init__(self): @cache -def rule_name_to_code(executable: Path) -> dict[str, str]: +def rule_name_to_code(executable: Path) -> dict[str, str | None]: rules = json.loads( check_output( [executable, "rule", "--all", "--output-format", "json"], @@ -83,7 +86,7 @@ def rule_name_to_code(executable: Path) -> dict[str, str]: def normalize_rule_selectors( - config: dict[str, Any], rule_names: dict[str, str] + config: dict[str, Any], rule_names: dict[str, str | None] ) -> None: selector_lists: list[list[Any]] = [] @@ -104,12 +107,14 @@ def normalize_rule_selectors( ) for selectors in selector_lists: - selectors[:] = [ - rule_names.get(selector, selector) - if isinstance(selector, str) - else selector - for selector in selectors - ] + normalized = [] + for selector in selectors: + if not isinstance(selector, str): + normalized.append(selector) + # Rules without codes have no selector that works outside preview mode. + elif (code := rule_names.get(selector, selector)) is not None: + normalized.append(code) + selectors[:] = normalized @dataclass(frozen=True) @@ -147,7 +152,7 @@ def patch_config( self, dirpath: Path, preview: bool, - rule_names: dict[str, str], + rule_names: dict[str, str | None], ) -> None: """ Temporarily patch the Ruff configuration file in the given directory. diff --git a/python/ruff-ecosystem/ruff_ecosystem/types.py b/python/ruff-ecosystem/ruff_ecosystem/types.py index e3e2fcf365..a73614b4c4 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/types.py +++ b/python/ruff-ecosystem/ruff_ecosystem/types.py @@ -1,6 +1,5 @@ from __future__ import annotations -import abc import dataclasses import difflib from collections.abc import Iterable, Iterator, Sequence @@ -11,7 +10,7 @@ from ruff_ecosystem.projects import ClonedRepository, Project -class Serializable(abc.ABC): +class Serializable: """ Allows serialization of content by casting to a JSON-compatible type. """ diff --git a/python/ruff-ecosystem/uv.lock b/python/ruff-ecosystem/uv.lock new file mode 100644 index 0000000000..1f71b96d7c --- /dev/null +++ b/python/ruff-ecosystem/uv.lock @@ -0,0 +1,95 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[manifest] +build-constraints = [{ name = "uv-build", specifier = "==0.12.3" }] + +[[package]] +name = "ruff-ecosystem" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "tomli" }, + { name = "tomli-w" }, + { name = "unidiff" }, +] + +[package.metadata] +requires-dist = [ + { name = "tomli", specifier = "==2.4.1" }, + { name = "tomli-w", specifier = "==1.2.0" }, + { name = "unidiff", specifier = "==1.0.0" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "unidiff" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/48/6ebfbda867e1a07bab3bbffe820e980bff8262c97ff77d1496a4fa15e711/unidiff-1.0.0.tar.gz", hash = "sha256:5e5d5cfab2dc98be819b74747ab7d9f5af8695369ec8710b93f9ab0f0ae6a449", size = 29365, upload-time = "2026-07-25T19:13:59.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/860142913b2fee25c78b3af733054e248c488bd83cf6cfb97969e98e3bcf/unidiff-1.0.0-py3-none-any.whl", hash = "sha256:2e1fb4eebe2354a26a1f3d51efe2e5d504cae5764b98ed8bdbb4e7a000baff28", size = 18279, upload-time = "2026-07-25T19:13:58.797Z" }, +] diff --git a/python/ruff/__main__.py b/python/ruff/__main__.py index 875131923e..741d753fea 100644 --- a/python/ruff/__main__.py +++ b/python/ruff/__main__.py @@ -14,7 +14,7 @@ def _run() -> None: # Avoid emitting a traceback on interrupt try: - completed_process = subprocess.run([ruff, *sys.argv[1:]]) + completed_process = subprocess.run([ruff, *sys.argv[1:]], check=False) except KeyboardInterrupt: sys.exit(2) diff --git a/ruff.schema.json b/ruff.schema.json index 7d52e06bf5..2b01128120 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -446,7 +446,7 @@ ] }, "ignore": { - "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.", + "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire groups (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.\n\nIn preview, categories like `correctness` and `suspicious` can be used\nin addition to rule codes and linter group prefixes.", "type": [ "array", "null" @@ -582,7 +582,7 @@ "deprecated": true }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.", + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "object", "null" @@ -691,7 +691,7 @@ ] }, "select": { - "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.", + "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire groups (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.\n\nIn preview, categories like `correctness` and `suspicious` can be used\nin addition to rule codes and linter group prefixes.", "type": [ "array", "null" @@ -2479,7 +2479,7 @@ ] }, "ignore": { - "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.", + "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire groups (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.\n\nIn preview, categories like `correctness` and `suspicious` can be used\nin addition to rule codes and linter group prefixes.", "type": [ "array", "null" @@ -2540,7 +2540,7 @@ ] }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.", + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "object", "null" @@ -2637,7 +2637,7 @@ ] }, "select": { - "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.", + "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire groups (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.\n\nIn preview, categories like `correctness` and `suspicious` can be used\nin addition to rule codes and linter group prefixes.", "type": [ "array", "null" @@ -4585,6 +4585,7 @@ "UP045", "UP046", "UP047", + "UP048", "UP049", "UP05", "UP050", @@ -4746,11 +4747,13 @@ "complex-assignment-in-stub", "complex-if-statement-in-stub", "complex-structure", + "complexity", "constant-imported-as-non-constant", "continue-in-finally", "continue-outside-loop", "convert-named-tuple-functional-to-class", "convert-typed-dict-functional-to-class", + "correctness", "create-subprocess-in-async-function", "custom-type-var-for-self", "dataclass-enum", @@ -4846,6 +4849,7 @@ "for-loop-writes", "format-in-get-text-func-call", "format-literals", + "formatting", "forward-annotation-syntax-error", "fromisoformat-replace-z", "function-call-in-dataclass-default-argument", @@ -5180,6 +5184,7 @@ "pass-statement-stub-body", "patch-version-comparison", "path-constructor-current-directory", + "pedantic", "pep484-style-positional-only-parameter", "percent-format-expected-mapping", "percent-format-expected-sequence", @@ -5190,6 +5195,7 @@ "percent-format-positional-count-mismatch", "percent-format-star-requires-sequence", "percent-format-unsupported-format-character", + "performance", "post-init-default", "potential-index-error", "print", @@ -5211,6 +5217,7 @@ "pytest-erroneous-use-fixtures-on-fixture", "pytest-extraneous-scope-function", "pytest-fail-without-message", + "pytest-fixture-autouse", "pytest-fixture-finalizer-callback", "pytest-fixture-incorrect-parentheses-style", "pytest-fixture-param-without-value", @@ -5279,6 +5286,7 @@ "replace-universal-newlines", "request-with-no-cert-validation", "request-without-timeout", + "restriction", "return-in-generator", "return-in-init", "return-in-try-except-finally", @@ -5291,6 +5299,7 @@ "runtime-cast-value", "runtime-import-in-type-checking-block", "runtime-string-union", + "security", "self-assigning-variable", "self-or-cls-assignment", "set-attr-with-constant", @@ -5336,6 +5345,7 @@ "string-or-bytes-too-long", "strip-with-multi-characters", "stub-body-multiple-statements", + "style", "subclass-builtin", "subprocess-popen-preexec-fn", "subprocess-popen-with-shell-equals-true", @@ -5349,6 +5359,7 @@ "superfluous-else-return", "suppressible-exception", "surrounding-whitespace", + "suspicious", "suspicious-eval-usage", "suspicious-ftp-lib-usage", "suspicious-ftplib-import", @@ -5573,6 +5584,7 @@ "verbose-raise", "wait-for-process-in-async-function", "weak-cryptographic-key", + "while-one", "whitespace-after-decorator", "whitespace-after-open-bracket", "whitespace-before-close-bracket", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7243604323..b73c15e3c4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.0" diff --git a/scripts/Dockerfile.ecosystem b/scripts/Dockerfile.ecosystem deleted file mode 100644 index d671b20c1e..0000000000 --- a/scripts/Dockerfile.ecosystem +++ /dev/null @@ -1,34 +0,0 @@ -# [crater](https://github.com/rust-lang/crater)-inspired check that tests against a large number of -# projects, mainly from https://github.com/akx/ruff-usage-aggregate. -# -# We run this in a Docker container as Ruff isn't designed for untrusted inputs. -# -# Either download https://github.com/akx/ruff-usage-aggregate/blob/master/data/known-github-tomls.jsonl as -# `github_search.jsonl` or follow the instructions in the README to scrape your own dataset. -# -# Setup: -# ``` -# apt-get install musl-tools # or corresponding command to install musl on your platform, e.g. `yay musl` -# rustup target add x86_64-unknown-linux-musl -# ``` -# From the project root: -# ``` -# cargo build --target x86_64-unknown-linux-musl -# docker buildx build -f scripts/Dockerfile.ecosystem -t ruff-ecosystem-checker --load . -# docker run --rm -v ./target/x86_64-unknown-linux-musl/debug/ruff:/app/ruff-new -v ./ruff-old:/app/ruff-old ruff-ecosystem-checker -# ``` -# You can customize this, e.g. cache the git checkouts, a custom json file and a glibc build: -# ``` -# docker run -v ./target/debug/ruff:/app/ruff-new -v ./ruff-old:/app/ruff-old -v ./target/checkouts:/app/checkouts \ -# -v ./github_search.jsonl:/app/github_search.jsonl --rm ruff-ecosystem-checker \ -# python check_ecosystem.py --verbose ruff-new ruff-old --projects github_search.jsonl --checkouts checkouts \ -# > target/ecosystem-ci.txt -# ``` - -FROM python:3.11 -RUN mkdir /app -WORKDIR /app -ADD scripts/check_ecosystem.py check_ecosystem.py -ADD github_search.jsonl github_search.jsonl - -CMD ["python", "check_ecosystem.py", "--verbose", "--projects", "github_search.jsonl", "ruff-new", "ruff-old"] diff --git a/scripts/_utils.py b/scripts/_utils.py deleted file mode 100644 index 6807c74a91..0000000000 --- a/scripts/_utils.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -ROOT_DIR = Path(__file__).resolve().parent.parent - - -def dir_name(linter_name: str) -> str: - return linter_name.replace("-", "_") - - -def pascal_case(linter_name: str) -> str: - """Convert from snake-case to PascalCase.""" - return "".join(word.title() for word in linter_name.split("-")) - - -def snake_case(name: str) -> str: - """Convert from PascalCase to snake_case.""" - return "".join( - f"_{word.lower()}" if word.isupper() else word for word in name - ).lstrip("_") - - -def get_indent(line: str) -> str: - return re.match(r"^\s*", line).group() # type: ignore[union-attr, ty:unresolved-attribute] diff --git a/scripts/add_plugin.py b/scripts/add_plugin.py index 9a435b0ff5..b332f2371d 100755 --- a/scripts/add_plugin.py +++ b/scripts/add_plugin.py @@ -1,4 +1,23 @@ #!/usr/bin/env python3 +# +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Generate boilerplate for a new Flake8 plugin. Example usage: @@ -12,8 +31,23 @@ from __future__ import annotations import argparse +import re +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def dir_name(linter_name: str) -> str: + return linter_name.replace("-", "_") + + +def pascal_case(linter_name: str) -> str: + """Convert from snake-case to PascalCase.""" + return "".join(word.title() for word in linter_name.split("-")) + -from _utils import ROOT_DIR, dir_name, get_indent, pascal_case +def get_indent(line: str) -> str: + return re.match(r"^\s*", line).group() # type: ignore[union-attr, ty:unresolved-attribute] def main(*, plugin: str, url: str, prefix_code: str): @@ -54,7 +88,7 @@ def main(*, plugin: str, url: str, prefix_code: str): Ok(()) } } -""" # noqa: UP031 # Using an f-string here is ugly as all the curly parens need to be escaped +""" # ruff: ignore[printf-string-formatting] # Using an f-string here is ugly as all the curly parens need to be escaped % dir_name(plugin), ) diff --git a/scripts/add_plugin.py.lock b/scripts/add_plugin.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/add_plugin.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/add_rule.py b/scripts/add_rule.py index 87aa9d89a2..eab8b005a6 100755 --- a/scripts/add_rule.py +++ b/scripts/add_rule.py @@ -1,4 +1,23 @@ #!/usr/bin/env python3 +# +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Generate boilerplate for a new rule. Example usage: @@ -7,19 +26,41 @@ --name PreferListBuiltin \ --prefix PIE \ --code 807 \ - --linter flake8-pie + --linter flake8-pie \ + --category pedantic """ from __future__ import annotations import argparse +import re import subprocess from pathlib import Path -from _utils import ROOT_DIR, dir_name, get_indent, pascal_case, snake_case +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def dir_name(linter_name: str) -> str: + return linter_name.replace("-", "_") + + +def pascal_case(linter_name: str) -> str: + """Convert from snake-case to PascalCase.""" + return "".join(word.title() for word in linter_name.split("-")) + + +def snake_case(name: str) -> str: + """Convert from PascalCase to snake_case.""" + return "".join( + f"_{word.lower()}" if word.isupper() else word for word in name + ).lstrip("_") -def main(*, name: str, prefix: str, code: str, linter: str): +def get_indent(line: str) -> str: + return re.match(r"^\s*", line).group() # type: ignore[union-attr, ty:unresolved-attribute] + + +def main(*, name: str, prefix: str, code: str, linter: str, category: str): """Generate boilerplate for a new rule.""" # Create a test fixture. filestem = f"{prefix}{code}" if linter != "pylint" else snake_case(name) @@ -97,6 +138,7 @@ def main(*, name: str, prefix: str, code: str, linter: str): use crate::Violation; use crate::checkers::ast::Checker; +use crate::codes::Category; /// ## What it does /// @@ -110,7 +152,7 @@ def main(*, name: str, prefix: str, code: str, linter: str): /// ```python /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +#[violation_metadata(preview_since = "NEXT_RUFF_VERSION", category = Category::{pascal_case(category)})] pub(crate) struct {name}; impl Violation for {name} {{ @@ -139,7 +181,7 @@ def main(*, name: str, prefix: str, code: str, linter: str): lines.append(line) variant = pascal_case(linter) - linter_name = linter.split(" ")[0].replace("-", "_") + linter_name = linter.split(" ", maxsplit=1)[0].replace("-", "_") rule = f"""rules::{linter_name}::rules::{name}""" lines.append( " " * 8 + f"""({variant}, "{code}") => {rule},\n""", @@ -155,7 +197,7 @@ def main(*, name: str, prefix: str, code: str, linter: str): def _rustfmt(path: str | Path): - subprocess.run(["rustfmt", path]) + subprocess.run(["rustfmt", path], check=True) if __name__ == "__main__": @@ -163,7 +205,8 @@ def _rustfmt(path: str | Path): description="Generate boilerplate for a new rule.", epilog=( "python scripts/add_rule.py " - "--name PreferListBuiltin --code PIE807 --linter flake8-pie" + "--name PreferListBuiltin --code PIE807 --linter flake8-pie " + "--category pedantic" ), ) parser.add_argument( @@ -193,6 +236,28 @@ def _rustfmt(path: str | Path): required=True, help="The source with which the check originated (e.g., 'flake8-pie').", ) + parser.add_argument( + "--category", + choices=( + "correctness", + "suspicious", + "complexity", + "performance", + "style", + "security", + "formatting", + "pedantic", + "restriction", + ), + required=True, + help="The semantic category for the rule.", + ) args = parser.parse_args() - main(name=args.name, prefix=args.prefix, code=args.code, linter=args.linter) + main( + name=args.name, + prefix=args.prefix, + code=args.code, + linter=args.linter, + category=args.category, + ) diff --git a/scripts/add_rule.py.lock b/scripts/add_rule.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/add_rule.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index 6a1171ac92..7a5ad8471c 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scripts" -version = "0.16.2" +version = "0.16.6" description = "" authors = ["Charles Marsh "] @@ -23,3 +23,7 @@ linter = [ "pylint", "isort", ] + +[tool.uv] +no-build = true +exclude-newer = "P7D" diff --git a/scripts/benchmarks/uv.lock b/scripts/benchmarks/uv.lock index 842ff01a1c..8dc6816606 100644 --- a/scripts/benchmarks/uv.lock +++ b/scripts/benchmarks/uv.lock @@ -1,13 +1,18 @@ version = 1 -requires-python = ">=3.13" +revision = 3 +requires-python = ">=3.14" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" [[package]] name = "astroid" version = "3.3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/f6/7725404e3dcaeafe695d4fe42ad99eefbcba4cad2c83ca122e6b439c9f96/astroid-3.3.7.tar.gz", hash = "sha256:29fe1df7ef64dc17a54dbfad67b40b445340fcdba7c4012e7ecc9270c9b2f5b6", size = 398091 } +sdist = { url = "https://files.pythonhosted.org/packages/20/f6/7725404e3dcaeafe695d4fe42ad99eefbcba4cad2c83ca122e6b439c9f96/astroid-3.3.7.tar.gz", hash = "sha256:29fe1df7ef64dc17a54dbfad67b40b445340fcdba7c4012e7ecc9270c9b2f5b6", size = 398091, upload-time = "2024-12-21T14:44:02.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0b/ef3a51abbf2064ac50447a02d1cd14c1f008590e96f780042c21108b6b56/astroid-3.3.7-py3-none-any.whl", hash = "sha256:e1ea2c358a3c760ef583d4963e773100fa2c693b27ed158a1d0e81adb4436903", size = 275125 }, + { url = "https://files.pythonhosted.org/packages/83/0b/ef3a51abbf2064ac50447a02d1cd14c1f008590e96f780042c21108b6b56/astroid-3.3.7-py3-none-any.whl", hash = "sha256:e1ea2c358a3c760ef583d4963e773100fa2c693b27ed158a1d0e81adb4436903", size = 275125, upload-time = "2024-12-21T14:43:59.935Z" }, ] [[package]] @@ -17,9 +22,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483 }, + { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, ] [[package]] @@ -29,14 +34,14 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycodestyle" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/52/65556a5f917a4b273fd1b705f98687a6bd721dbc45966f0f6687e90a18b0/autopep8-2.3.1.tar.gz", hash = "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", size = 92064 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/52/65556a5f917a4b273fd1b705f98687a6bd721dbc45966f0f6687e90a18b0/autopep8-2.3.1.tar.gz", hash = "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", size = 92064, upload-time = "2024-06-23T05:15:55.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/9e/f0beffe45b507dca9d7540fad42b316b2fd1076dc484c9b1f23d9da570d7/autopep8-2.3.1-py2.py3-none-any.whl", hash = "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d", size = 45667 }, + { url = "https://files.pythonhosted.org/packages/ad/9e/f0beffe45b507dca9d7540fad42b316b2fd1076dc484c9b1f23d9da570d7/autopep8-2.3.1-py2.py3-none-any.whl", hash = "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d", size = 45667, upload-time = "2024-06-23T05:15:51.29Z" }, ] [[package]] name = "black" -version = "24.10.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -44,44 +49,46 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986 }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085 }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928 }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875 }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898 }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "dill" version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/43/86fe3f9e130c4137b0f1b50784dd70a5087b911fe07fa81e53e0c4c47fea/dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c", size = 187000 } +sdist = { url = "https://files.pythonhosted.org/packages/70/43/86fe3f9e130c4137b0f1b50784dd70a5087b911fe07fa81e53e0c4c47fea/dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c", size = 187000, upload-time = "2024-09-29T00:03:20.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418 }, + { url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418, upload-time = "2024-09-29T00:03:19.344Z" }, ] [[package]] @@ -93,81 +100,81 @@ dependencies = [ { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/72/e8d66150c4fcace3c0a450466aa3480506ba2cae7b61e100a2613afc3907/flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", size = 48054 } +sdist = { url = "https://files.pythonhosted.org/packages/37/72/e8d66150c4fcace3c0a450466aa3480506ba2cae7b61e100a2613afc3907/flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", size = 48054, upload-time = "2024-08-04T20:32:44.311Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/42/65004373ac4617464f35ed15931b30d764f53cdd30cc78d5aea349c8c050/flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213", size = 57731 }, + { url = "https://files.pythonhosted.org/packages/d9/42/65004373ac4617464f35ed15931b30d764f53cdd30cc78d5aea349c8c050/flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213", size = 57731, upload-time = "2024-08-04T20:32:42.661Z" }, ] [[package]] name = "isort" version = "5.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 } +sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 }, + { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" }, ] [[package]] name = "mccabe" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" version = "4.3.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, ] [[package]] name = "pycodestyle" version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 } +sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232, upload-time = "2024-08-04T20:26:54.576Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 }, + { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" }, ] [[package]] name = "pyflakes" version = "3.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 } +sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload-time = "2024-01-05T00:28:47.703Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 }, + { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, ] [[package]] @@ -183,14 +190,33 @@ dependencies = [ { name = "platformdirs" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/d8/4471b2cb4ad18b4af717918c468209bd2bd5a02c52f60be5ee8a71b5af2c/pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01", size = 1516485 } +sdist = { url = "https://files.pythonhosted.org/packages/81/d8/4471b2cb4ad18b4af717918c468209bd2bd5a02c52f60be5ee8a71b5af2c/pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01", size = 1516485, upload-time = "2024-12-01T18:45:32.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/55/5eaf6c415f6ddb09b9b039278823a8e27fb81ea7a34ec80c6d9223b17f2e/pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a", size = 521873, upload-time = "2024-12-01T18:45:29.733Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/55/5eaf6c415f6ddb09b9b039278823a8e27fb81ea7a34ec80c6d9223b17f2e/pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a", size = 521873 }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] [[package]] name = "scripts" -version = "0.8.4" +version = "0.16.3" source = { virtual = "." } dependencies = [ { name = "autoflake" }, @@ -251,9 +277,9 @@ linter = [ name = "tomlkit" version = "0.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885, upload-time = "2024-08-14T08:19:41.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955, upload-time = "2024-08-14T08:19:40.05Z" }, ] [[package]] @@ -263,7 +289,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907 } +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158 }, + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, ] diff --git a/scripts/build_ruff_pgo.py b/scripts/build_ruff_pgo.py index c9347aeff2..c864aac44d 100644 --- a/scripts/build_ruff_pgo.py +++ b/scripts/build_ruff_pgo.py @@ -3,6 +3,19 @@ # /// script # requires-python = ">=3.11" # dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" # /// from __future__ import annotations @@ -130,6 +143,11 @@ def url(self) -> str: def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--target", help="Host-native Rust target triple") + parser.add_argument( + "--debug", + action="store_true", + help="Use debug builds to validate the complete PGO pipeline", + ) parser.add_argument( "--target-dir", type=Path, @@ -170,7 +188,7 @@ def main(): environment = os.environ.copy() if args.prepare_corpus: corpus = ecosystem_python_files(target_dir / "corpus", environment=environment) - write_corpus_arguments(target_dir, corpus) + _ = write_corpus_arguments(target_dir, corpus) print(f"Prepared {len(corpus)} ecosystem Python files", flush=True) return @@ -203,11 +221,12 @@ def main(): environment.get("RUSTFLAGS"), f"-Cprofile-generate={profile_dir}" ), } - print("Building instrumented release Ruff", flush=True) - run(cargo_command(target), environment=instrumented_environment) + profile = "debug" if args.debug else "release" + print(f"Building instrumented {profile} Ruff", flush=True) + run(cargo_command(target, debug=args.debug), environment=instrumented_environment) binary_name = f"{BINARY_STEM}.exe" if "windows" in target else BINARY_STEM - instrumented_binary = instrumented_target_dir / target / "release" / binary_name + instrumented_binary = instrumented_target_dir / target / profile / binary_name if not instrumented_binary.is_file(): raise RuntimeError(f"Instrumented Ruff binary not found: {instrumented_binary}") @@ -229,10 +248,11 @@ def main(): environment.get("RUSTFLAGS"), f"-Cprofile-use={merged_profile}" ), } - print("Building optimized release Ruff", flush=True) - run(cargo_command(target), environment=optimized_environment) + print(f"Building profile-guided {profile} Ruff", flush=True) + run(cargo_command(target, debug=args.debug), environment=optimized_environment) print( - f"Optimized Ruff: {target_dir / target / 'release' / binary_name}", flush=True + f"Profile-guided Ruff: {target_dir / target / profile / binary_name}", + flush=True, ) @@ -506,11 +526,11 @@ def write_corpus_arguments(target_directory: Path, corpus: list[str]) -> Path: return arguments -def cargo_command(target: str) -> list[str]: +def cargo_command(target: str, *, debug: bool = False) -> list[str]: return [ "cargo", "rustc", - "--release", + *(() if debug else ("--release",)), "--locked", "--package", "ruff", diff --git a/scripts/build_ruff_pgo.py.lock b/scripts/build_ruff_pgo.py.lock new file mode 100644 index 0000000000..5951180dc2 --- /dev/null +++ b/scripts/build_ruff_pgo.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/bump-workspace-crate-versions.py b/scripts/bump-workspace-crate-versions.py index 8c583b8a52..4bcce8ffce 100644 --- a/scripts/bump-workspace-crate-versions.py +++ b/scripts/bump-workspace-crate-versions.py @@ -8,6 +8,19 @@ # /// script # requires-python = ">=3.13" # dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" # /// diff --git a/scripts/bump-workspace-crate-versions.py.lock b/scripts/bump-workspace-crate-versions.py.lock new file mode 100644 index 0000000000..a1f7963bfd --- /dev/null +++ b/scripts/bump-workspace-crate-versions.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/check_by_lexer.py b/scripts/check_by_lexer.py index 10cb9f3a09..2005e5a0c5 100644 --- a/scripts/check_by_lexer.py +++ b/scripts/check_by_lexer.py @@ -17,6 +17,15 @@ run directly, or through `prek` """ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pygments", "basedpython-pygments"] +# +# # the lexer under test is the one in this checkout, not a built copy of it +# [tool.uv.sources] +# basedpython-pygments = { path = "../python/basedpython-pygments", editable = true } +# /// + from __future__ import annotations import re diff --git a/scripts/check_by_lexer.py.lock b/scripts/check_by_lexer.py.lock new file mode 100644 index 0000000000..65221b9388 --- /dev/null +++ b/scripts/check_by_lexer.py.lock @@ -0,0 +1,41 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [ + { name = "basedpython-pygments", editable = "../python/basedpython-pygments" }, + { name = "pygments" }, +] + +[[package]] +name = "basedpython-pygments" +version = "0.0.0" +source = { editable = "../python/basedpython-pygments" } +dependencies = [ + { name = "pygments" }, +] + +[package.metadata] +requires-dist = [{ name = "pygments", specifier = ">=2.19" }] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] diff --git a/scripts/check_docs_formatted.py b/scripts/check_docs_formatted.py index e128a5c35d..0cc3f8bf53 100755 --- a/scripts/check_docs_formatted.py +++ b/scripts/check_docs_formatted.py @@ -1,4 +1,23 @@ #!/usr/bin/env python3 +# +# /// script +# requires-python = ">=3.12" +# dependencies = ["ruff"] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Check code snippets in docs are formatted by Ruff.""" from __future__ import annotations @@ -191,8 +210,8 @@ def _snipped_match(match: Match[str]) -> str: code = format_str(code, extension) except InvalidInput as e: errors.append(CodeBlockError(e)) - except NotImplementedError as e: - raise e + except NotImplementedError: + raise code = textwrap.indent(code, match["indent"] or "") return f"{match['before']}{code}{match['after']}" @@ -289,15 +308,8 @@ def main(argv: Sequence[str] | None = None) -> int: description="Check code snippets in docs are formatted by Ruff.", ) parser.add_argument("--skip-errors", action="store_true") - parser.add_argument("--generate-docs", action="store_true") args = parser.parse_args(argv) - if args.generate_docs: - # Generate docs - from generate_mkdocs import main as generate_docs - - generate_docs() - # Get static docs static_docs = [Path("docs") / f for f in os.listdir("docs") if f.endswith(".md")] diff --git a/scripts/check_docs_formatted.py.lock b/scripts/check_docs_formatted.py.lock new file mode 100644 index 0000000000..56b00b685e --- /dev/null +++ b/scripts/check_docs_formatted.py.lock @@ -0,0 +1,43 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "ruff" }] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] diff --git a/scripts/check_docs_nav.py b/scripts/check_docs_nav.py index be0e003730..347aebc3a4 100644 --- a/scripts/check_docs_nav.py +++ b/scripts/check_docs_nav.py @@ -9,6 +9,11 @@ against the three drifting apart """ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + from __future__ import annotations import re diff --git a/scripts/check_docs_nav.py.lock b/scripts/check_docs_nav.py.lock new file mode 100644 index 0000000000..5951180dc2 --- /dev/null +++ b/scripts/check_docs_nav.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/check_ecosystem.py b/scripts/check_ecosystem.py deleted file mode 100755 index 08f3409ebe..0000000000 --- a/scripts/check_ecosystem.py +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env python3 -""" -**DEPRECATED** This script is being replaced by the ruff-ecosystem package. - - -Check two versions of ruff against a corpus of open-source code. - -Example usage: - - scripts/check_ecosystem.py -""" - -from __future__ import annotations - -import argparse -import asyncio -import difflib -import heapq -import json -import logging -import re -import tempfile -import time -from asyncio.subprocess import PIPE, create_subprocess_exec -from collections.abc import Awaitable -from contextlib import asynccontextmanager, nullcontext -from pathlib import Path -from signal import SIGINT, SIGTERM -from typing import TYPE_CHECKING, NamedTuple, Self, TypeVar, override - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterator, Sequence - -logger = logging.getLogger(__name__) - - -class Repository(NamedTuple): - """A GitHub repository at a specific ref.""" - - org: str - repo: str - ref: str | None - select: str = "" - ignore: str = "" - exclude: str = "" - # Generating fixes is slow and verbose - show_fixes: bool = False - - @asynccontextmanager - async def clone(self: Self, checkout_dir: Path) -> AsyncIterator[str]: - """Shallow clone this repository to a temporary directory.""" - if checkout_dir.exists(): - logger.debug(f"Reusing {self.org}:{self.repo}") - yield await self._get_commit(checkout_dir) - return - - logger.debug(f"Cloning {self.org}:{self.repo}") - git_clone_command = [ - "git", - "clone", - "--config", - "advice.detachedHead=false", - "--quiet", - "--depth", - "1", - "--no-tags", - ] - if self.ref: - git_clone_command.extend(["--branch", self.ref]) - - git_clone_command.extend( - [ - f"https://github.com/{self.org}/{self.repo}", - str(checkout_dir), - ], - ) - - git_clone_process = await create_subprocess_exec( - *git_clone_command, # ty: ignore[refutable-unpacking] - env={"GIT_TERMINAL_PROMPT": "0"}, - ) - - status_code = await git_clone_process.wait() - - logger.debug( - f"Finished cloning {self.org}/{self.repo} with status {status_code}", - ) - yield await self._get_commit(checkout_dir) - - def url_for(self: Self, commit_sha: str, path: str, lnum: int | None = None) -> str: - """ - Return the GitHub URL for the given commit, path, and line number, if given. - """ - # Default to main branch - url = f"https://github.com/{self.org}/{self.repo}/blob/{commit_sha}/{path}" - if lnum: - url += f"#L{lnum}" - return url - - async def _get_commit(self: Self, checkout_dir: Path) -> str: - """Return the commit sha for the repository in the checkout directory.""" - git_sha_process = await create_subprocess_exec( - "git", - "rev-parse", - "HEAD", - cwd=checkout_dir, - stdout=PIPE, - ) - git_sha_stdout, _ = await git_sha_process.communicate() - assert await git_sha_process.wait() == 0, ( - f"Failed to retrieve commit sha at {checkout_dir}" - ) - return git_sha_stdout.decode().strip() - - -# Repositories to check -# We check most repositories with the default ruleset instead of all rules to avoid -# noisy reports when new rules are added; see https://github.com/astral-sh/ruff/pull/3590 -REPOSITORIES: list[Repository] = [ - Repository("DisnakeDev", "disnake", "master"), - Repository("PostHog", "HouseWatch", "main"), - Repository("RasaHQ", "rasa", "main"), - Repository("Snowflake-Labs", "snowcli", "main"), - Repository("aiven", "aiven-client", "main"), - Repository("alteryx", "featuretools", "main"), - Repository("apache", "airflow", "main", select="ALL"), - Repository("apache", "superset", "master", select="ALL"), - Repository("aws", "aws-sam-cli", "develop"), - Repository("binary-husky", "gpt_academic", "master"), - Repository("bloomberg", "pytest-memray", "main"), - Repository("bokeh", "bokeh", "branch-3.10", select="ALL"), - # Disabled due to use of explicit `select` with `E999`, which has been removed. - # See: https://github.com/astral-sh/ruff/pull/12129 - # Repository("demisto", "content", "master"), - Repository("docker", "docker-py", "main"), - Repository("facebookresearch", "chameleon", "main"), - Repository("freedomofpress", "securedrop", "develop"), - Repository("fronzbot", "blinkpy", "dev"), - Repository("ibis-project", "ibis", "master"), - Repository("ing-bank", "probatus", "main"), - Repository("jrnl-org", "jrnl", "main"), - Repository("langchain-ai", "langchain", "main"), - Repository("latchbio", "latch", "main"), - Repository("lnbits", "lnbits", "main"), - Repository("milvus-io", "pymilvus", "master"), - Repository("mlflow", "mlflow", "master"), - Repository("model-bakers", "model_bakery", "main"), - Repository("pandas-dev", "pandas", "main"), - Repository("prefecthq", "prefect", "main"), - Repository("pypa", "build", "main"), - Repository("pypa", "cibuildwheel", "main"), - Repository("pypa", "pip", "main"), - Repository("pypa", "setuptools", "main"), - Repository("python", "mypy", "master"), - Repository("python", "typeshed", "main", select="PYI"), - Repository("python-poetry", "poetry", "master"), - Repository("qdrant", "qdrant-client", "master"), - Repository("reflex-dev", "reflex", "main"), - Repository("rotki", "rotki", "develop"), - Repository("scikit-build", "scikit-build", "main"), - Repository("scikit-build", "scikit-build-core", "main"), - Repository("sphinx-doc", "sphinx", "master"), - Repository("spruceid", "siwe-py", "main"), - Repository("tiangolo", "fastapi", "master"), - Repository("yandex", "ch-backup", "main"), - Repository("zulip", "zulip", "main", select="ALL"), -] - -SUMMARY_LINE_RE = re.compile(r"^(Found \d+ error.*)|(.*potentially fixable with.*)$") - - -class RuffError(Exception): - """An error reported by ruff.""" - - -async def check( - *, - ruff: Path, - path: Path, - name: str, - select: str = "", - ignore: str = "", - exclude: str = "", - show_fixes: bool = False, -) -> Sequence[str]: - """Run the given ruff binary against the specified path.""" - logger.debug(f"Checking {name} with {ruff}") - ruff_args = ["check", "--no-cache", "--exit-zero"] - if select: - ruff_args.extend(["--select", select]) - if ignore: - ruff_args.extend(["--ignore", ignore]) - if exclude: - ruff_args.extend(["--exclude", exclude]) - if show_fixes: - ruff_args.extend(["--show-fixes"]) - - start = time.time() - proc = await create_subprocess_exec( - ruff.absolute(), - *ruff_args, - ".", - stdout=PIPE, - stderr=PIPE, - cwd=path, - ) - result, err = await proc.communicate() - end = time.time() - - logger.debug(f"Finished checking {name} with {ruff} in {end - start:.2f}") - - if proc.returncode != 0: - raise RuffError(err.decode("utf8")) - - lines = [ - line - for line in result.decode("utf8").splitlines() - if SUMMARY_LINE_RE.match(line) is None - ] - - return sorted(lines) - - -class Diff(NamedTuple): - """A diff between two runs of ruff.""" - - removed: set[str] - added: set[str] - source_sha: str - - def __bool__(self: Self) -> bool: - """Return true if this diff is non-empty.""" - return bool(self.removed or self.added) - - @override - def __iter__(self: Self) -> Iterator[str]: - """Iterate through the changed lines in diff format.""" - for line in heapq.merge(sorted(self.removed), sorted(self.added)): - if line in self.removed: - yield f"- {line}" - else: - yield f"+ {line}" - - -async def compare( - ruff1: Path, - ruff2: Path, - repo: Repository, - checkouts: Path | None = None, -) -> Diff: - """Check a specific repository against two versions of ruff.""" - removed, added = set(), set() - - # By the default, the git clone are transient, but if the user provides a - # directory for permanent storage we keep it there - if checkouts: - location_context = nullcontext(checkouts) - else: - location_context = tempfile.TemporaryDirectory() - - with location_context as checkout_parent: - assert ":" not in repo.org - assert ":" not in repo.repo - checkout_dir = Path(checkout_parent).joinpath(f"{repo.org}:{repo.repo}") - async with repo.clone(checkout_dir) as checkout_sha: - try: - async with asyncio.TaskGroup() as tg: - check1 = tg.create_task( - check( - ruff=ruff1, - path=checkout_dir, - name=f"{repo.org}/{repo.repo}", - select=repo.select, - ignore=repo.ignore, - exclude=repo.exclude, - show_fixes=repo.show_fixes, - ), - ) - check2 = tg.create_task( - check( - ruff=ruff2, - path=checkout_dir, - name=f"{repo.org}/{repo.repo}", - select=repo.select, - ignore=repo.ignore, - exclude=repo.exclude, - show_fixes=repo.show_fixes, - ), - ) - except ExceptionGroup as e: - raise e.exceptions[0] from e - - for line in difflib.ndiff(check1.result(), check2.result()): - if line.startswith("- "): - removed.add(line[2:]) - elif line.startswith("+ "): - added.add(line[2:]) - - return Diff(removed, added, checkout_sha) - - -def read_projects_jsonl(projects_jsonl: Path) -> dict[tuple[str, str], Repository]: - """Read either of the two formats of https://github.com/akx/ruff-usage-aggregate.""" - repositories = {} - for line in projects_jsonl.read_text().splitlines(): - data = json.loads(line) - # Check the input format. - if "items" in data: - for item in data["items"]: - # Pick only the easier case for now. - if item["path"] != "pyproject.toml": - continue - repository = item["repository"] - assert re.fullmatch(r"[a-zA-Z0-9_.-]+", repository["name"]), repository[ - "name" - ] - # GitHub doesn't give us any branch or pure rev info. This would give - # us the revision, but there's no way with git to just do - # `git clone --depth 1` with a specific ref. - # `ref = item["url"].split("?ref=")[1]` would be exact - repositories[(repository["owner"], repository["repo"])] = Repository( - repository["owner"]["login"], - repository["name"], - None, - select=repository.get("select"), - ignore=repository.get("ignore"), - exclude=repository.get("exclude"), - ) - else: - assert "owner" in data, "Unknown ruff-usage-aggregate format" - # Pick only the easier case for now. - if data["path"] != "pyproject.toml": - continue - repositories[(data["owner"], data["repo"])] = Repository( - data["owner"], - data["repo"], - data.get("ref"), - select=data.get("select"), - ignore=data.get("ignore"), - exclude=data.get("exclude"), - ) - return repositories - - -DIFF_LINE_RE = re.compile( - r"^(?P
[+-]) (?P(?P[^:]+):(?P\d+):\d+:) (?P.*)$",
-)
-
-T = TypeVar("T")
-
-
-async def main(
-    *,
-    ruff1: Path,
-    ruff2: Path,
-    projects_jsonl: Path | None,
-    checkouts: Path | None = None,
-):
-    """Check two versions of ruff against a corpus of open-source code."""
-    if projects_jsonl:
-        repositories = read_projects_jsonl(projects_jsonl)
-    else:
-        repositories = {(repo.org, repo.repo): repo for repo in REPOSITORIES}
-
-    logger.debug(f"Checking {len(repositories)} projects")
-
-    # https://stackoverflow.com/a/61478547/3549270
-    # Otherwise doing 3k repositories can take >8GB RAM
-    semaphore = asyncio.Semaphore(50)
-
-    async def limited_parallelism(coroutine: Awaitable[T]) -> T:
-        async with semaphore:
-            return await coroutine
-
-    results = await asyncio.gather(
-        *[
-            limited_parallelism(compare(ruff1, ruff2, repo, checkouts))
-            for repo in repositories.values()
-        ],
-        return_exceptions=True,
-    )
-
-    diffs = dict(zip(repositories, results, strict=True))
-
-    total_removed = total_added = 0
-    errors = 0
-
-    for diff in diffs.values():
-        if isinstance(diff, BaseException):
-            errors += 1
-        else:
-            total_removed += len(diff.removed)
-            total_added += len(diff.added)
-
-    if total_removed == 0 and total_added == 0 and errors == 0:
-        print("\u2705 ecosystem check detected no changes.")
-    else:
-        rule_changes: dict[str, tuple[int, int]] = {}
-        changes = f"(+{total_added}, -{total_removed}, {errors} error(s))"
-
-        print(f"\u2139\ufe0f ecosystem check **detected changes**. {changes}")
-        print()
-
-        for (org, repo), diff in diffs.items():
-            if isinstance(diff, BaseException):
-                changes = "error"
-                print(f"
{repo} ({changes})") - repo = repositories[(org, repo)] - print( - f"https://github.com/{repo.org}/{repo.repo} ref {repo.ref} " - f"select {repo.select} ignore {repo.ignore} exclude {repo.exclude}", - ) - print("

") - print() - - print("```") - print(str(diff)) - print("```") - - print() - print("

") - print("
") - elif diff: - changes = f"+{len(diff.added)}, -{len(diff.removed)}" - print(f"
{repo} ({changes})") - print("

") - print() - - repo = repositories[(org, repo)] - diff_lines = list(diff) - - print("

")
-                for line in diff_lines:
-                    match = DIFF_LINE_RE.match(line)
-                    if match is None:
-                        print(line)
-                        continue
-
-                    pre, inner, path, lnum, post = match.groups()
-                    url = repo.url_for(diff.source_sha, path, int(lnum))
-                    print(f"{pre} {inner} {post}")
-                print("
") - - print() - print("

") - print("
") - - # Count rule changes - for line in diff_lines: - # Find rule change for current line or construction - # + /::: - matches = re.search(r": ([A-Z]{1,4}[0-9]{3,4})", line) - - if matches is None: - # Handle case where there are no regex matches e.g. - # + "?application=AIRFLOW&authenticator=TEST_AUTH&role=TEST_ROLE&warehouse=TEST_WAREHOUSE" - # Which was found in local testing - continue - - rule_code = matches.group(1) - - # Get current additions and removals for this rule - current_changes = rule_changes.get(rule_code, (0, 0)) - - # Check if addition or removal depending on the first character - if line[0] == "+": - current_changes = (current_changes[0] + 1, current_changes[1]) - elif line[0] == "-": - current_changes = (current_changes[0], current_changes[1] + 1) - - rule_changes[rule_code] = current_changes - - else: - continue - - if len(rule_changes.keys()) > 0: - print(f"Rules changed: {len(rule_changes.keys())}") - print() - print("| Rule | Changes | Additions | Removals |") - print("| ---- | ------- | --------- | -------- |") - for rule, (additions, removals) in sorted( - rule_changes.items(), - key=lambda x: x[1][0] + x[1][1], - reverse=True, - ): - print(f"| {rule} | {additions + removals} | {additions} | {removals} |") - - logger.debug(f"Finished {len(repositories)} repositories") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Check two versions of ruff against a corpus of open-source code.", - epilog="scripts/check_ecosystem.py ", - ) - - parser.add_argument( - "--projects", - type=Path, - help=( - "Optional JSON files to use over the default repositories. " - "Supports both github_search_*.jsonl and known-github-tomls.jsonl." - ), - ) - parser.add_argument( - "--checkouts", - type=Path, - help=( - "Location for the git checkouts, in case you want to save them" - " (defaults to temporary directory)" - ), - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Activate debug logging", - ) - parser.add_argument( - "ruff1", - type=Path, - ) - parser.add_argument( - "ruff2", - type=Path, - ) - - args = parser.parse_args() - - if args.verbose: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - if args.checkouts: - args.checkouts.mkdir(exist_ok=True, parents=True) - main_task = asyncio.ensure_future( - main( - ruff1=args.ruff1, - ruff2=args.ruff2, - projects_jsonl=args.projects, - checkouts=args.checkouts, - ), - ) - # https://stackoverflow.com/a/58840987/3549270 - for signal in [SIGINT, SIGTERM]: - loop.add_signal_handler(signal, main_task.cancel) - try: - loop.run_until_complete(main_task) - finally: - loop.close() diff --git a/scripts/check_ecosystem_roundtrip.py b/scripts/check_ecosystem_roundtrip.py index de0b59ba59..24a8ba97fe 100755 --- a/scripts/check_ecosystem_roundtrip.py +++ b/scripts/check_ecosystem_roundtrip.py @@ -159,6 +159,10 @@ def _rss_bytes(pid: int) -> int | None: _ENTRY_OVERHEAD = 120 _NOTICE_RESERVE = 250 +# the artifact the workflow uploads the untruncated report as. the omission +# notice names it, so the two have to stay in step +FULL_REPORT_ARTIFACT = "roundtrip-report.md" + # a project that fails identically on both binaries gets one line, not a dump _SUMMARY_CHARS = 200 @@ -544,10 +548,12 @@ def _detail_cap(n_findings: int) -> int: return max(_MIN_DETAIL_CHARS, min(_MAX_DETAIL_CHARS, share)) -def _truncate_detail(detail: str, cap: int) -> str: +def _truncate_detail(detail: str, cap: int | None) -> str: """Cap one finding's body, keeping both ends: a panic is identified by its - header and topmost frames, a failed build by where it finally gave up.""" - if len(detail) <= cap: + header and topmost frames, a failed build by where it finally gave up. + + A cap of `None` keeps the body whole, which is what the full report wants.""" + if cap is None or len(detail) <= cap: return detail marker = "\n... {} characters elided ...\n" head_budget = cap * 3 // 4 @@ -590,7 +596,7 @@ def _error_summary(err: str) -> str: def _details_entry( - name: str, path: Path, detail: str, cap: int, fence: str = "" + name: str, path: Path, detail: str, cap: int | None, fence: str = "" ) -> str: return "\n".join( [ @@ -605,12 +611,18 @@ def _details_entry( ) -def _assemble(header: list[str], sections: list[tuple[str, list[str]]]) -> str: +def _assemble( + header: list[str], sections: list[tuple[str, list[str]]], budget: int | None +) -> str: """Join the report under GitHub's comment size limit, dropping whole findings rather than cutting the report off mid-sentence. Sections are consumed in severity order, so what gets dropped first is the - least important thing in the report — never a regression.""" + least important thing in the report — never a regression. + + A `budget` of `None` drops nothing. That is the report the run uploads as an + artifact, and it is what the omission notice sends the reader to: a comment + that says findings were left out has to leave them somewhere reachable.""" out = list(header) used = sum(len(line) + 1 for line in out) + _NOTICE_RESERVE dropped = 0 @@ -620,7 +632,10 @@ def _assemble(header: list[str], sections: list[tuple[str, list[str]]]) -> str: pending_cost = sum(len(line) + 1 for line in pending) for entry in entries: cost = len(entry) + 1 - if used + (0 if placed else pending_cost) + cost > _COMMENT_BUDGET: + if ( + budget is not None + and used + (0 if placed else pending_cost) + cost > budget + ): dropped += 1 continue if not placed: @@ -635,8 +650,9 @@ def _assemble(header: list[str], sections: list[tuple[str, list[str]]]) -> str: if dropped: out.append( f"_{dropped} finding(s) omitted to fit GitHub's " - f"{COMMENT_CHAR_LIMIT}-character comment limit. The full report is " - f"the `comment.md` artifact of this run._" + f"{COMMENT_CHAR_LIMIT}-character comment limit. Every finding, with " + f"nothing elided, is in the `{FULL_REPORT_ARTIFACT}` artifact of this " + f"run._" ) return "\n".join(out).rstrip() + "\n" @@ -655,8 +671,11 @@ def compared_projects(results: list[ProjectDiff] | list[ProjectErrors]) -> int: def render_diff_report( - results: list[ProjectDiff], old_label: str, new_label: str + results: list[ProjectDiff], old_label: str, new_label: str, *, full: bool = False ) -> tuple[str, bool]: + """Render the report. `full` renders it with no size limit at all, for the + artifact the truncated comment points at.""" + # sorted, not in shard-completion order: the report is read by diffing it # against the last run, and a stable order is what makes new entries visible def of_kind(kind: str) -> list[tuple[ProjectDiff, FileDiff]]: @@ -676,6 +695,7 @@ def of_kind(kind: str) -> list[tuple[ProjectDiff, FileDiff]]: total_files = sum(r.files_checked for r in results) n_projects = compared_projects(results) + budget = None if full else _COMMENT_BUDGET lines: list[str] = ["## by ecosystem round-trip", "", COMMENT_MARKER, ""] skipped_section = ( @@ -691,7 +711,7 @@ def of_kind(kind: str) -> list[tuple[ProjectDiff, FileDiff]]: f"❌ nothing ran: all {len(results)} requested project(s) were skipped, " f"so this check compared no round-trip output at all." ) - return _assemble(lines, [skipped_section]), False + return _assemble(lines, [skipped_section], budget), False if broken or fixed or changed or error_changed: lines.append(f"base: `{old_label}` → head: `{new_label}`") @@ -718,14 +738,14 @@ def of_kind(kind: str) -> list[tuple[ProjectDiff, FileDiff]]: ) lines.append("") - cap = _detail_cap(len(broken) + len(changed) + len(error_changed)) + cap = None if full else _detail_cap(len(broken) + len(changed) + len(error_changed)) sections = [ ( "### ❌ regressions (built on base, now fails)", [_details_entry(r.name, d.path, d.detail, cap) for r, d in broken], ), ( - "### ℹ️ changed round-trip output", # noqa: RUF001 + "### ℹ️ changed round-trip output", # ruff: ignore[ambiguous-unicode-character-string] [_details_entry(r.name, d.path, d.detail, cap, "diff") for r, d in changed], ), ( @@ -743,7 +763,7 @@ def of_kind(kind: str) -> list[tuple[ProjectDiff, FileDiff]]: skipped_section, ] - return _assemble(lines, sections), not broken + return _assemble(lines, sections, budget), not broken def _project_diff_to_dict(p: ProjectDiff) -> dict[str, Any]: @@ -766,7 +786,9 @@ def _project_diff_from_dict(d: dict[str, Any]) -> ProjectDiff: ) -def render_from_json(paths: list[Path], old_label: str, new_label: str) -> int: +def render_from_json( + paths: list[Path], old_label: str, new_label: str, full_out: Path | None = None +) -> int: """Merge per-shard JSON results into the single markdown report.""" projects: list[ProjectDiff] = [] for path in paths: @@ -774,6 +796,9 @@ def render_from_json(paths: list[Path], old_label: str, new_label: str) -> int: projects.extend(_project_diff_from_dict(x) for x in data["projects"]) report, _clean = render_diff_report(projects, old_label, new_label) print(report) + if full_out is not None: + full, _ = render_diff_report(projects, old_label, new_label, full=True) + full_out.write_text(full) # findings (broken/changed/error-changed) are surfaced in the PR comment for # humans to review — they don't fail the job. only a genuine harness crash is # a failure, and a run that compared nothing is one of those: every shard @@ -942,6 +967,11 @@ async def setup_and_run(name: str) -> ProjectDiff | ProjectErrors: if baseline is not None: diffs = [r for r in results if isinstance(r, ProjectDiff)] report, clean = render_diff_report(diffs, args.old_label, args.new_label) + if args.render_full is not None: + full, _ = render_diff_report( + diffs, args.old_label, args.new_label, full=True + ) + args.render_full.write_text(full) else: errs = [r for r in results if isinstance(r, ProjectErrors)] report, clean = render_error_report(errs) @@ -988,6 +1018,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: nargs="+", help="merge these shard JSON files into the markdown report and exit", ) + parser.add_argument( + "--render-full", + type=Path, + help="also write the report with nothing dropped or elided here, for the " + "run to upload; the comment names it when it has to leave findings out", + ) parser.add_argument( "--baseline", type=Path, @@ -1069,7 +1105,9 @@ def main() -> int: ) if args.render: - return render_from_json(args.render, args.old_label, args.new_label) + return render_from_json( + args.render, args.old_label, args.new_label, args.render_full + ) if args.by is None: logger.error("the `by` binary is required unless --render is given") return 2 diff --git a/scripts/check_ecosystem_roundtrip.py.lock b/scripts/check_ecosystem_roundtrip.py.lock new file mode 100644 index 0000000000..ed6fd9afa1 --- /dev/null +++ b/scripts/check_ecosystem_roundtrip.py.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "mypy-primer", git = "https://github.com/hauntsaninja/mypy_primer" }] + +[[package]] +name = "mypy-primer" +version = "0.1.0" +source = { git = "https://github.com/hauntsaninja/mypy_primer#b7b4f951a3043c5b6b690986893f6365a8d10657" } diff --git a/scripts/codemod_docstrings.sh b/scripts/codemod_docstrings.sh index a3f31977c4..6bd3f20825 100755 --- a/scripts/codemod_docstrings.sh +++ b/scripts/codemod_docstrings.sh @@ -18,10 +18,14 @@ set -eu -docstring_adder="git+https://github.com/astral-sh/docstring-adder.git@701ead71db935e67c25756b9cb1617d58d85bb84" stdlib_path="./crates/ty_vendored/vendor/typeshed/stdlib" for python_version in 3.15 3.14 3.13 3.12 3.11 3.10 do - PYTHONUTF8=1 uvx --python="$python_version" --force-reinstall --from="${docstring_adder}" add-docstrings --stdlib-path="${stdlib_path}" + PYTHONUTF8=1 uv run \ + --locked \ + --only-group=typeshed-docstrings \ + --python="$python_version" \ + add-docstrings \ + --stdlib-path="${stdlib_path}" done diff --git a/scripts/collect_ty_ecosystem_run_metadata.py b/scripts/collect_ty_ecosystem_run_metadata.py index a6ce3c3b13..fb677b58b5 100755 --- a/scripts/collect_ty_ecosystem_run_metadata.py +++ b/scripts/collect_ty_ecosystem_run_metadata.py @@ -3,6 +3,19 @@ # /// script # requires-python = ">=3.11" # dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" # /// """Collect the exact inputs used by a Ruff ty ecosystem-analyzer run.""" @@ -57,7 +70,7 @@ def payloads(log: str) -> list[str]: return result -def unique_value(log: str, pattern: str, label: str) -> str: +def optional_value(log: str, pattern: str, label: str) -> str | None: regex = re.compile(pattern) # `pattern` is a parameter, so nothing here knows whether its first group is # optional; a line whose group did not participate carries no value @@ -67,11 +80,20 @@ def unique_value(log: str, pattern: str, label: str) -> str: if (match := regex.fullmatch(line)) and (value := match.group(1)) is not None } if not values: - raise MetadataError(f"could not find {label} in the Actions log") + return None if len(values) > 1: rendered = ", ".join(sorted(values)) raise MetadataError(f"found conflicting {label} values: {rendered}") - return values.pop() + ret = values.pop() + assert isinstance(ret, str) + return ret + + +def unique_value(log: str, pattern: str, label: str) -> str: + value = optional_value(log, pattern, label) + if value is None: + raise MetadataError(f"could not find {label} in the Actions log") + return value def parse_build_log(log: str) -> tuple[str, str]: @@ -80,13 +102,13 @@ def parse_build_log(log: str) -> tuple[str, str]: return merge_base, pr_revision -def parse_shard_log(log: str) -> tuple[str, str, str]: +def parse_shard_log(log: str) -> tuple[str, str | None, str]: exclude_newer = unique_value( log, r"EXCLUDE_NEWER: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)", "EXCLUDE_NEWER", ) - analyzer_revision = unique_value( + analyzer_revision = optional_value( log, rf"ECOSYSTEM_ANALYZER_COMMIT: ({SHA})", "ecosystem-analyzer revision", @@ -95,6 +117,25 @@ def parse_shard_log(log: str) -> tuple[str, str, str]: return exclude_newer, analyzer_revision, merge_base +def parse_ecosystem_analyzer_revision(lockfile: str) -> str: + try: + packages = tomllib.loads(lockfile)["package"] + package = next( + package for package in packages if package["name"] == "ecosystem-analyzer" + ) + source = package["source"]["git"] + revision = source.rsplit("#", maxsplit=1)[1] + except (IndexError, KeyError, StopIteration, TypeError) as error: + raise MetadataError( + "could not find ecosystem-analyzer revision in uv.lock" + ) from error + + if not isinstance(revision, str) or re.fullmatch(SHA, revision) is None: + raise MetadataError("ecosystem-analyzer revision is not a 40-character Git SHA") + + return revision + + def parse_minimum_python(source: str) -> tuple[int, int]: tree = ast.parse(source) for node in tree.body: @@ -106,12 +147,10 @@ def parse_minimum_python(source: str) -> tuple[int, int]: ): continue value = ast.literal_eval(node.value) - if ( - isinstance(value, tuple) - and len(value) == 2 - and all(isinstance(part, int) for part in value) - ): - return value + if isinstance(value, tuple) and len(value) == 2: + major, minor = value + if isinstance(major, int) and isinstance(minor, int): + return (major, minor) break raise MetadataError("could not parse ecosystem-analyzer MINIMUM_PYTHON_VERSION") @@ -290,6 +329,11 @@ def repository_file(repository: str, path: str, revision: str) -> str: ] ) + if analyzer_revision is None: + analyzer_revision = parse_ecosystem_analyzer_revision( + repository_file(repo, "uv.lock", pr_revision) + ) + analyzer_pyproject = repository_file( analyzer_repo, "pyproject.toml", analyzer_revision ) diff --git a/scripts/collect_ty_ecosystem_run_metadata.py.lock b/scripts/collect_ty_ecosystem_run_metadata.py.lock new file mode 100644 index 0000000000..5951180dc2 --- /dev/null +++ b/scripts/collect_ty_ecosystem_run_metadata.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/conformance.py b/scripts/conformance.py index 112458a56e..e8270dbc4d 100644 --- a/scripts/conformance.py +++ b/scripts/conformance.py @@ -1,18 +1,33 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """ Run typing conformance tests and compare results between two ty versions. -By default, this script will use `uv` to run the latest version of ty -as the new version with `uvx ty@latest`. This requires `uv` to be installed -and available in the system PATH. +ty versions can be supplied as `uvx ty` or `uvx ty@version` +for a specific version. This requires `uv` to be installed +and available on the system PATH. If CONFORMANCE_SUITE_COMMIT is set, the hash will be used to create links to the corresponding line in the conformance repository for each diagnostic. Otherwise, it will default to `main'. Examples: - # Compare an older version of ty to latest - %(prog)s --old-ty uvx ty@0.0.1a35 - # Compare two specific ty versions %(prog)s --old-ty uvx ty@0.0.1a35 --new-ty uvx ty@0.0.7 @@ -729,11 +744,16 @@ def render_test_cases( return "\n".join(lines) -def collect_file_stats(test_cases: list[TestCase]) -> list[FileStats]: - """Compute per-file statistics from grouped test cases.""" - path_to_cases: dict[Path, list[TestCase]] = {} +def collect_file_stats( + test_cases: list[TestCase], test_files: Sequence[Path] +) -> list[FileStats]: + # `test_cases` only contain files where `ty` generates a diagnostic + # We expand this with the full set of `test_files` to ensure we don't undercount + path_to_cases: dict[Path, list[TestCase]] = { + path.resolve(): [] for path in test_files + } for tc in test_cases: - path_to_cases.setdefault(tc.path, []).append(tc) + path_to_cases[tc.path].append(tc) return [ FileStats( path=path, @@ -1020,8 +1040,8 @@ def parse_args(): parser.add_argument( "--new-ty", nargs="+", - default=["uvx", "ty@latest"], - help="Command to run new version of ty (default: uvx ty@latest)", + help="Command to run new version of ty", + required=True, ) parser.add_argument( @@ -1091,7 +1111,7 @@ def main(): expected=expected, ) - file_stats = collect_file_stats(grouped) + file_stats = collect_file_stats(grouped, test_files) rendered = "\n\n".join( filter( diff --git a/scripts/conformance.py.lock b/scripts/conformance.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/conformance.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/ecosystem_all_check.py b/scripts/ecosystem_all_check.py index 0ec516c2c8..b3f20bff0d 100644 --- a/scripts/ecosystem_all_check.py +++ b/scripts/ecosystem_all_check.py @@ -1,7 +1,25 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["tqdm"] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """This is @konstin's scripts for checking an entire checkout of ~2.1k packages for panics, fix errors and similar problems. -It's a less elaborate, more hacky version of check_ecosystem.py +It's a less elaborate, more hacky ecosystem checker. """ from __future__ import annotations @@ -63,6 +81,7 @@ def main(): cwd=project_dir, capture_output=True, text=True, + check=False, ) except CalledProcessError as e: tqdm.write(f"Ruff failed on {project_dir}: {e}") diff --git a/scripts/ecosystem_all_check.py.lock b/scripts/ecosystem_all_check.py.lock new file mode 100644 index 0000000000..79a334f079 --- /dev/null +++ b/scripts/ecosystem_all_check.py.lock @@ -0,0 +1,39 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "tqdm" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] diff --git a/scripts/ecosystem_all_check.sh b/scripts/ecosystem_all_check.sh index 2018108280..da6dd3dea4 100755 --- a/scripts/ecosystem_all_check.sh +++ b/scripts/ecosystem_all_check.sh @@ -7,7 +7,7 @@ # # Usage: # ```shell -# # You can also use any other check_ecosystem.py input file +# # You can also use any compatible JSONL input file # curl https://raw.githubusercontent.com/akx/ruff-usage-aggregate/master/data/known-github-tomls-clean.jsonl > github_search.jsonl # cargo build --release --target x86_64-unknown-linux-musl --bin ruff # scripts/ecosystem_all_check.sh check --select RUF200 diff --git a/scripts/generate-crate-readmes.py b/scripts/generate-crate-readmes.py index ea1ffa03f1..0a85279017 100644 --- a/scripts/generate-crate-readmes.py +++ b/scripts/generate-crate-readmes.py @@ -1,6 +1,19 @@ # /// script # requires-python = ">=3.13" # dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" # /// from __future__ import annotations diff --git a/scripts/generate-crate-readmes.py.lock b/scripts/generate-crate-readmes.py.lock new file mode 100644 index 0000000000..a1f7963bfd --- /dev/null +++ b/scripts/generate-crate-readmes.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/generate_builtin_modules.py b/scripts/generate_builtin_modules.py index a22d82cced..17756920d6 100644 --- a/scripts/generate_builtin_modules.py +++ b/scripts/generate_builtin_modules.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Script to generate `crates/ruff_python_stdlib/src/sys/builtin_modules.rs`. This script requires `uvx` to be available on PATH. @@ -41,7 +59,7 @@ def builtin_modules_on_version(minor_version: int) -> set[str]: f"python3.{minor_version}", "--upgrade", ] - run(command_1) + _ = run(command_1) command_2 = [ "uvx", "--managed-python", diff --git a/scripts/generate_builtin_modules.py.lock b/scripts/generate_builtin_modules.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/generate_builtin_modules.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/generate_known_standard_library.py b/scripts/generate_known_standard_library.py index 4ac01204bf..5be703f351 100644 --- a/scripts/generate_known_standard_library.py +++ b/scripts/generate_known_standard_library.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["stdlibs"] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + from __future__ import annotations from pathlib import Path diff --git a/scripts/generate_known_standard_library.py.lock b/scripts/generate_known_standard_library.py.lock new file mode 100644 index 0000000000..513772c081 --- /dev/null +++ b/scripts/generate_known_standard_library.py.lock @@ -0,0 +1,27 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "stdlibs" }] + +[[package]] +name = "stdlibs" +version = "2026.2.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/cd/2710eaacaefc8be2f520b55c313498a50a295a8378e932c70d4ea34250aa/stdlibs-2026.2.26.tar.gz", hash = "sha256:10f911bdd8d3e45b452cc187b3527e6f9d288c8a943c5f973da94c71b2757d5b", size = 20203, upload-time = "2026-02-26T23:30:04.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ec/b6a5a568d584659e037c8f53fc25acc79950ac32796b8861b2015446b7b2/stdlibs-2026.2.26-py3-none-any.whl", hash = "sha256:3257486216eac5ac627a3a4c5665802aca72fe7fc9e4ab1f232b1fb47bfd3db6", size = 59288, upload-time = "2026-02-26T23:30:03.597Z" }, +] diff --git a/scripts/generate_mkdocs.py b/scripts/generate_mkdocs.py index 154551dac1..c76e90083f 100644 --- a/scripts/generate_mkdocs.py +++ b/scripts/generate_mkdocs.py @@ -1,3 +1,25 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "mdformat>=1.0.0", +# "mdformat-mkdocs>=5.3.0", +# "pyyaml>=6.0.3", +# ] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Generate an MkDocs-compatible `docs` and `mkdocs.yml` from the README.md.""" from __future__ import annotations @@ -53,6 +75,7 @@ class Section(NamedTuple): Section("Integrations", "integrations.md", generated=False), Section("FAQ", "faq.md", generated=False), Section("Contributing", "contributing.md", generated=True), + Section("Proposing Lint Rules", "rule-proposals.md", generated=False), ] LINK_REWRITES: dict[str, str] = { @@ -106,7 +129,7 @@ def clean_file_content(content: str, title: str) -> str: def generate_rule_metadata(rule_doc: Path): - """Add frontmatter metadata containing a rule's code and description. + """Add frontmatter metadata containing a rule's description and optional code. For example: ```yaml @@ -126,18 +149,21 @@ def generate_rule_metadata(rule_doc: Path): # truthiness tests below asking one question instead of two. rule_code = "" description = "" + title_found = False what_it_does_found = False for line in lines: if line == "\n": continue - # Assume that the only first-level heading is the rule title and code. + # Assume that the only first-level heading is the rule title and optional code. # # For example: given `# abstract-base-class-without-abstract-method (B024)`, # extract the rule code (`B024`). if line.startswith("# "): - rule_code = line.strip().rsplit("(", 1) - rule_code = rule_code[1][:-1] + title_found = True + _, separator, code = line.strip().rpartition(" (") + if separator and code.endswith(")"): + rule_code = code[:-1] if line.startswith("## What it does"): what_it_does_found = True @@ -146,30 +172,21 @@ def generate_rule_metadata(rule_doc: Path): if what_it_does_found and not description: description = line.removesuffix("\n") - if all([rule_code, description]): + if title_found and description: break else: - if not rule_code: + if not title_found: raise ValueError("Missing title line") if not what_it_does_found: raise ValueError(f"Missing '## What it does' in {rule_doc}") with rule_doc.open("w", encoding="utf-8") as f: - f.writelines( - "\n".join( - [ - "---", - "description: |-", - f" {description}", - "tags:", - f"- {rule_code}", - "---", - "", - "", - ] - ) - ) + metadata = ["---", "description: |-", f" {description}"] + if rule_code is not None: + metadata.extend(["tags:", f"- {rule_code}"]) + metadata.extend(["---", "", ""]) + f.write("\n".join(metadata)) f.writelines(lines) @@ -276,6 +293,7 @@ def main(): ), ] for rule in rules + if rule["code"] is not None ) ), }, @@ -298,7 +316,7 @@ def main(): ) with Path("mkdocs.generated.yml").open("w+", encoding="utf8") as fp: - yaml.safe_dump(config, fp) + _ = yaml.safe_dump(config, fp) if __name__ == "__main__": diff --git a/scripts/generate_mkdocs.py.lock b/scripts/generate_mkdocs.py.lock new file mode 100644 index 0000000000..6beb8983d0 --- /dev/null +++ b/scripts/generate_mkdocs.py.lock @@ -0,0 +1,151 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [ + { name = "mdformat", specifier = ">=1.0.0" }, + { name = "mdformat-mkdocs", specifier = ">=5.3.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + +[[package]] +name = "mdformat-gfm" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" }, +] + +[[package]] +name = "mdformat-mkdocs" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "mdit-py-plugins" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/8c/ca9c13017fcb224e9a0c17c214279eb7273318d1890cd0adc80a3c30e443/mdformat_mkdocs-5.3.0.tar.gz", hash = "sha256:9ae35940cfc1d350c41dda717963c90c669937fbbe3be32412a2b975e4bf891d", size = 33319, upload-time = "2026-08-02T18:38:07.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/71/1e1a81c7ed1629ac41f5b3d7dd7bc93c91e106398a88f0e2949c39e04b8e/mdformat_mkdocs-5.3.0-py3-none-any.whl", hash = "sha256:46938724df5892f517130a42d5652276f9a40e80ad0bae4a2af6bde71b53f861", size = 43928, upload-time = "2026-08-02T18:38:06.347Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] diff --git a/scripts/memory_report.py b/scripts/memory_report.py index a6e9756a8a..89d15e7418 100644 --- a/scripts/memory_report.py +++ b/scripts/memory_report.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """ Compare memory usage reports between two ty versions and generate a PR comment. @@ -123,7 +141,9 @@ def load_reports_from_directory(directory: Path) -> dict[str, MemoryReport]: def item_total_bytes(item: dict[str, Any]) -> int: """Get total bytes (metadata + fields) for a struct or query item.""" - return item.get("metadata_bytes", 0) + item.get("fields_bytes", 0) + result = item.get("metadata_bytes", 0) + item.get("fields_bytes", 0) + assert isinstance(result, int) + return result def diff_items( @@ -133,8 +153,8 @@ def diff_items( Returns a list of (name, old_bytes, new_bytes) sorted by absolute diff descending. """ - old_by_name = {item["name"]: item for item in old_items} - new_by_name = {item["name"]: item for item in new_items} + old_by_name: dict[str, dict[str, Any]] = {item["name"]: item for item in old_items} + new_by_name: dict[str, dict[str, Any]] = {item["name"]: item for item in new_items} all_names = old_by_name.keys() | new_by_name.keys() diff --git a/scripts/memory_report.py.lock b/scripts/memory_report.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/memory_report.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/publish-crates.py b/scripts/publish-crates.py index a1a9ff3f59..c2fe51e91f 100644 --- a/scripts/publish-crates.py +++ b/scripts/publish-crates.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + # Publish workspace crates to crates.io idempotently. # # `cargo publish --workspace` fails if any selected crate version already exists on crates.io. That @@ -133,7 +151,7 @@ def publish_workspace( print(f" {crate.pretty()}") command = build_cargo_publish_command(cargo, existing, cargo_publish_args) - return subprocess.run(command, cwd=REPO_ROOT).returncode + return subprocess.run(command, cwd=REPO_ROOT, check=False).returncode def parse_args(argv: list[str] | None = None) -> argparse.Namespace: diff --git a/scripts/publish-crates.py.lock b/scripts/publish-crates.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/publish-crates.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml deleted file mode 100644 index 3588ac9e87..0000000000 --- a/scripts/pyproject.toml +++ /dev/null @@ -1,58 +0,0 @@ -[project] -name = "scripts" -version = "0.0.1" -dependencies = [ - "stdlibs", - "tqdm", - "mdformat", - "pyyaml", - "mypy-primer", - "httpx", - # `check_by_lexer.py` exercises the lexer in `python/basedpython-pygments`. - # only `pygments` is imported — the lexer itself is resolved through its - # entry point at runtime, so it is not a dependency of this project - "pygments", -] -requires-python = ">=3.12" - -[dependency-groups] -# pytest runs `test_build_backend.py`; it is not what the file is written -# against, but the checker types its fixtures from pytest's own package -dev = ["pytest"] - -[tool.black] -line-length = 88 - -[tool.ruff] -extend = "../pyproject.toml" - -[tool.ty.environment] -# `test_build_backend.py` tests `basedpython.build`, the PEP 517 backend that -# ships in the `basedpython` wheel. it puts `../python` on `sys.path` to reach it, -# and this is the same thing said to the checker -root = [".", "../python"] - -[tool.ty.src] -# `ty_benchmark` is a standalone project with its own pyproject.toml files, search paths, etc. -# `native-bench/programs` are the benchmark's inputs rather than its tooling: each one is written -# to measure a particular shape, so what the checker would ask for — an `@override`, a narrower -# annotation — would change the program being measured -exclude = ["./ty_benchmark", "./native-bench/programs"] - -[tool.uv.sources] -mypy-primer = { git = "https://github.com/hauntsaninja/mypy_primer" } - -[tool.ty.rules] -possibly-unresolved-reference = "error" -division-by-zero = "error" -unused-ignore-comment = "error" - -# these files come from upstream ruff, where a call written for its effect alone is -# ordinary style. writing the discard out at each site would put a conflict in every -# one of them on the next upstream sync, so the rule is off for them rather than for -# the project — code of our own here is still checked -[[tool.ty.overrides]] -include = ["build_ruff_pgo.py", "generate_builtin_modules.py", "generate_mkdocs.py", "setup-crates-io-publish.py"] - -[tool.ty.overrides.rules] -unused-return-value = "ignore" diff --git a/scripts/release.sh b/scripts/release.sh index 08483f393f..9183d5408c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,7 +4,7 @@ # All additional options are passed to `rooster release` set -eu -export UV_PREVIEW=1 +export UV_DEFAULT_INDEX='https://pypi.org/simple' script_root="$(realpath "$(dirname "$0")")" project_root="$(dirname "$script_root")" @@ -22,7 +22,10 @@ uv run --script "$project_root/scripts/generate-crate-readmes.py" echo "Updating lockfiles..." cargo update -p ruff -uv lock --no-config +uv lock echo "Checking crates.io publish setup..." -uv run --no-config --script "$project_root/scripts/setup-crates-io-publish.py" --quiet +crates_policies="$(mktemp -d)" +trap 'rm -rf "$crates_policies"' EXIT +git clone --depth=1 --quiet https://github.com/astral-sh/crates-policies.git "$crates_policies" +uv run --script "$crates_policies/check.py" "$project_root" diff --git a/scripts/setup-crates-io-publish.py b/scripts/setup-crates-io-publish.py deleted file mode 100644 index 979853b369..0000000000 --- a/scripts/setup-crates-io-publish.py +++ /dev/null @@ -1,459 +0,0 @@ -# Ensure workspace crates are ready for trusted publishing on crates.io. -# -# This script performs four steps for each candidate crate: -# -# 1. Publish a placeholder crate if the crate doesn't yet exist on crates.io. -# 2. Remove trusted publisher configs that don't match our desired config. -# 3. Ensure exactly one desired trusted publishing config exists. -# 4. Enable `trustpub_only` ("Require trusted publishing for all new versions"). -# -# It authenticates with `CARGO_REGISTRY_TOKEN`, which must have the `publish-new` and -# `trusted-publishing` scopes. -# -# Crates tracked in `.known-crates` are assumed to be configured and are skipped unless `--force` is -# used. -# -# Usage: -# -# CARGO_REGISTRY_TOKEN= uv run --script scripts/setup-crates-io-publish.py [--dry-run] [--force] [--quiet] - -# /// script -# requires-python = ">=3.13" -# dependencies = ["httpx"] -# /// - -from __future__ import annotations - -import argparse -import json -import os -import pathlib -import subprocess -import sys -import tempfile -import time -import tomllib - -import httpx - -CRATES_IO_API = "https://crates.io/api/v1" -USER_AGENT = "ruff-crates-io-publish-setup (github.com/astral-sh/ruff)" - -REPOSITORY_OWNER = "astral-sh" -REPOSITORY_NAME = "ruff" -WORKFLOW_FILENAME = "release.yml" -ENVIRONMENT = "release" - -REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent -WORKSPACE_MANIFEST_PATH = REPO_ROOT / "Cargo.toml" -KNOWN_CRATES_PATH = REPO_ROOT / ".known-crates" -KNOWN_CRATES_HEADER = "# GENERATED-BY scripts/setup-crates-io-publish.py\n" - -PLACEHOLDER_VERSION = "0.0.0" - -# Delay between `cargo publish` calls to respect crates.io rate limits. -PUBLISH_DELAY_SECS = 15 - - -def get_publishable_crates() -> list[dict[str, str]]: - """Return publishable workspace crates as ``[{"name": …, "version": …}]``.""" - result = subprocess.run( - ["cargo", "metadata", "--format-version", "1", "--no-deps"], - capture_output=True, - text=True, - check=True, - ) - metadata = json.loads(result.stdout) - - workspace_member_ids = set(metadata["workspace_members"]) - crates = [] - for package in metadata["packages"]: - if package["id"] not in workspace_member_ids: - continue - # ``publish = false`` is represented as an empty list in cargo metadata. - if package.get("publish") == []: - continue - crates.append({"name": package["name"], "version": package["version"]}) - - return sorted(crates, key=lambda c: c["name"]) - - -def load_known_crates() -> set[str]: - """Load crate names from ``.known-crates``.""" - if not KNOWN_CRATES_PATH.exists(): - return set() - - known = set() - for line in KNOWN_CRATES_PATH.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - known.add(line) - return known - - -def save_known_crates(crates: set[str]): - """Persist the sorted set of fully-configured crate names.""" - lines = [KNOWN_CRATES_HEADER] - for name in sorted(crates): - lines.append(f"{name}\n") - KNOWN_CRATES_PATH.write_text("".join(lines)) - - -def get_crate_metadata( - client: httpx.Client, crate_name: str -) -> dict[str, object] | None: - """Get crate metadata from the public crates.io API.""" - response = client.get(f"{CRATES_IO_API}/crates/{crate_name}") - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - - -def load_workspace_package_metadata() -> dict[str, object]: - """Load shared package metadata from the workspace manifest.""" - manifest = tomllib.loads(WORKSPACE_MANIFEST_PATH.read_text()) - workspace_package = manifest.get("workspace", {}).get("package") - if not isinstance(workspace_package, dict): - raise RuntimeError("workspace.package is missing from Cargo.toml") - return workspace_package - - -def publish_placeholder_crate( - crate_name: str, - workspace_package: dict[str, object], -) -> bool: - """Publish a generated placeholder crate to reserve a new crates.io name.""" - with tempfile.TemporaryDirectory(prefix=f"{crate_name}-placeholder-") as temp_dir: - temp_path = pathlib.Path(temp_dir) - src_dir = temp_path / "src" - src_dir.mkdir(parents=True) - - authors = workspace_package.get("authors", []) - if not isinstance(authors, list): - raise RuntimeError("workspace.package.authors must be a list") - - edition = workspace_package.get("edition") - rust_version = workspace_package.get("rust-version") - homepage = workspace_package.get("homepage") - repository = workspace_package.get("repository") - license_expression = workspace_package.get("license") - - description = ( - "This is a placeholder release for an internal component crate of Ruff" - ) - - manifest = ( - "[package]\n" - f"name = {json.dumps(crate_name)}\n" - f"version = {json.dumps(PLACEHOLDER_VERSION)}\n" - f"edition = {json.dumps(edition)}\n" - f"rust-version = {json.dumps(rust_version)}\n" - f"authors = {json.dumps(authors)}\n" - f"license = {json.dumps(license_expression)}\n" - f"homepage = {json.dumps(homepage)}\n" - f"repository = {json.dumps(repository)}\n" - f"description = {json.dumps(description)}\n" - 'readme = "README.md"\n' - ) - (temp_path / "Cargo.toml").write_text(manifest) - (temp_path / "README.md").write_text( - "\n\n" - f"# {crate_name}\n\n" - f"This crate is an internal component of [Ruff](https://crates.io/crates/ruff). " - f"This placeholder version ({PLACEHOLDER_VERSION}) only exists to reserve the " - "crate name and enable trusted publishing for future releases.\n" - ) - (src_dir / "lib.rs").write_text( - "//! Placeholder crate published to reserve the name on crates.io.\n\n" - "/// Marker type for the placeholder release.\n" - "pub struct Placeholder;\n" - ) - - result = subprocess.run( - [ - "cargo", - "publish", - "--manifest-path", - str(temp_path / "Cargo.toml"), - "--no-verify", - ], - capture_output=True, - text=True, - ) - if result.returncode != 0: - print(result.stderr, file=sys.stderr, end="") - return False - return True - - -def create_trusted_publisher(client: httpx.Client, crate_name: str): - """Create a Trusted Publishing GitHub config for *crate_name*.""" - response = client.post( - f"{CRATES_IO_API}/trusted_publishing/github_configs", - json={ - "github_config": { - "crate": crate_name, - "repository_owner": REPOSITORY_OWNER, - "repository_name": REPOSITORY_NAME, - "workflow_filename": WORKFLOW_FILENAME, - "environment": ENVIRONMENT, - } - }, - ) - response.raise_for_status() - - -def list_trusted_publishers( - client: httpx.Client, crate_name: str -) -> list[dict[str, object]]: - """List Trusted Publishing GitHub configs for *crate_name*.""" - response = client.get( - f"{CRATES_IO_API}/trusted_publishing/github_configs", - params={"crate": crate_name}, - ) - response.raise_for_status() - payload = response.json() - return payload.get("github_configs", []) - - -def delete_trusted_publisher(client: httpx.Client, config_id: int): - """Delete a Trusted Publishing GitHub config by ID.""" - response = client.delete( - f"{CRATES_IO_API}/trusted_publishing/github_configs/{config_id}" - ) - response.raise_for_status() - - -def set_trustpub_only(client: httpx.Client, crate_name: str, enabled: bool): - """Enable or disable `trustpub_only` for a crate.""" - response = client.patch( - f"{CRATES_IO_API}/crates/{crate_name}", - json={"crate": {"trustpub_only": enabled}}, - ) - response.raise_for_status() - - -def is_desired_config(config: dict[str, object]) -> bool: - """Return True if *config* matches the workflow this repo uses.""" - return ( - config.get("repository_owner") == REPOSITORY_OWNER - and config.get("repository_name") == REPOSITORY_NAME - and config.get("workflow_filename") == WORKFLOW_FILENAME - and config.get("environment") == ENVIRONMENT - ) - - -def handle_trusted_publisher_error(exc: httpx.HTTPStatusError) -> None: - """Print a helpful hint for common trusted-publishing API errors, then exit.""" - print( - f"error {exc.response.status_code}: {exc.response.text}", - file=sys.stderr, - ) - if exc.response.status_code == 401: - if "GitHub session has expired" in exc.response.text: - print( - "\nhint: your crates.io account's linked GitHub OAuth" - " token is stale.\nLog out and back in at" - " https://crates.io to refresh it, then re-run.", - file=sys.stderr, - ) - else: - print( - "\nhint: your CARGO_REGISTRY_TOKEN may be invalid or revoked.", - file=sys.stderr, - ) - elif exc.response.status_code == 403: - print( - "\nhint: your token may lack the trusted-publishing scope," - " or you are not an owner of this crate.", - file=sys.stderr, - ) - sys.exit(1) - - -def main(): - parser = argparse.ArgumentParser( - description="Ensure workspace crates are ready for trusted publishing on crates.io." - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Show what would be done without making changes", - ) - parser.add_argument( - "--force", action="store_true", help="Re-check crates already in .known-crates" - ) - parser.add_argument( - "--quiet", "-q", action="store_true", help="Suppress informational output" - ) - args = parser.parse_args() - - dry_run = args.dry_run - force = args.force - quiet = args.quiet - - workspace_package = load_workspace_package_metadata() - - crates = get_publishable_crates() - known = set() if force else load_known_crates() - candidates = [c for c in crates if c["name"] not in known] - - if not candidates: - if not quiet: - print( - f"All {len(crates)} publishable crates are in .known-crates — nothing to do." - ) - return - - token = os.environ.get("CARGO_REGISTRY_TOKEN", "") - if not token: - crate_names = ", ".join(crate["name"] for crate in candidates) - print( - f"Crates requiring crates.io publish setup: {crate_names}", - file=sys.stderr, - ) - print( - "error: CARGO_REGISTRY_TOKEN is required (with `publish-new`" - " and `trusted-publishing` scopes)", - file=sys.stderr, - ) - print( - "\nPlease ask a crates.io owner to bootstrap releases which add a new crate.", - file=sys.stderr, - ) - sys.exit(1) - - client = httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30) - auth_client = httpx.Client( - headers={"Authorization": token, "User-Agent": USER_AGENT}, - timeout=30, - ) - - s = "" if len(candidates) == 1 else "s" - if not quiet: - print(f"Checking {len(candidates)} crate{s}") - - published_any = False - for crate in candidates: - name = crate["name"] - version = crate["version"] - metadata = get_crate_metadata(client, name) - exists = metadata is not None - - trustpub_only_enabled = False - if exists: - crate_payload = metadata.get("crate") - if isinstance(crate_payload, dict): - trustpub_only_enabled = bool(crate_payload.get("trustpub_only", False)) - - configs: list[dict[str, object]] = [] - if exists: - try: - configs = list_trusted_publishers(auth_client, name) - except httpx.HTTPStatusError as exc: - print(f"{name}: failed to list trusted publishers", file=sys.stderr) - handle_trusted_publisher_error(exc) - - desired_configs = [config for config in configs if is_desired_config(config)] - extra_desired_configs = desired_configs[1:] - non_matching_configs = [ - config for config in configs if not is_desired_config(config) - ] - configs_to_delete = non_matching_configs + extra_desired_configs - - needs_initial_publish = not exists - needs_add_publisher = not desired_configs - needs_trustpub_only = not trustpub_only_enabled - - if dry_run: - actions: list[str] = [] - if needs_initial_publish: - actions.append("publish placeholder") - if configs_to_delete: - count = len(configs_to_delete) - noun = "publisher" if count == 1 else "publishers" - actions.append(f"remove {count} {noun}") - if needs_add_publisher: - actions.append("add trusted publisher") - if needs_trustpub_only: - actions.append("enable trustpub_only") - - if actions: - print(f"{name}: would {' and '.join(actions)}") - elif not quiet: - print(f"{name}: already configured") - continue - - if needs_initial_publish: - if version == PLACEHOLDER_VERSION: - print( - f"{name}: workspace version matches placeholder version {PLACEHOLDER_VERSION}; " - "bump the real crate version before running this script", - file=sys.stderr, - ) - sys.exit(1) - - if published_any: - print(f"waiting {PUBLISH_DELAY_SECS}s for rate limit") - time.sleep(PUBLISH_DELAY_SECS) - - print(f"{name}: publishing placeholder") - if not publish_placeholder_crate(name, workspace_package): - print(f"{name}: placeholder publish failed", file=sys.stderr) - sys.exit(1) - published_any = True - - if configs_to_delete: - deleted = 0 - for config in configs_to_delete: - config_id = config.get("id") - if not isinstance(config_id, int): - print( - f"{name}: unexpected trusted publisher payload: missing `id`", - file=sys.stderr, - ) - sys.exit(1) - try: - delete_trusted_publisher(auth_client, config_id) - except httpx.HTTPStatusError as exc: - print( - f"{name}: failed to delete trusted publisher {config_id}", - file=sys.stderr, - ) - handle_trusted_publisher_error(exc) - deleted += 1 - noun = "publisher" if deleted == 1 else "publishers" - print(f"{name}: removed {deleted} {noun}") - - if needs_add_publisher: - print(f"{name}: registering trusted publisher") - try: - create_trusted_publisher(auth_client, name) - except httpx.HTTPStatusError as exc: - handle_trusted_publisher_error(exc) - - if needs_trustpub_only: - print(f"{name}: enabling trustpub_only") - try: - set_trustpub_only(auth_client, name, enabled=True) - except httpx.HTTPStatusError as exc: - handle_trusted_publisher_error(exc) - - if ( - not configs_to_delete - and not needs_add_publisher - and not needs_trustpub_only - and not quiet - ): - print(f"{name}: already configured") - - known.add(name) - - if not dry_run: - save_known_crates(known) - - -if __name__ == "__main__": - main() diff --git a/scripts/setup-crates-io-publish.py.lock b/scripts/setup-crates-io-publish.py.lock deleted file mode 100644 index 0e4bb17fde..0000000000 --- a/scripts/setup-crates-io-publish.py.lock +++ /dev/null @@ -1,73 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[manifest] -requirements = [{ name = "httpx" }] - -[[package]] -name = "anyio" -version = "4.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, -] - -[[package]] -name = "certifi" -version = "2026.5.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] diff --git a/scripts/setup_primer_project.py b/scripts/setup_primer_project.py old mode 100644 new mode 100755 index 49a8cdbd1d..36f940f2db --- a/scripts/setup_primer_project.py +++ b/scripts/setup_primer_project.py @@ -4,17 +4,29 @@ # requires-python = ">=3.11" # dependencies = ["mypy-primer"] # +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# # [tool.uv] +# no-build = true +# no-binary-package = ["mypy-primer"] +# build-constraint-dependencies = ["setuptools==84.0.0"] # # This is the default for ad hoc use. Historical ecosystem reproduction must # # bypass the adjacent lock and select ecosystem-analyzer's exact mypy-primer # # revision and project Python version, as shown in the module docstring. # # `exclude-newer` still constrains mypy-primer's registry dependencies. -# exclude-newer = "7 days" +# exclude-newer = "P7D" # # [tool.uv.sources] -# # Keep this revision and the script's lockfile in sync with ecosystem-analyzer's -# # mypy-primer pin so memory reports and ecosystem jobs use the same project definitions. -# mypy-primer = { git = "https://github.com/hauntsaninja/mypy_primer", rev = "6d6eebd8d37c9b8931381e79aa99808d9378c988" } +# # Keep the script's lockfile in sync with the mypy-primer pin in the project's uv.lock file +# # so memory reports and ecosystem jobs use the same project definitions. +# mypy-primer = { git = "https://github.com/hauntsaninja/mypy_primer" } # /// """Clone a mypy-primer project and set up a virtualenv with its dependencies installed. @@ -151,7 +163,7 @@ def main(): install_cmd = project.install_cmd.format(install=install_base) print(f"Running install command: {install_cmd}") # Primer install commands are trusted project metadata and may use shell syntax. - subprocess.run(install_cmd, cwd=target_dir, shell=True, check=True) # noqa: S602 + subprocess.run(install_cmd, cwd=target_dir, shell=True, check=True) # ruff: ignore[subprocess-popen-with-shell-equals-true] # Install listed dependencies (matching primer's setup()) if project.deps: diff --git a/scripts/setup_primer_project.py.lock b/scripts/setup_primer_project.py.lock index feea69058f..a2a52f2c72 100644 --- a/scripts/setup_primer_project.py.lock +++ b/scripts/setup_primer_project.py.lock @@ -6,10 +6,19 @@ requires-python = ">=3.11" exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + [manifest] -requirements = [{ name = "mypy-primer", git = "https://github.com/hauntsaninja/mypy_primer?rev=6d6eebd8d37c9b8931381e79aa99808d9378c988" }] +requirements = [{ name = "mypy-primer", git = "https://github.com/hauntsaninja/mypy_primer" }] +build-constraints = [{ name = "setuptools", specifier = "==84.0.0" }] [[package]] name = "mypy-primer" version = "0.1.0" -source = { git = "https://github.com/hauntsaninja/mypy_primer?rev=6d6eebd8d37c9b8931381e79aa99808d9378c988#6d6eebd8d37c9b8931381e79aa99808d9378c988" } +source = { git = "https://github.com/hauntsaninja/mypy_primer#3058720299b812c393ad926bcca96eede20fa683" } diff --git a/scripts/test_build_backend.py b/scripts/test_build_backend.py index 80b4432a6e..63cd28cb91 100644 --- a/scripts/test_build_backend.py +++ b/scripts/test_build_backend.py @@ -11,6 +11,15 @@ neither is tested here. """ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pytest"] +# +# # the backend under test is the one this checkout ships, not an installed copy +# [tool.ty.environment] +# extra-paths = ["../python"] +# /// + from __future__ import annotations import contextlib diff --git a/scripts/test_build_backend.py.lock b/scripts/test_build_backend.py.lock new file mode 100644 index 0000000000..14ebd1ff6e --- /dev/null +++ b/scripts/test_build_backend.py.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] diff --git a/scripts/test_check_ecosystem_roundtrip.py b/scripts/test_check_ecosystem_roundtrip.py index bcdbe850c5..f2489f8ecd 100644 --- a/scripts/test_check_ecosystem_roundtrip.py +++ b/scripts/test_check_ecosystem_roundtrip.py @@ -9,6 +9,15 @@ unresolved-import diagnostics. """ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pytest"] +# +# # the module under test is the sibling script, imported the way pytest imports it +# [tool.ty.environment] +# extra-paths = ["."] +# /// + from __future__ import annotations import json @@ -19,6 +28,7 @@ _COMMENT_BUDGET, BUILD_PATH, COMMENT_CHAR_LIMIT, + FULL_REPORT_ARTIFACT, FileDiff, ProjectDiff, ProjectErrors, @@ -232,6 +242,58 @@ def test_counts_reflect_everything_even_when_entries_are_dropped(self): assert "error changes: 200" in body assert "finding(s) omitted" in body + def test_the_full_report_keeps_what_the_comment_drops(self): + # the comment leaves findings out to fit GitHub's limit and says so. what + # it says has to be true: the artifact it names holds every one of them + results = [ + project( + f"noise{i}", "error-changed", panic(thread=i, first_line=1, frames=900) + ) + for i in range(200) + ] + comment, _ = render_diff_report(results, "base", "head") + assert "finding(s) omitted" in comment + missing = [r.name for r in results if r.name not in comment] + assert missing, "expected this many findings not to fit the comment" + + full, _ = render_diff_report(results, "base", "head", full=True) + assert "finding(s) omitted" not in full + for name in missing: + assert name in full + + def test_the_full_report_elides_nothing_within_a_finding(self): + # dropping whole findings is not the only thing the comment does to fit: + # it also clips each body. the artifact keeps them whole + results = [ + project( + "huge", "error-changed", diagnostics(binary="by", sha="a", count=5_000) + ) + ] + comment, _ = render_diff_report(results, "base", "head") + full, _ = render_diff_report(results, "base", "head", full=True) + assert "characters elided" in comment + assert "characters elided" not in full + assert len(full) > len(comment) + + def test_the_notice_names_the_artifact_the_workflow_uploads(self): + # the notice used to send the reader to the `comment.md` artifact, which is + # the truncated comment itself — the findings it promised were nowhere + results = [ + project( + f"noise{i}", "error-changed", panic(thread=i, first_line=1, frames=900) + ) + for i in range(200) + ] + comment, _ = render_diff_report(results, "base", "head") + assert f"`{FULL_REPORT_ARTIFACT}` artifact" in comment + + workflow = ( + Path(__file__).parent.parent + / ".github/workflows/by-ecosystem-roundtrip.yaml" + ).read_text() + assert f"name: {FULL_REPORT_ARTIFACT}" in workflow + assert f"--render-full {FULL_REPORT_ARTIFACT}" in workflow + def test_a_small_report_is_not_truncated(self): body, _ = render_diff_report( [project("a", "error-changed", "base: x\nhead: y")], "base", "head" diff --git a/scripts/test_check_ecosystem_roundtrip.py.lock b/scripts/test_check_ecosystem_roundtrip.py.lock new file mode 100644 index 0000000000..14ebd1ff6e --- /dev/null +++ b/scripts/test_check_ecosystem_roundtrip.py.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +requirements = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] diff --git a/scripts/transform_readme.py b/scripts/transform_readme.py index 6bede6f54c..602db470c4 100644 --- a/scripts/transform_readme.py +++ b/scripts/transform_readme.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Transform the README.md to support a specific deployment target. By default, we assume that our README.md will be rendered on GitHub. However, different diff --git a/scripts/transform_readme.py.lock b/scripts/transform_readme.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/transform_readme.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/ty_benchmark/.npmrc b/scripts/ty_benchmark/.npmrc new file mode 100644 index 0000000000..a22c5b4c3b --- /dev/null +++ b/scripts/ty_benchmark/.npmrc @@ -0,0 +1,3 @@ +engine-strict = true +ignore-scripts = true +min-release-age = 7 diff --git a/scripts/ty_benchmark/README.md b/scripts/ty_benchmark/README.md index 7ec8b915f9..0da4259f4e 100644 --- a/scripts/ty_benchmark/README.md +++ b/scripts/ty_benchmark/README.md @@ -5,6 +5,7 @@ - Windows: `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` 1. Build ty: `cargo build --bin ty --release` 1. `cd` into the benchmark directory: `cd scripts/ty_benchmark` +1. Install npm 11.10.0 or newer, which supports the dependency cooldown in `.npmrc` 1. Install Pyright: `npm ci --ignore-scripts` 1. Run benchmarks: `uv run benchmark` diff --git a/scripts/ty_benchmark/package-lock.json b/scripts/ty_benchmark/package-lock.json index 674bef644b..5300b1cc75 100644 --- a/scripts/ty_benchmark/package-lock.json +++ b/scripts/ty_benchmark/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "pyright": "1.1.411" + }, + "engines": { + "npm": ">=11.10.0" } }, "node_modules/fsevents": { diff --git a/scripts/ty_benchmark/package.json b/scripts/ty_benchmark/package.json index 8152ea2434..52eea38ebf 100644 --- a/scripts/ty_benchmark/package.json +++ b/scripts/ty_benchmark/package.json @@ -1,6 +1,9 @@ { "name": "ty_benchmark", "version": "0.0.0", + "engines": { + "npm": ">=11.10.0" + }, "dependencies": { "pyright": "1.1.411" } diff --git a/scripts/ty_benchmark/pyproject.toml b/scripts/ty_benchmark/pyproject.toml index 8b22bd693e..36821b5de3 100644 --- a/scripts/ty_benchmark/pyproject.toml +++ b/scripts/ty_benchmark/pyproject.toml @@ -20,11 +20,8 @@ dependencies = [ benchmark = "benchmark.run:main" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/benchmark"] +requires = ["uv_build>=0.12.3,<0.13"] +build-backend = "uv_build" [tool.ruff.lint] ignore = [ @@ -40,6 +37,16 @@ possibly-unresolved-reference = "error" division-by-zero = "error" unused-ignore-comment = "error" +[tool.uv] +no-build = true +no-binary-package = ["ty-benchmark"] +# Pin the isolated build environment for source-build exceptions. +build-constraint-dependencies = ["uv-build==0.12.3"] +exclude-newer = "P7D" + +[tool.uv.build-backend] +module-name = "benchmark" + # these files come from upstream ruff, where a call written for its effect alone is # ordinary style. writing the discard out at each site would put a conflict in every # one of them on the next upstream sync, so the rule is off for them rather than for diff --git a/scripts/ty_benchmark/uv.lock b/scripts/ty_benchmark/uv.lock index dd30a3fb58..ab092b57f3 100644 --- a/scripts/ty_benchmark/uv.lock +++ b/scripts/ty_benchmark/uv.lock @@ -2,6 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.14" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[manifest] +build-constraints = [{ name = "uv-build", specifier = "==0.12.3" }] + [[package]] name = "attrs" version = "26.1.0" diff --git a/scripts/update_ambiguous_characters.py b/scripts/update_ambiguous_characters.py index c08ca58c90..d62da6c59f 100644 --- a/scripts/update_ambiguous_characters.py +++ b/scripts/update_ambiguous_characters.py @@ -1,7 +1,26 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Generate the confusables.rs file from the VS Code ambiguous.json file.""" from __future__ import annotations +import itertools import json import subprocess from pathlib import Path @@ -34,7 +53,8 @@ def get_mapping_data() -> dict[str, list[int]]: encoding="utf-8", ) # The content is a JSON object literal wrapped in a JSON string, so double decode: - return json.loads(json.loads(content)) + mapping_data: dict[str, list[int]] = json.loads(json.loads(content)) + return mapping_data def format_number(number: int) -> str: @@ -62,10 +82,9 @@ def format_confusables_rs(raw_data: dict[str, list[int]]) -> str: """Format the downloaded data into a Rust source file.""" # The input data contains duplicate entries. flattened_items: set[tuple[int, int]] = set() - for _category, items in raw_data.items(): + for items in raw_data.values(): assert len(items) % 2 == 0, "Expected pairs of items" - for i in range(0, len(items), 2): - flattened_items.add((items[i], items[i + 1])) + flattened_items.update(itertools.batched(items, 2, strict=True)) tuples = [ f" {format_number(left)} => '{format_char(right)}',\n" diff --git a/scripts/update_ambiguous_characters.py.lock b/scripts/update_ambiguous_characters.py.lock new file mode 100644 index 0000000000..a1f7963bfd --- /dev/null +++ b/scripts/update_ambiguous_characters.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/update_schemastore.py b/scripts/update_schemastore.py index dc4d08bf3b..25b109d963 100644 --- a/scripts/update_schemastore.py +++ b/scripts/update_schemastore.py @@ -1,3 +1,21 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.ty.rules] +# blanket-ignore-comment = "warn" +# missing-type-argument = "warn" +# possibly-unresolved-reference = "warn" +# unsound-return-statement = "warn" +# unsound-yield = "warn" +# unsupported-dynamic-base = "warn" +# division-by-zero = "warn" +# +# [tool.uv] +# no-build = true +# exclude-newer = "P7D" +# /// + """Update ruff.json in schemastore. This script will clone `astral-sh/schemastore`, update the schema and push the changes @@ -6,7 +24,7 @@ Usage: - uv run --only-dev scripts/update_schemastore.py + uv run --script scripts/update_schemastore.py """ from __future__ import annotations diff --git a/scripts/update_schemastore.py.lock b/scripts/update_schemastore.py.lock new file mode 100644 index 0000000000..b51f89b1a9 --- /dev/null +++ b/scripts/update_schemastore.py.lock @@ -0,0 +1,15 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" diff --git a/scripts/uv.lock b/scripts/uv.lock deleted file mode 100644 index af36e00d8b..0000000000 --- a/scripts/uv.lock +++ /dev/null @@ -1,279 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "anyio" -version = "4.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - -[[package]] -name = "mdformat" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mypy-primer" -version = "0.1.0" -source = { git = "https://github.com/hauntsaninja/mypy_primer#23bbdd55fea37ca2489043d1327dbe35c4fc7083" } - -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "scripts" -version = "0.0.1" -source = { virtual = "." } -dependencies = [ - { name = "httpx" }, - { name = "mdformat" }, - { name = "mypy-primer" }, - { name = "pygments" }, - { name = "pyyaml" }, - { name = "stdlibs" }, - { name = "tqdm" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx" }, - { name = "mdformat" }, - { name = "mypy-primer", git = "https://github.com/hauntsaninja/mypy_primer" }, - { name = "pygments" }, - { name = "pyyaml" }, - { name = "stdlibs" }, - { name = "tqdm" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest" }] - -[[package]] -name = "stdlibs" -version = "2026.2.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/cd/2710eaacaefc8be2f520b55c313498a50a295a8378e932c70d4ea34250aa/stdlibs-2026.2.26.tar.gz", hash = "sha256:10f911bdd8d3e45b452cc187b3527e6f9d288c8a943c5f973da94c71b2757d5b", size = 20203, upload-time = "2026-02-26T23:30:04.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/ec/b6a5a568d584659e037c8f53fc25acc79950ac32796b8861b2015446b7b2/stdlibs-2026.2.26-py3-none-any.whl", hash = "sha256:3257486216eac5ac627a3a4c5665802aca72fe7fc9e4ab1f232b1fb47bfd3db6", size = 59288, upload-time = "2026-02-26T23:30:03.597Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] diff --git a/ty.schema.json b/ty.schema.json index dd9a35467a..1544a9d345 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -397,7 +397,7 @@ } }, "python": { - "description": "Path to your project's Python environment or interpreter.\n\nty uses the `site-packages` directory of your project's Python environment\nto resolve third-party (and, in some cases, first-party) imports in your code.\n\nThis can be a path to:\n\n- A Python interpreter, e.g. `.venv/bin/python3`\n- A virtual environment directory, e.g. `.venv`\n- A system Python [`sys.prefix`] directory, e.g. `/usr`\n\nIf you're using a project management tool such as uv, you should not generally need to\nspecify this option, as commands such as `uv run` will set the `VIRTUAL_ENV` environment\nvariable to point to your project's virtual environment. ty can also infer the location of\nyour environment from an activated Conda environment, and will look for a `.venv` directory\nin the project root if none of the above apply. Failing that, ty will look for a `python3`\nor `python` binary available in `PATH`.\n\n[`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix", + "description": "Path to your project's Python environment or interpreter.\n\nty uses the `site-packages` directory of your project's Python environment\nto resolve third-party (and, in some cases, first-party) imports in your code.\n\nThis can be a path to:\n\n- A Python interpreter, e.g. `.venv/bin/python3`\n- A virtual environment directory, e.g. `.venv`\n- A system Python [`sys.prefix`] directory, e.g. `/usr`\n\nIf you're using a project management tool such as uv, you should not generally need to\nspecify this option, as commands such as `uv run` will set the `VIRTUAL_ENV` environment\nvariable to point to your project's virtual environment. ty can also infer the location of\nyour environment from an activated Conda environment, and will look for a `.venv` directory\nin the project root if none of the above apply. Failing that, ty will look for a `python3`\nor `python` binary available in `PATH`.\n\nScripts with inline metadata use their own Python environment. They can use an explicitly\nconfigured environment, an activated environment, or an environment selected by the editor.\nUnlike projects, they do not automatically use a `.venv` directory.\n\n[`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix", "anyOf": [ { "$ref": "#/definitions/RelativePathBuf" @@ -419,7 +419,7 @@ ] }, "python-version": { - "description": "Specifies the version of Python that will be used to analyze the source code.\nThe version should be specified as a string in the format `M.m` where `M` is the major version\nand `m` is the minor (e.g. `\"3.7\"` or `\"3.12\"`).\nIf a version is provided, ty will generate errors if the source code makes use of language features\nthat are not supported in that version.\n\nty officially supports type checking code that targets Python 3.10 and later. Python 3.7\nthrough 3.9 can still be selected, but ty may produce false positives or false negatives for\nstandard-library APIs because its bundled stubs do not fully describe those versions.\n\nIf a version is not specified, ty will try the following techniques in order of preference\nto determine a value:\n1. Check for the `project.requires-python` setting in a `pyproject.toml` file\n and use the minimum version from the specified range\n2. Check for an activated or configured Python environment\n and attempt to infer the Python version of that environment\n3. Fall back to the default value (see below)\n\nFor some language features, ty can also understand conditionals based on comparisons\nwith `sys.version_info`. These are commonly found in typeshed, for example,\nto reflect the differing contents of the standard library across Python versions.", + "description": "Specifies the version of Python that will be used to analyze the source code.\nThe version should be specified as a string in the format `M.m` where `M` is the major version\nand `m` is the minor (e.g. `\"3.7\"` or `\"3.12\"`).\nIf a version is provided, ty will generate errors if the source code makes use of language features\nthat are not supported in that version.\n\nty officially supports type checking code that targets Python 3.10 and later. Python 3.7\nthrough 3.9 can still be selected, but ty may produce false positives or false negatives for\nstandard-library APIs because its bundled stubs do not fully describe those versions.\n\nIf a version is not specified, ty will try the following techniques in order of preference\nto determine a value:\n1. Check for the `project.requires-python` setting in a `pyproject.toml` file\n and use the minimum version from the specified range\n2. Check for an activated or configured Python environment\n and attempt to infer the Python version of that environment\n3. Fall back to the default value (see below)\n\nScripts with inline metadata use their `requires-python` field instead of\n`project.requires-python`. They do not inherit the Python version of the enclosing project.\n\nFor some language features, ty can also understand conditionals based on comparisons\nwith `sys.version_info`. These are commonly found in typeshed, for example,\nto reflect the differing contents of the standard library across Python versions.", "anyOf": [ { "$ref": "#/definitions/SupportedPythonVersion" @@ -430,7 +430,7 @@ ] }, "root": { - "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly.\nThe project root (`.`) is always included. Additionally, the following directories are included\nif they exist and are not packages (i.e. they do not contain `__init__.py` or `__init__.pyi` files):\n\n* `./src`\n* `./` (if a `.//` directory exists)\n* `./python`", + "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly.\nThe project root (`.`) is always included. Additionally, the following directories are included\nif they exist and are not packages (i.e. they do not contain `__init__.py` or `__init__.pyi` files):\n\n* `./src`\n* `./` (if a `.//` directory exists)\n* `./python`\n\nScripts with inline metadata have no first-party roots by default because they are\nsingle-file programs. Set `root = [\".\"]` to allow importing local modules.", "type": [ "array", "null" @@ -641,7 +641,7 @@ ] }, "RelativePathBuf": { - "description": "A possibly relative path in a configuration file.\n\nRelative paths in configuration files or from CLI options\nrequire different anchoring:\n\n* CLI: The path is relative to the current working directory\n* Configuration file: The path is relative to the project's root.", + "description": "A possibly relative path in a configuration file.\n\nRelative paths in configuration files or from CLI options\nrequire different anchoring:\n\n* CLI: The path is relative to the current working directory\n* Configuration file: The path is relative to the project's or script's configuration root.", "allOf": [ { "$ref": "#/definitions/SystemPathBuf" @@ -653,7 +653,7 @@ "properties": { "abstract-and-final-method": { "title": "detects methods that are both abstract and final", - "description": "## What it does\n\nChecks for methods decorated with both `@abstractmethod` and `@final`.\n\n## Why is this bad?\n\nAn abstract method must be overridden for a subclass to become concrete, but a final\nmethod cannot be overridden. Combining the decorators therefore makes it impossible\nfor a subclass to provide a concrete implementation.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\nfrom typing import final\n\n\nclass Base(ABC):\n @final\n @abstractmethod\n def method(self) -> None: ... # error\n```", + "description": "## What it does\n\nChecks for methods decorated with both `@abstractmethod` and `@final`.\n\n## Why is this bad?\n\nAn abstract method must be overridden for a subclass to become concrete, but a final method cannot\nbe overridden. Combining the decorators therefore makes it impossible for a subclass to provide a\nconcrete implementation.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\nfrom typing import final\n\n\nclass Base(ABC):\n @final\n @abstractmethod\n def method(self) -> None: ... # error\n```", "default": "error", "oneOf": [ { @@ -663,7 +663,7 @@ }, "abstract-method-in-final-class": { "title": "detects `@final` classes with unimplemented abstract methods", - "description": "## What it does\n\nChecks for `@final` classes that have unimplemented abstract methods.\n\n## Why is this bad?\n\nA class decorated with `@final` cannot be subclassed. If such a class has abstract\nmethods that are not implemented, the class can never be properly instantiated, as\nthe abstract methods can never be implemented (since subclassing is prohibited).\n\nAt runtime, instantiation of classes with unimplemented abstract methods is only\nprevented for classes that have `ABCMeta` (or a subclass of it) as their metaclass.\nHowever, type checkers also enforce this for classes that do not use `ABCMeta`, since\nthe intent for the class to be abstract is clear from the use of `@abstractmethod`.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\nfrom typing import final\n\n\nclass Base(ABC):\n @abstractmethod\n def method(self) -> int: ...\n\n\n@final\n# `Derived` does not implement `method`\nclass Derived(Base): # error\n pass\n```", + "description": "## What it does\n\nChecks for `@final` classes that have unimplemented abstract methods.\n\n## Why is this bad?\n\nA class decorated with `@final` cannot be subclassed. If such a class has abstract methods that are\nnot implemented, the class can never be properly instantiated, as the abstract methods can never be\nimplemented (since subclassing is prohibited).\n\nAt runtime, instantiation of classes with unimplemented abstract methods is only prevented for\nclasses that have `ABCMeta` (or a subclass of it) as their metaclass. However, type checkers also\nenforce this for classes that do not use `ABCMeta`, since the intent for the class to be abstract is\nclear from the use of `@abstractmethod`.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\nfrom typing import final\n\n\nclass Base(ABC):\n @abstractmethod\n def method(self) -> int: ...\n\n\n@final\n# `Derived` does not implement `method`\nclass Derived(Base): # error\n pass\n```", "default": "error", "oneOf": [ { @@ -712,7 +712,7 @@ }, "ambiguous-protocol-member": { "title": "detects protocol classes with ambiguous interfaces", - "description": "## What it does\n\nChecks for protocol classes with members that will lead to ambiguous interfaces.\n\n## Why is this bad?\n\nAssigning to an undeclared variable in a protocol class, or to an undeclared attribute\nthrough a protocol method's `self` or `cls` receiver, leads to an ambiguous interface\nwhich may lead to the type checker inferring unexpected things. It's recommended to\nensure that all members of a protocol class are explicitly declared.\n\n## Examples\n\n```py\nfrom typing import ClassVar, Protocol\n\n\nclass BaseProto(Protocol):\n a: int # fine (explicitly declared as `int`)\n instance_member: str\n class_member: ClassVar[str]\n\n # fine: a method definition using `def` is considered a declaration\n def method_member(self) -> int: ...\n\n def method(self) -> None:\n self.instance_member = \"value\" # fine (declared in the class body)\n self.implicit = \"value\" # error: [ambiguous-protocol-member]\n\n @classmethod\n def class_method(cls) -> None:\n cls.class_member = \"value\" # fine (declared in the class body)\n cls.implicit_class = \"value\" # error: [ambiguous-protocol-member]\n\n # no explicit declaration, leading to ambiguity\n c = \"some variable\" # error\n # no explicit declaration, leading to ambiguity\n b = method_member # error\n\n # This creates implicit assignments of `d` and `e` in the protocol class body.\n # Were they really meant to be considered protocol members?\n # error: \"`d` is not declared as a protocol member\"\n # error: \"`e` is not declared as a protocol member\"\n for d, e in enumerate(range(42)):\n pass\n\n\nclass SubProto(BaseProto, Protocol):\n a = 42 # fine (declared in superclass)\n```", + "description": "## What it does\n\nChecks for protocol classes with members that will lead to ambiguous interfaces.\n\n## Why is this bad?\n\nAssigning to an undeclared variable in a protocol class, or to an undeclared attribute through a\nprotocol method's `self` or `cls` receiver, leads to an ambiguous interface which may lead to the\ntype checker inferring unexpected things. It's recommended to ensure that all members of a protocol\nclass are explicitly declared.\n\n## Examples\n\n```py\nfrom typing import ClassVar, Protocol\n\n\nclass BaseProto(Protocol):\n a: int # fine (explicitly declared as `int`)\n instance_member: str\n class_member: ClassVar[str]\n\n # fine: a method definition using `def` is considered a declaration\n def method_member(self) -> int: ...\n\n def method(self) -> None:\n self.instance_member = \"value\" # fine (declared in the class body)\n self.implicit = \"value\" # error: [ambiguous-protocol-member]\n\n @classmethod\n def class_method(cls) -> None:\n cls.class_member = \"value\" # fine (declared in the class body)\n cls.implicit_class = \"value\" # error: [ambiguous-protocol-member]\n\n # no explicit declaration, leading to ambiguity\n c = \"some variable\" # error\n # no explicit declaration, leading to ambiguity\n b = method_member # error\n\n # This creates implicit assignments of `d` and `e` in the protocol class body.\n # Were they really meant to be considered protocol members?\n # error: \"`d` is not declared as a protocol member\"\n # error: \"`e` is not declared as a protocol member\"\n for d, e in enumerate(range(42)):\n pass\n\n\nclass SubProto(BaseProto, Protocol):\n a = 42 # fine (declared in superclass)\n```", "default": "warn", "oneOf": [ { @@ -722,7 +722,7 @@ }, "assert-type-unspellable-subtype": { "title": "detects failed type assertions", - "description": "## What it does\n\nChecks for `assert_type()` calls where the actual type\nis an unspellable subtype of the asserted type.\n\n## Why is this bad?\n\n`assert_type()` is intended to ensure that the inferred type of a value\nis exactly the same as the asserted type. But in some situations, ty\nhas nonstandard extensions to the type system that allow it to infer\nmore precise types than can be expressed in user annotations. ty emits a\ndifferent error code to `type-assertion-failure` in these situations so\nthat users can easily differentiate between the two cases.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```python\nfrom typing import assert_type\n\n\ndef _(x: int):\n assert_type(x, int) # fine\n if x:\n # the actual type is `int & ~AlwaysFalsy`,\n # which excludes types like `Literal[0]`\n # error: [assert-type-unspellable-subtype]\n assert_type(x, int)\n```", + "description": "## What it does\n\nChecks for `assert_type()` calls where the actual type is an unspellable subtype of the asserted\ntype.\n\n## Why is this bad?\n\n`assert_type()` is intended to ensure that the inferred type of a value is exactly the same as the\nasserted type. But in some situations, ty has nonstandard extensions to the type system that allow\nit to infer more precise types than can be expressed in user annotations. ty emits a different error\ncode to `type-assertion-failure` in these situations so that users can easily differentiate between\nthe two cases.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```python\nfrom typing import assert_type\n\n\ndef _(x: int):\n assert_type(x, int) # fine\n if x:\n # the actual type is `int & ~AlwaysFalsy`,\n # which excludes types like `Literal[0]`\n # error: [assert-type-unspellable-subtype]\n assert_type(x, int)\n```", "default": "error", "oneOf": [ { @@ -732,7 +732,7 @@ }, "blanket-ignore-comment": { "title": "detects blanket `ty: ignore` comments", - "description": "## What it does\n\nChecks for `ty: ignore` comments that don't specify which rules to ignore.\n\n## Why is this bad?\n\nA blanket `ty: ignore` comment suppresses every type-checking diagnostic on the\napplicable line or file. Specifying rule codes documents which diagnostics are\nexpected and prevents the comment from silencing unrelated errors.\n\n## Examples\n\n```py\n# error\nvalue = unknown # ty: ignore\n```\n\nUse instead:\n\n```py\nvalue = unknown # ty: ignore[unresolved-reference]\n```", + "description": "## What it does\n\nChecks for `ty: ignore` comments that don't specify which rules to ignore.\n\n## Why is this bad?\n\nA blanket `ty: ignore` comment suppresses every type-checking diagnostic on the applicable line or\nfile. Specifying rule codes documents which diagnostics are expected and prevents the comment from\nsilencing unrelated errors.\n\n## Examples\n\n```py\n# error\nvalue = unknown # ty: ignore\n```\n\nUse instead:\n\n```py\nvalue = unknown # ty: ignore[unresolved-reference]\n```", "default": "warn", "oneOf": [ { @@ -752,7 +752,7 @@ }, "call-abstract-method": { "title": "detects calls to abstract methods with trivial bodies on class objects", - "description": "## What it does\n\nChecks for calls to abstract `@classmethod`s or `@staticmethod`s\nwith \"trivial bodies\" when accessed on the class object itself.\n\n\"Trivial bodies\" are bodies that solely consist of `...`, `pass`,\na docstring, and/or `raise NotImplementedError`.\n\n## Why is this bad?\n\nAn abstract method with a trivial body has no concrete implementation\nto execute, so calling such a method directly on the class will probably\nnot have the desired effect.\n\nIt is also unsound to call these methods directly on the class. Unlike\nother methods, ty permits abstract methods with trivial bodies to have\nnon-`None` return types even though they always return `None` at runtime.\nThis is because it is expected that these methods will always be\noverridden rather than being called directly. As a result of this\nexception to the normal rule, ty may infer an incorrect type if one of\nthese methods is called directly, which may then mean that type errors\nelsewhere in your code go undetected by ty.\n\nCalling abstract classmethods or staticmethods via `type[X]` is allowed,\nsince the actual runtime type could be a concrete subclass with an implementation.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\n\n\nclass Foo(ABC):\n @classmethod\n @abstractmethod\n def method(cls) -> int: ...\n\n\n# cannot call abstract classmethod\nFoo.method() # error\n```", + "description": "## What it does\n\nChecks for calls to abstract `@classmethod`s or `@staticmethod`s with \"trivial bodies\" when accessed\non the class object itself.\n\n\"Trivial bodies\" are bodies that solely consist of `...`, `pass`, a docstring, and/or\n`raise NotImplementedError`.\n\n## Why is this bad?\n\nAn abstract method with a trivial body has no concrete implementation to execute, so calling such a\nmethod directly on the class will probably not have the desired effect.\n\nIt is also unsound to call these methods directly on the class. Unlike other methods, ty permits\nabstract methods with trivial bodies to have non-`None` return types even though they always return\n`None` at runtime. This is because it is expected that these methods will always be overridden\nrather than being called directly. As a result of this exception to the normal rule, ty may infer an\nincorrect type if one of these methods is called directly, which may then mean that type errors\nelsewhere in your code go undetected by ty.\n\nCalling abstract classmethods or staticmethods via `type[X]` is allowed, since the actual runtime\ntype could be a concrete subclass with an implementation.\n\n## Example\n\n```python\nfrom abc import ABC, abstractmethod\n\n\nclass Foo(ABC):\n @classmethod\n @abstractmethod\n def method(cls) -> int: ...\n\n\n# cannot call abstract classmethod\nFoo.method() # error\n```", "default": "error", "oneOf": [ { @@ -772,7 +772,7 @@ }, "call-top-callable": { "title": "detects calls to the top callable type", - "description": "## What it does\n\nChecks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all\ncallable types with return type `T`).\n\n## Why is this bad?\n\nWhen an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or\n`isinstance(x, Callable)`), we know the object is callable, but we don't know its\nprecise signature. This type represents the set of all possible callable types\n(including, e.g., functions that take no arguments and functions that require arguments),\nso no specific set of arguments can be guaranteed to be valid.\n\n## Examples\n\n```python\ndef f(x: object):\n if callable(x):\n # We know `x` is callable, but not what arguments it accepts\n x() # error\n```", + "description": "## What it does\n\nChecks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all callable\ntypes with return type `T`).\n\n## Why is this bad?\n\nWhen an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or\n`isinstance(x, Callable)`), we know the object is callable, but we don't know its precise signature.\nThis type represents the set of all possible callable types (including, e.g., functions that take no\narguments and functions that require arguments), so no specific set of arguments can be guaranteed\nto be valid.\n\n## Examples\n\n```python\ndef f(x: object):\n if callable(x):\n # We know `x` is callable, but not what arguments it accepts\n x() # error\n```", "default": "error", "oneOf": [ { @@ -782,7 +782,7 @@ }, "conflicting-declarations": { "title": "detects conflicting declarations", - "description": "## What it does\n\nChecks whether a variable has been declared as two conflicting types.\n\n## Why is this bad\n\nA variable with two conflicting declarations likely indicates a mistake.\nMoreover, it could lead to incorrect or ill-defined type inference for\nother code that relies on these variables.\n\n## Examples\n\n```python\nif __name__ == \"__main__\":\n a: int\nelse:\n a: str\n\na = 1 # error\n```", + "description": "## What it does\n\nChecks whether a variable has been declared as two conflicting types.\n\n## Why is this bad\n\nA variable with two conflicting declarations likely indicates a mistake. Moreover, it could lead to\nincorrect or ill-defined type inference for other code that relies on these variables.\n\n## Examples\n\n```python\nif __name__ == \"__main__\":\n a: int\nelse:\n a: str\n\na = 1 # error\n```", "default": "error", "oneOf": [ { @@ -792,7 +792,7 @@ }, "conflicting-metaclass": { "title": "detects conflicting metaclasses", - "description": "## What it does\n\nChecks for class definitions where the metaclass of the class\nbeing created would not be a subclass of the metaclasses of\nall the class's bases.\n\n## Why is it bad?\n\nSuch a class definition raises a `TypeError` at runtime.\n\n## Examples\n\n```pyi\nclass M1(type): ...\nclass M2(type): ...\nclass A(metaclass=M1): ...\nclass B(metaclass=M2): ...\n\n# TypeError: metaclass conflict\nclass C(A, B): ... # error\n```", + "description": "## What it does\n\nChecks for class definitions where the metaclass of the class being created would not be a subclass\nof the metaclasses of all the class's bases.\n\n## Why is it bad?\n\nSuch a class definition raises a `TypeError` at runtime.\n\n## Examples\n\n```pyi\nclass M1(type): ...\nclass M2(type): ...\nclass A(metaclass=M1): ...\nclass B(metaclass=M2): ...\n\n# TypeError: metaclass conflict\nclass C(A, B): ... # error\n```", "default": "error", "oneOf": [ { @@ -802,7 +802,7 @@ }, "cyclic-class-definition": { "title": "detects cyclic class definitions", - "description": "## What it does\n\nChecks for class definitions in stub files that inherit\n(directly or indirectly) from themselves.\n\n## Why is it bad?\n\nAlthough forward references are natively supported in stub files,\ninheritance cycles are still disallowed, as it is impossible to\nresolve a consistent [method resolution order] for a class that\ninherits from itself.\n\n## Examples\n\n`foo.pyi`:\n\n```pyi\nclass A(B): ... # error\nclass B(A): ... # error\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", + "description": "## What it does\n\nChecks for class definitions in stub files that inherit (directly or indirectly) from themselves.\n\n## Why is it bad?\n\nAlthough forward references are natively supported in stub files, inheritance cycles are still\ndisallowed, as it is impossible to resolve a consistent [method resolution order] for a class that\ninherits from itself.\n\n## Examples\n\n`foo.pyi`:\n\n```pyi\nclass A(B): ... # error\nclass B(A): ... # error\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", "default": "error", "oneOf": [ { @@ -812,7 +812,7 @@ }, "cyclic-type-alias-definition": { "title": "detects cyclic type alias definitions", - "description": "## What it does\n\nChecks for type alias definitions that (directly or mutually) refer to themselves.\n\n## Why is it bad?\n\nAlthough it is permitted to define a recursive type alias, it is not meaningful\nto have a type alias whose expansion can only result in itself, and is therefore not allowed.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\ntype Itself = Itself # error\n\ntype A = B # error\ntype B = A # error\n```", + "description": "## What it does\n\nChecks for circular type alias definitions.\n\n## Why is it bad?\n\nRecursive aliases are valid when recursive references occur inside another type, such as\n`list[Tree]`. An alias cannot expand directly to itself or include itself as a union member. This\napplies to both `type` statements and aliases created with `TypeAliasType`.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import TypeAliasType\n\ntype Itself = Itself # error\n\ntype A = B # error\ntype B = A # error\n\ntype IntOr = int | IntOr # error\n\nCycle = TypeAliasType(\"Cycle\", \"Cycle\") # error\n\ntype Tree = int | list[Tree] # valid recursive alias\n```", "default": "error", "oneOf": [ { @@ -822,7 +822,7 @@ }, "dataclass-field-order": { "title": "detects dataclass definitions with required fields after fields with default values", - "description": "## What it does\n\nChecks for dataclass definitions where required fields are defined after\nfields with default values.\n\n## Why is this bad?\n\nIn dataclasses, all required fields (fields without default values) must be\ndefined before fields with default values. This is a Python requirement that\nwill raise a `TypeError` at runtime if violated.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Example:\n x: int = 1 # Field with default value\n # Required field after field with default\n y: str # error\n```", + "description": "## What it does\n\nChecks for dataclass definitions where required fields are defined after fields with default values.\n\n## Why is this bad?\n\nIn dataclasses, all required fields (fields without default values) must be defined before fields\nwith default values. This is a Python requirement that will raise a `TypeError` at runtime if\nviolated.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Example:\n x: int = 1 # Field with default value\n # Required field after field with default\n y: str # error\n```", "default": "error", "oneOf": [ { @@ -840,9 +840,19 @@ } ] }, + "disjoint-cast": { + "title": "detects `cast` calls between disjoint types", + "description": "## What it does\n\nDetects `cast` calls where the inferred type of the value is disjoint from the destination type.\n\nTwo types are disjoint if they are entirely non-overlapping. For example, `str` and `int` are\ndisjoint types because it is impossible to create a Python object that is both a `str` and an `int`\nat the same time: Python forbids multiple inheritance between these two classes:\n\n```pycon\n>>> class StrAndInt(int, str): ...\nTraceback (most recent call last):\n File \"\", line 1, in \n class StrAndInt(int, str): ...\nTypeError: multiple bases have instance lay-out conflict\n```\n\nThis means that any object of type `int` can never also be of type `str`, and any object of type\n`str` can never also inhabit the type `int`. The only common subtype of these two types is\n[`Never`][never], the uninhabited type, which has no members.\n\n## Why is this bad?\n\n`cast()` is deliberately designed as an \"escape hatch\" in the type system that is neither validated\nat runtime nor, by default, by type checkers. While upcasting to a supertype is always sound, and\ncasting to a subtype can be sound in some situations if accompanied by careful validation checks,\n`cast()` is also deliberately designed to allow unsound narrowing, and most useful applications of\n`cast()` in real-world code cannot be fully validated by a type checker.\n\nNonetheless, even while acknowledging the fact that `cast()` is intentionally designed to allow\nunsoundness, casting a value to an entirely *disjoint* type is especially likely to indicate a\nmistake in your code. A cast from an `int` to a `str`, for example, likely indicates a bug or\nmisunderstanding.\n\nThis rule therefore provides a means for codebases to partially validate their uses of `cast()`\nwithout banning the API -- or even banning all unsound uses of the API -- entirely.\n\n## Example\n\n```py\nfrom typing import cast\n\n\ndef parse(value: int) -> str:\n return cast(str, value) # error: [disjoint-cast]\n```\n\nCasts between overlapping (non-disjoint) types are allowed:\n\n```py\nfrom collections.abc import Sequence\nfrom typing import cast\n\n\ndef validate(numbers: Sequence[int | None]) -> Sequence[int]:\n if None in numbers:\n raise TypeError(\"must provide a sequence of numbers!\")\n return cast(Sequence[int], numbers)\n```\n\nNote that disjointness between types can sometimes be surprising. For example, `list[int]` is\ndisjoint from `list[bool]` even though `bool` is a subtype of `int`. Due to the fact that `list` is\n[mutable and invariant], it would be deeply unsound for ty to ever narrow an object of type\n`list[int]` to the type `list[bool]`. As such, ty will complain about a cast from `list[int]` to\n`list[bool]` when this rule is enabled.\n\nSimilarly, two `NewType`s can be disjoint even when they share the same underlying nominal base\ntype, unless one `NewType` is explicitly declared as a sub-newtype of the other.\n\n```py\nfrom typing import NewType, cast\n\n\nUserId = NewType(\"UserId\", int)\nProUserId = NewType(\"ProUserId\", int)\n\n\ndef f(x: list[int], user_id: UserId):\n y = cast(list[bool], x) # error: [disjoint-cast]\n pro_user_id = cast(ProUserId, user_id) # error: [disjoint-cast]\n```\n\n## Alternatives\n\nIn many cases, the diagnostic can be avoided by switching to use covariant generic types rather than\ninvariant ones:\n\n```py\n# `Sequence`, unlike `list`, is immutable and covariant\nfrom collections.abc import Sequence\nfrom typing import cast\n\n\ndef f(x: Sequence[int]):\n y = cast(Sequence[bool], x) # no diagnostic\n```\n\nThough if you're able to use covariant types, a type-safe narrowing mechanism that provides runtime\nvalidation, such as using `TypeIs`, is generally preferable to using `cast`:\n\n```py\n# `Sequence`, unlike `list`, is immutable and covariant\nfrom collections.abc import Sequence\nfrom typing_extensions import TypeIs, reveal_type\n\n\ndef is_sequence_of_bools(x: Sequence[int]) -> TypeIs[Sequence[bool]]:\n return all(isinstance(item, bool) for item in x)\n\n\ndef f(x: Sequence[int]):\n assert is_sequence_of_bools(x)\n reveal_type(x) # revealed: Sequence[bool]\n```\n\nIf you're unable to switch to an immutable, covariant generic type, other solutions to this\nparticular diagnostic might include assigning a new list altogether:\n\n```py\ndef f(x: list[int]):\n y: list[bool] = []\n for item in x:\n assert isinstance(item, bool)\n y.append(item)\n```\n\nOr using a `TypeGuard`. While the \"narrowing\" below is still unsound, there is at least some runtime\nvalidation of the element types taking place, making it superior to the `cast`:\n\n```py\nfrom typing_extensions import TypeGuard, reveal_type\n\n\ndef is_list_of_bools(x: list[int]) -> TypeGuard[list[bool]]:\n return all(isinstance(item, bool) for item in x)\n\n\ndef f(x: list[int]):\n assert is_list_of_bools(x)\n reveal_type(x) # revealed: list[bool]\n```\n\n## Default level\n\nThis rule is disabled by default. It is designed as a strict rule for users who want additional\nsoundness checks from their type checker, and it may have false positives in some situations.\n\n## See also\n\n- The Ruff rule [`banned-api`][banned-api] can be used to ban the use of `cast()` entirely in your\n codebase.\n- `redundant-cast` detects casts where the value already has the destination type.\n\n[banned-api]: https://docs.astral.sh/ruff/rules/banned-api/\n[mutable and invariant]: https://docs.astral.sh/ty/reference/typing-faq/#invariant-generics\n[never]: https://docs.python.org/3/library/typing.html#typing.Never", + "default": "ignore", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "division-by-zero": { "title": "detects division by zero", - "description": "## What it does\n\nIt detects division by zero.\n\n## Why is this bad?\n\nDividing by zero raises a `ZeroDivisionError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of\nfalse positives it can produce.\n\n## Examples\n\n```python\n5 / 0 # error\n```", + "description": "## What it does\n\nIt detects division by zero.\n\n## Why is this bad?\n\nDividing by zero raises a `ZeroDivisionError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of false positives it can produce.\n\n## Examples\n\n```python\n5 / 0 # error\n```", "default": "error", "oneOf": [ { @@ -862,7 +872,7 @@ }, "duplicate-kw-only": { "title": "detects dataclass definitions with more than one usage of `KW_ONLY`", - "description": "## What it does\n\nChecks for dataclass definitions with more than one field\nannotated with `KW_ONLY`.\n\n## Why is this bad?\n\n`dataclasses.KW_ONLY` is a special marker used to\nemulate the `*` syntax in normal signatures.\nIt can only be used once per dataclass.\n\nAttempting to annotate two different fields with\nit will lead to a runtime error.\n\n## Examples\n\n```python\nfrom dataclasses import dataclass, KW_ONLY\n\n\n# Crash at runtime\n@dataclass\nclass A: # error\n b: int\n _1: KW_ONLY\n c: str\n _2: KW_ONLY\n d: bytes\n```", + "description": "## What it does\n\nChecks for dataclass definitions with more than one field annotated with `KW_ONLY`.\n\n## Why is this bad?\n\n`dataclasses.KW_ONLY` is a special marker used to emulate the `*` syntax in normal signatures. It\ncan only be used once per dataclass.\n\nAttempting to annotate two different fields with it will lead to a runtime error.\n\n## Examples\n\n```python\nfrom dataclasses import dataclass, KW_ONLY\n\n\n# Crash at runtime\n@dataclass\nclass A: # error\n b: int\n _1: KW_ONLY\n c: str\n _2: KW_ONLY\n d: bytes\n```", "default": "error", "oneOf": [ { @@ -870,9 +880,19 @@ } ] }, + "dynamic-function-decorator-return": { + "title": "detects decorators that replace a function with a dynamic type such as `Any`", + "description": "## What it does\n\nDetects decorator applications that replace a function with `Any` or another [dynamic type].\n\n## Why is this bad?\n\nA decorator can replace the function it receives with any object. Type checkers therefore use the\ndecorator's return type as the type of the decorated function. If the decorator returns `Any` or\n`Unknown`, the original type is lost, along with the type checker's ability to catch invalid calls\nand attribute accesses. basedpython infers an unannotated return, so a decorator reaches this state\nby saying `Any` outright, or by coming from code the checker cannot read:\n\n```py\nfrom collections.abc import Callable\nfrom typing import Any\n\n\ndef untyped_decorator(function: Callable[..., object]) -> Any:\n return function\n\n\n# error: \"Decorator returns `Any`\"\n@untyped_decorator\ndef stringify(value: int) -> str:\n return str(value)\n\n\n# No type error is reported, even though `stringify` expects an integer.\nstringify(\"not an integer\")\n```\n\nThis rule identifies the point where a decorator erases useful type information, before that\nimprecision spreads to every use of the decorated function. It can be especially useful in cases\nwhere the decorator is defined in a third-party library. Whereas linter rules such as\n[`ANN201`][ann201] and [`ANN202`][ann202] can complain about missing annotations in your first-party\ncode, they cannot identify instances where unsound types leak into your code due to missing type\nannotations in third-party code installed into `site-packages`.\n\n## Examples\n\n`third_party_library.py`:\n\n```py\nfrom collections.abc import Callable\nfrom typing import Any\n\n\ndef untyped_decorator(function: Callable[..., object]) -> Any:\n return function\n```\n\n`first_party.py`:\n\n```py\nfrom third_party_library import untyped_decorator\n\n\n# error: \"Decorator returns `Any`\"\n@untyped_decorator\ndef greet(name: str) -> str:\n return f\"Hello, {name}!\"\n```\n\nIf making a PR to the third-party library to improve their annotations is not possible, fixes for\nthis diagnostic could include writing your own decorator or introducing a type-safe wrapper:\n\n```py\nfrom collections.abc import Callable\nfrom typing import TypeVar\n\nfrom third_party_library import untyped_decorator\n\n\nFunctionT = TypeVar(\"FunctionT\", bound=Callable[..., object])\n\n\ndef typed_wrapper(f: FunctionT) -> FunctionT:\n decorated = untyped_decorator(f)\n assert decorated is f\n return decorated\n\n\n@typed_wrapper\ndef greet(name: str) -> str:\n return f\"Hello, {name}!\"\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for advanced users wanting additional soundness\nchecks from their type checker, not for users who have just started to use type checkers on their\nPython code.\n\n[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/\n[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/\n[dynamic type]: https://typing.python.org/en/latest/spec/glossary.html#term-dynamic-type", + "default": "ignore", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "empty-body": { "title": "detects functions with empty bodies that have a non-`None` return type annotation", - "description": "## What it does\n\nDetects functions with empty bodies that have a non-`None` return type annotation.\n\nThe errors reported by this rule have the same motivation as the `invalid-return-type`\nrule. The diagnostic exists as a separate error code to allow users to disable this\nrule while prototyping code. While we strongly recommend enabling this rule if\npossible, users migrating from other type checkers may also find it useful to\ntemporarily disable this rule on some or all of their codebase if they find it\nresults in a large number of diagnostics.\n\n## Why is this bad?\n\nA function with an empty body (containing only `...`, `pass`, or a docstring) will\nimplicitly return `None` at runtime. Returning `None` when the return type is non-`None`\nis unsound, and will lead to ty inferring incorrect types elsewhere.\n\nFunctions with empty bodies are permitted in certain contexts where they serve as\ndeclarations rather than implementations:\n\n- Functions in stub files (`.pyi`)\n- Methods in Protocol classes\n- Abstract methods decorated with `@abstractmethod`\n- Overload declarations decorated with `@overload`\n- Functions in `if TYPE_CHECKING` blocks\n\n## Examples\n\n```python\ndef foo() -> int: ... # error: [empty-body]\n\n\ndef bar() -> str: # error: [empty-body]\n \"\"\"A function that does nothing.\"\"\"\n pass\n```", + "description": "## What it does\n\nDetects functions with empty bodies that have a non-`None` return type annotation.\n\nThe errors reported by this rule have the same motivation as the `invalid-return-type` rule. The\ndiagnostic exists as a separate error code to allow users to disable this rule while prototyping\ncode. While we strongly recommend enabling this rule if possible, users migrating from other type\ncheckers may also find it useful to temporarily disable this rule on some or all of their codebase\nif they find it results in a large number of diagnostics.\n\n## Why is this bad?\n\nA function with an empty body (containing only `...`, `pass`, or a docstring) will implicitly return\n`None` at runtime. Returning `None` when the return type is non-`None` is unsound, and will lead to\nty inferring incorrect types elsewhere.\n\nFunctions with empty bodies are permitted in certain contexts where they serve as declarations\nrather than implementations:\n\n- Functions in stub files (`.pyi`)\n- Methods in Protocol classes\n- Abstract methods decorated with `@abstractmethod`\n- Overload declarations decorated with `@overload`\n- Functions in `if TYPE_CHECKING` blocks\n\n## Examples\n\n```python\ndef foo() -> int: ... # error: [empty-body]\n\n\ndef bar() -> str: # error: [empty-body]\n \"\"\"A function that does nothing.\"\"\"\n pass\n```", "default": "error", "oneOf": [ { @@ -942,7 +962,7 @@ }, "final-on-non-method": { "title": "detects `@final` applied to non-method functions", - "description": "## What it does\n\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\n\nThe `@final` decorator is only meaningful on methods and classes.\nApplying it to a module-level function or a nested function has no\neffect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n\n# @final is not allowed on non-method functions\n@final # error\ndef my_function() -> int:\n return 0\n```", + "description": "## What it does\n\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\n\nThe `@final` decorator is only meaningful on methods and classes. Applying it to a module-level\nfunction or a nested function has no effect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n\n# @final is not allowed on non-method functions\n@final # error\ndef my_function() -> int:\n return 0\n```", "default": "error", "oneOf": [ { @@ -962,7 +982,7 @@ }, "final-without-value": { "title": "detects `Final` declarations without a value", - "description": "## What it does\n\nChecks for `Final` symbols that are declared without a value and are never\nassigned a value in their scope.\n\n## Why is this bad?\n\nA `Final` symbol must be initialized with a value at the time of declaration\nor in a subsequent assignment. At module or function scope, the assignment must\noccur in the same scope. In a class body, the assignment may occur in `__init__`.\nProtocol members are declarations of an interface and do not require a value.\n\n## Examples\n\n```python\nfrom typing import Final\n\n# `Final` symbol without a value\nMY_CONSTANT: Final[int] # error\n\n# OK: `Final` symbol with a value\nINITIALIZED_CONSTANT: Final[int] = 1\n```", + "description": "## What it does\n\nChecks for `Final` symbols that are declared without a value and are never assigned a value in their\nscope.\n\n## Why is this bad?\n\nA `Final` symbol must be initialized with a value at the time of declaration or in a subsequent\nassignment. At module or function scope, the assignment must occur in the same scope. In a class\nbody, the assignment may occur in `__init__`. Protocol members are declarations of an interface and\ndo not require a value.\n\n## Examples\n\n```python\nfrom typing import Final\n\n# `Final` symbol without a value\nMY_CONSTANT: Final[int] # error\n\n# OK: `Final` symbol with a value\nINITIALIZED_CONSTANT: Final[int] = 1\n```", "default": "error", "oneOf": [ { @@ -972,7 +992,7 @@ }, "ignore-comment-unknown-rule": { "title": "detects `ty: ignore` comments that reference unknown rules", - "description": "## What it does\n\nChecks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule.\n\n## Why is this bad?\n\nA `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match\nany known rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n\n```py\n# error\na = 20 / 1 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```", + "description": "## What it does\n\nChecks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint\nrule.\n\n## Why is this bad?\n\nA `ty: ignore[code]` or a `type: ignore[ty:code]` directive with a `code` that doesn't match any\nknown rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n\n```py\n# error\na = 20 / 1 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```", "default": "warn", "oneOf": [ { @@ -992,7 +1012,7 @@ }, "implicit-declaration": { "title": "detects a variable that is assigned without being declared with `let` or `var`", - "description": "## What it does\n\nChecks for a variable that a basedpython file assigns without ever declaring it.\n\n## Why is this bad?\n\nPython introduces a variable by assigning to it, so a typo makes a new variable\nrather than an error, and reading a statement tells you nothing about whether\nthe name is new or one you have seen before.\n\nbasedpython has a keyword for each: `let` for a binding that never changes, and\n`var` for one that does. With this rule on, every variable a scope binds has to\nbe declared once with one of them, and every later assignment is visibly a\nre-assignment.\n\nThis rule is off by default, because a file written without the keywords is\nvalid basedpython.\n\n## Examples\n\nEvery assignment to a name the scope never declares is reported, so a variable\nintroduced this way is reported wherever it is written:\n\n`undeclared.by`:\n\n```by\ncount = 0 # error: [implicit-declaration]\ncount = count + 1 # error: [implicit-declaration]\n```\n\nDeclaring it once answers all of them:\n\n`declared.by`:\n\n```by\nvar count = 0\ncount = count + 1\n```\n\nAn assignment to something other than a plain name — an attribute, a subscript,\nan item of an unpacking — is not a declaration, and is never reported.", + "description": "## What it does\n\nChecks for a variable that a basedpython file assigns without ever declaring it.\n\n## Why is this bad?\n\nPython introduces a variable by assigning to it, so a typo makes a new variable rather than an\nerror, and reading a statement tells you nothing about whether the name is new or one you have seen\nbefore.\n\nbasedpython has a keyword for each: `let` for a binding that never changes, and `var` for one that\ndoes. With this rule on, every variable a scope binds has to be declared once with one of them, and\nevery later assignment is visibly a re-assignment.\n\nThis rule is off by default, because a file written without the keywords is valid basedpython.\n\n## Examples\n\nEvery assignment to a name the scope never declares is reported, so a variable introduced this way\nis reported wherever it is written:\n\n`undeclared.by`:\n\n```by\ncount = 0 # error: [implicit-declaration]\ncount = count + 1 # error: [implicit-declaration]\n```\n\nDeclaring it once answers all of them:\n\n`declared.by`:\n\n```by\nvar count = 0\ncount = count + 1\n```\n\nAn assignment to something other than a plain name — an attribute, a subscript, an item of an\nunpacking — is not a declaration, and is never reported.", "default": "ignore", "oneOf": [ { @@ -1022,7 +1042,7 @@ }, "index-out-of-bounds": { "title": "detects index out of bounds errors", - "description": "## What it does\n\nChecks for attempts to use an out of bounds index to get an item from\na container.\n\n## Why is this bad?\n\nUsing an out of bounds index will raise an `IndexError` at runtime.\n\n## Examples\n\n```python\nt = (0, 1, 2)\n# IndexError: tuple index out of range\nt[3] # error\n```", + "description": "## What it does\n\nChecks for attempts to use an out of bounds index to get an item from a container.\n\n## Why is this bad?\n\nUsing an out of bounds index will raise an `IndexError` at runtime.\n\n## Examples\n\n```python\nt = (0, 1, 2)\n# IndexError: tuple index out of range\nt[3] # error\n```", "default": "error", "oneOf": [ { @@ -1032,7 +1052,7 @@ }, "ineffective-final": { "title": "detects calls to `final()` that type checkers cannot interpret", - "description": "## What it does\n\nChecks for calls to `final()` that type checkers cannot interpret.\n\n## Why is this bad?\n\nThe `final()` function is designed to be used as a decorator. When called directly\nas a function (e.g., `final(type(...))`), type checkers will not understand the\napplication of `final` and will not prevent subclassing.\n\n## Example\n\n```python\nfrom typing import final\n\n# Incorrect: type checkers will not prevent subclassing\nMyClass = final(type(\"MyClass\", (), {})) # error\n\n\n# Correct: use `final` as a decorator\n@final\nclass MyClass: ...\n```", + "description": "## What it does\n\nChecks for calls to `final()` that type checkers cannot interpret.\n\n## Why is this bad?\n\nThe `final()` function is designed to be used as a decorator. When called directly as a function\n(e.g., `final(type(...))`), type checkers will not understand the application of `final` and will\nnot prevent subclassing.\n\n## Example\n\n```python\nfrom typing import final\n\n# Incorrect: type checkers will not prevent subclassing\nMyClass = final(type(\"MyClass\", (), {})) # error\n\n\n# Correct: use `final` as a decorator\n@final\nclass MyClass: ...\n```", "default": "warn", "oneOf": [ { @@ -1042,7 +1062,7 @@ }, "instance-layout-conflict": { "title": "detects class definitions that raise `TypeError` due to instance layout conflict", - "description": "## What it does\n\nChecks for classes definitions which will fail at runtime due to\n\"instance memory layout conflicts\".\n\nThis error is usually caused by attempting to combine multiple classes\nthat define non-empty `__slots__` in a class's [Method Resolution Order][method-resolution-order]\n(MRO), or by attempting to combine multiple builtin classes in a class's\nMRO.\n\n## Why is this bad?\n\nInheriting from bases with conflicting instance memory layouts\nwill lead to a `TypeError` at runtime.\n\nAn instance memory layout conflict occurs when CPython cannot determine\nthe memory layout instances of a class should have, because the instance\nmemory layout of one of its bases conflicts with the instance memory layout\nof one or more of its other bases.\n\nFor example, if a Python class defines non-empty `__slots__`, this will\nimpact the memory layout of instances of that class. Multiple inheritance\nfrom more than one different class defining non-empty `__slots__` is not\nallowed:\n\n```python\nclass A:\n __slots__ = (\"a\", \"b\")\n\n\nclass B:\n __slots__ = (\"a\", \"b\") # Even if the values are the same\n\n\n# TypeError: multiple bases have instance lay-out conflict\nclass C(A, B): ... # error\n```\n\nAn instance layout conflict can also be caused by attempting to use\nmultiple inheritance with two builtin classes, due to the way that these\nclasses are implemented in a CPython C extension:\n\n```python\n# TypeError: multiple bases have instance lay-out conflict\nclass A(int, float): ... # error\n```\n\nNote that pure-Python classes with no `__slots__`, or pure-Python classes\nwith empty `__slots__`, are always compatible:\n\n```python\nclass A: ...\n\n\nclass B:\n __slots__ = ()\n\n\nclass C:\n __slots__ = (\"a\", \"b\")\n\n\n# fine\nclass D(A, B, C): ...\n```\n\n## Known problems\n\nClasses that have \"dynamic\" definitions of `__slots__` (definitions do not consist\nof string literals, or tuples of string literals) are not currently considered disjoint\nbases by ty.\n\nAdditionally, this check is not exhaustive: many C extensions (including several in\nthe standard library) define classes that use extended memory layouts and thus cannot\ncoexist in a single MRO. Since it is currently not possible to represent this fact in\nstub files, having a full knowledge of these classes is also impossible. When it comes\nto classes that do not define `__slots__` at the Python level, therefore, ty, currently\nonly hard-codes a number of cases where it knows that a class will produce instances with\nan atypical memory layout.\n\n## Further reading\n\n- [CPython documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots)\n- [CPython documentation: Method Resolution Order](https://docs.python.org/3/glossary.html#term-method-resolution-order)\n\n[method-resolution-order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", + "description": "## What it does\n\nChecks for classes definitions which will fail at runtime due to \"instance memory layout conflicts\".\n\nThis error is usually caused by attempting to combine multiple classes that define non-empty\n`__slots__` in a class's [Method Resolution Order][method-resolution-order] (MRO), or by attempting\nto combine multiple builtin classes in a class's MRO.\n\n## Why is this bad?\n\nInheriting from bases with conflicting instance memory layouts will lead to a `TypeError` at\nruntime.\n\nAn instance memory layout conflict occurs when CPython cannot determine the memory layout instances\nof a class should have, because the instance memory layout of one of its bases conflicts with the\ninstance memory layout of one or more of its other bases.\n\nFor example, if a Python class defines non-empty `__slots__`, this will impact the memory layout of\ninstances of that class. Multiple inheritance from more than one different class defining non-empty\n`__slots__` is not allowed:\n\n```python\nclass A:\n __slots__ = (\"a\", \"b\")\n\n\nclass B:\n __slots__ = (\"a\", \"b\") # Even if the values are the same\n\n\n# TypeError: multiple bases have instance lay-out conflict\nclass C(A, B): ... # error\n```\n\nAn instance layout conflict can also be caused by attempting to use multiple inheritance with two\nbuiltin classes, due to the way that these classes are implemented in a CPython C extension:\n\n```python\n# TypeError: multiple bases have instance lay-out conflict\nclass A(int, float): ... # error\n```\n\nNote that pure-Python classes with no `__slots__`, or pure-Python classes with empty `__slots__`,\nare always compatible:\n\n```python\nclass A: ...\n\n\nclass B:\n __slots__ = ()\n\n\nclass C:\n __slots__ = (\"a\", \"b\")\n\n\n# fine\nclass D(A, B, C): ...\n```\n\n## Known problems\n\nClasses whose `__slots__` values cannot be determined statically are not always considered disjoint\nbases by ty. Static definitions can include string literals, fixed-length tuples, and literal lists,\nsets, or dictionaries of string literals.\n\nAdditionally, this check is not exhaustive: many C extensions (including several in the standard\nlibrary) define classes that use extended memory layouts and thus cannot coexist in a single MRO.\nSince it is currently not possible to represent this fact in stub files, having a full knowledge of\nthese classes is also impossible. When it comes to classes that do not define `__slots__` at the\nPython level, therefore, ty, currently only hard-codes a number of cases where it knows that a class\nwill produce instances with an atypical memory layout.\n\n## Further reading\n\n- [CPython documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots)\n- [CPython documentation: Method Resolution Order](https://docs.python.org/3/glossary.html#term-method-resolution-order)\n\n[method-resolution-order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", "default": "error", "oneOf": [ { @@ -1052,7 +1072,7 @@ }, "invalid-argument-type": { "title": "detects call arguments whose type is not assignable to the corresponding typed parameter", - "description": "## What it does\n\nDetects call arguments whose type is not assignable to the corresponding typed parameter.\n\n## Why is this bad?\n\nPassing an argument of a type the function (or callable object) does not accept violates\nthe expectations of the function author and may cause unexpected runtime errors within the\nbody of the function.\n\n## Examples\n\n```python\ndef func(x: int): ...\n\n\nfunc(\"foo\") # error: [invalid-argument-type]\n```", + "description": "## What it does\n\nDetects call arguments whose type is not assignable to the corresponding typed parameter.\n\n## Why is this bad?\n\nPassing an argument of a type the function (or callable object) does not accept violates the\nexpectations of the function author and may cause unexpected runtime errors within the body of the\nfunction.\n\n## Examples\n\n```python\ndef func(x: int): ...\n\n\nfunc(\"foo\") # error: [invalid-argument-type]\n```", "default": "error", "oneOf": [ { @@ -1062,7 +1082,7 @@ }, "invalid-assignment": { "title": "detects invalid assignments", - "description": "## What it does\n\nChecks for assignments where the type of the value\nis not [assignable to] the type of the assignee.\n\n## Why is this bad?\n\nSuch assignments break the rules of the type system and\nweaken a type checker's ability to accurately reason about your code.\n\n## Examples\n\n```python\na: int = \"\" # error\n```\n\n[assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable", + "description": "## What it does\n\nChecks for assignments where the type of the value is not [assignable to] the type of the assignee.\n\n## Why is this bad?\n\nSuch assignments break the rules of the type system and weaken a type checker's ability to\naccurately reason about your code.\n\n## Examples\n\n```python\na: int = \"\" # error\n```\n\n[assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable", "default": "error", "oneOf": [ { @@ -1072,7 +1092,7 @@ }, "invalid-attribute-access": { "title": "Invalid attribute access", - "description": "## What it does\n\nChecks for assignments to class variables from instances\nand assignments to instance-only attributes from their class.\n\nAn \"instance-only\" variable is one which is only ever assigned to or declared\nwhen accessed via `self` in an instance method.\n\n## Why is this bad?\n\nIncorrect assignments break the rules of the type system and\nweaken a type checker's ability to accurately reason about your code.\n\n## Examples\n\n```python\nfrom typing import ClassVar\n\n\nclass C:\n instance_var: int\n class_var: ClassVar[int] = 1\n\n def __init__(self):\n # instance variable declared in the class body\n self.instance_var = 42\n\n # instance-only variable not declared in the class body\n self.instance_only_var: int = 42\n\n\nC.class_var = 3 # okay\n\nC.instance_var = 56 # okay\nC().instance_var = 72 # okay\n\nC().instance_only_var = 100 # okay\n\n# Cannot assign to class variable from instance\nC().class_var = 3 # error\n\n# Cannot assign to instance-only variable from class\nC.instance_only_var = 56 # error\n```", + "description": "## What it does\n\nChecks for assignments to class variables from instances and assignments to instance-only attributes\nfrom their class. Also checks for reads and writes of generic instance attributes through a generic\nclass or a specialized generic alias.\n\nAn \"instance-only\" variable is one which is only ever assigned to or declared when accessed via\n`self` in an instance method.\n\nA generic instance attribute has a type that depends on the class's type parameters. Specializing a\ngeneric class does not create separate class attribute storage, so these attributes cannot be\naccessed through the generic class or a specialized alias. Access through a `type[...]` receiver is\nallowed because it can refer to a concrete subclass with its own class attributes.\n\n## Why is this bad?\n\nIncorrect assignments break the rules of the type system and weaken a type checker's ability to\naccurately reason about your code.\n\n## Examples\n\n```python\nfrom typing import ClassVar\n\n\nclass C:\n instance_var: int\n class_var: ClassVar[int] = 1\n\n def __init__(self):\n # instance variable declared in the class body\n self.instance_var = 42\n\n # instance-only variable not declared in the class body\n self.instance_only_var: int = 42\n\n\nC.class_var = 3 # okay\n\nC.instance_var = 56 # okay\nC().instance_var = 72 # okay\n\nC().instance_only_var = 100 # okay\n\n# Cannot assign to class variable from instance\nC().class_var = 3 # error\n\n# Cannot assign to instance-only variable from class\nC.instance_only_var = 56 # error\n```\n\n```python\nfrom typing import Generic, TypeVar\n\nT = TypeVar(\"T\")\n\n\nclass Box(Generic[T]):\n value: T\n\n\nBox[int].value = 1 # error\nBox.value # error\n\nbox = Box[int]()\nbox.value = 1 # okay\n```", "default": "error", "oneOf": [ { @@ -1082,7 +1102,7 @@ }, "invalid-attribute-override": { "title": "detects attribute overrides that change class-variable or instance-variable behavior", - "description": "## What it does\n\nDetects attribute overrides that change whether an inherited attribute\nis a class variable or an instance variable.\n\nThis rule currently only covers class-variable and instance-variable\ncategory changes.\n\n## Why is this bad?\n\nPure class variables and instance variables have different access and\nassignment behavior. Overriding one with the other violates the\n[Liskov Substitution Principle][liskov-substitution-principle] (\"LSP\"), because code that is valid for\nthe superclass may no longer be valid for the subclass.\n\n## Example\n\n```python\nfrom typing import ClassVar\n\n\nclass Base:\n instance_attr: int\n class_attr: ClassVar[int]\n\n\nclass Sub(Base):\n instance_attr: ClassVar[int] # error: [invalid-attribute-override]\n class_attr: int # error: [invalid-attribute-override]\n```\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle", + "description": "## What it does\n\nDetects attribute overrides that change whether an inherited attribute is a class variable or an\ninstance variable.\n\nThis rule currently only covers class-variable and instance-variable category changes.\n\n## Why is this bad?\n\nPure class variables and instance variables have different access and assignment behavior.\nOverriding one with the other violates the\n[Liskov Substitution Principle][liskov-substitution-principle] (\"LSP\"), because code that is valid\nfor the superclass may no longer be valid for the subclass.\n\n## Example\n\n```python\nfrom typing import ClassVar\n\n\nclass Base:\n instance_attr: int\n class_attr: ClassVar[int]\n\n\nclass Sub(Base):\n instance_attr: ClassVar[int] # error: [invalid-attribute-override]\n class_attr: int # error: [invalid-attribute-override]\n```\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle", "default": "error", "oneOf": [ { @@ -1132,7 +1152,7 @@ }, "invalid-context-manager": { "title": "detects expressions used in with statements that don't implement the context manager protocol", - "description": "## What it does\n\nChecks for expressions used in `with` statements\nthat do not implement the context manager protocol.\n\n## Why is this bad?\n\nSuch a statement will raise `TypeError` at runtime.\n\n## Examples\n\n```python\n# TypeError: 'int' object does not support the context manager protocol\nwith 1: # error\n print(2)\n```", + "description": "## What it does\n\nChecks for expressions used in `with` statements that do not implement the context manager protocol.\n\n## Why is this bad?\n\nSuch a statement will raise `TypeError` at runtime.\n\n## Examples\n\n```python\n# TypeError: 'int' object does not support the context manager protocol\nwith 1: # error\n print(2)\n```", "default": "error", "oneOf": [ { @@ -1152,7 +1172,7 @@ }, "invalid-dataclass": { "title": "detects invalid `@dataclass` applications", - "description": "## What it does\n\nChecks for invalid applications of the `@dataclass` decorator.\n\n## Why is this bad?\n\nApplying `@dataclass` with incompatible arguments raises an exception while creating the\nclass:\n\n- `order=True` with `eq=False`\n- `weakref_slot=True` with `slots=False`\n\nApplying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`,\n`Enum`, or `Protocol` is also invalid:\n\n- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when\n instantiating the class.\n- `Enum` classes with `@dataclass` are [explicitly not supported].\n- `Protocol` classes define interfaces and cannot be instantiated.\n\n## Examples\n\n```python\nfrom dataclasses import dataclass\nfrom typing import NamedTuple\n\n\n@dataclass(order=True, eq=False) # error: [invalid-dataclass]\nclass Ordered: ...\n\n\n@dataclass\nclass Foo(NamedTuple): # error: [invalid-dataclass]\n x: int\n```\n\nSee: \n\n[explicitly not supported]: https://docs.python.org/3/howto/enum.html#dataclass-support", + "description": "## What it does\n\nChecks for invalid applications of the `@dataclass` decorator.\n\n## Why is this bad?\n\nApplying `@dataclass` with incompatible arguments raises an exception while creating the class:\n\n- `order=True` with `eq=False`\n- `weakref_slot=True` with `slots=False`\n- `slots=True` when the class already defines `__slots__`\n\nApplying `@dataclass` to a class that inherits from `NamedTuple`, `TypedDict`, `Enum`, or `Protocol`\nis also invalid:\n\n- `NamedTuple` and `TypedDict` classes will raise an exception at runtime when instantiating the\n class.\n- `Enum` classes with `@dataclass` are [explicitly not supported].\n- `Protocol` classes define interfaces and cannot be instantiated.\n\n## Examples\n\n```python\nfrom dataclasses import dataclass\nfrom typing import NamedTuple\n\n\n@dataclass(order=True, eq=False) # error: [invalid-dataclass]\nclass Ordered: ...\n\n\n@dataclass\nclass Foo(NamedTuple): # error: [invalid-dataclass]\n x: int\n```\n\nSee: \n\n[explicitly not supported]: https://docs.python.org/3/howto/enum.html#dataclass-support", "default": "error", "oneOf": [ { @@ -1172,7 +1192,7 @@ }, "invalid-declaration": { "title": "detects invalid declarations", - "description": "## What it does\n\nChecks for declarations where the inferred type of an existing symbol\nis not [assignable to] its post-hoc declared type.\n\n## Why is this bad?\n\nSuch declarations break the rules of the type system and\nweaken a type checker's ability to accurately reason about your code.\n\n## Examples\n\n```python\na = 1\na: str # error\n```\n\n[assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable", + "description": "## What it does\n\nChecks for declarations where the inferred type of an existing symbol is not [assignable to] its\npost-hoc declared type.\n\n## Why is this bad?\n\nSuch declarations break the rules of the type system and weaken a type checker's ability to\naccurately reason about your code.\n\n## Examples\n\n```python\na = 1\na: str # error\n```\n\n[assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable", "default": "error", "oneOf": [ { @@ -1182,7 +1202,7 @@ }, "invalid-enum-member-annotation": { "title": "detects type annotations on enum members", - "description": "## What it does\n\nChecks for enum members that have explicit type annotations.\n\n## Why is this bad?\n\nThe [typing spec] states that type checkers should infer a literal type\nfor all enum members. An explicit type annotation on an enum member is\nmisleading because the annotated type will be incorrect — the actual\nruntime type is the enum class itself, not the annotated type.\n\nIn CPython's `enum` module, annotated assignments with values are still\ntreated as members at runtime, but the annotation will confuse readers of the code.\n\n## Examples\n\n```python\nfrom enum import Enum\n\n\nclass Pet(Enum):\n CAT = 1 # OK\n # enum members should not be annotated\n DOG: int = 2 # error\n```\n\nUse instead:\n\n```python\nfrom enum import Enum\n\n\nclass Pet(Enum):\n CAT = 1\n DOG = 2\n```\n\n## References\n\n- [Typing spec: Enum members](https://typing.python.org/en/latest/spec/enums.html#enum-members)\n\n[typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members", + "description": "## What it does\n\nChecks for enum members that have explicit type annotations.\n\n## Why is this bad?\n\nThe [typing spec] states that type checkers should infer a literal type for all enum members. An\nexplicit type annotation on an enum member is misleading because the annotated type will be\nincorrect — the actual runtime type is the enum class itself, not the annotated type.\n\nIn CPython's `enum` module, annotated assignments with values are still treated as members at\nruntime, but the annotation will confuse readers of the code.\n\n## Examples\n\n```python\nfrom enum import Enum\n\n\nclass Pet(Enum):\n CAT = 1 # OK\n # enum members should not be annotated\n DOG: int = 2 # error\n```\n\nUse instead:\n\n```python\nfrom enum import Enum\n\n\nclass Pet(Enum):\n CAT = 1\n DOG = 2\n```\n\n## References\n\n- [Typing spec: Enum members](https://typing.python.org/en/latest/spec/enums.html#enum-members)\n\n[typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members", "default": "warn", "oneOf": [ { @@ -1192,7 +1212,7 @@ }, "invalid-exception-caught": { "title": "detects exception handlers that catch classes that do not inherit from `BaseException`", - "description": "## What it does\n\nChecks for exception handlers that catch non-exception classes.\n\n## Why is this bad?\n\nCatching classes that do not inherit from `BaseException` will raise a `TypeError` at runtime.\n\n## Example\n\n```python\nimport random\n\n\ndef might_raise() -> float:\n return 1 / random.choice([0, 1, 2, 3, 4, 5])\n\n\ntry:\n might_raise()\nexcept 1: # error\n ...\n```\n\nUse instead:\n\n```python\nimport random\n\n\ndef might_raise() -> float:\n return 1 / random.choice([0, 1, 2, 3, 4, 5])\n\n\ntry:\n might_raise()\nexcept ZeroDivisionError:\n ...\n```\n\n## References\n\n- [Python documentation: except clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause)\n- [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions)\n\n## Ruff rule\n\nThis rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes)", + "description": "## What it does\n\nChecks for exception handlers that catch non-exception classes.\n\n## Why is this bad?\n\nCatching classes that do not inherit from `BaseException` will raise a `TypeError` at runtime.\n\n## Example\n\n```python\nimport random\n\n\ndef might_raise() -> float:\n return 1 / random.choice([0, 1, 2, 3, 4, 5])\n\n\ntry:\n might_raise()\nexcept 1: # error\n ...\n```\n\nUse instead:\n\n```python\nimport random\n\n\ndef might_raise() -> float:\n return 1 / random.choice([0, 1, 2, 3, 4, 5])\n\n\ntry:\n might_raise()\nexcept ZeroDivisionError:\n ...\n```\n\n## References\n\n- [Python documentation: except clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause)\n- [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions)\n\n## Ruff rule\n\nThis rule corresponds to Ruff's\n[`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes)", "default": "error", "oneOf": [ { @@ -1202,7 +1222,7 @@ }, "invalid-explicit-override": { "title": "detects methods that are decorated with `@override` but do not override any method in a superclass", - "description": "## What it does\n\nChecks for methods that are decorated with `@override` but do not override any method in a superclass.\n\n## Why is this bad?\n\nDecorating a method with `@override` declares to the type checker that the intention is that it should\noverride a method from a superclass.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import override\n\n\nclass A:\n @override\n def foo(self): ... # error\n\n\nclass B(A):\n @override\n def ffooo(self): ... # error\n\n\nclass C:\n @override\n def __repr__(self): ... # fine: overrides `object.__repr__`\n\n\nclass D(A):\n @override\n def foo(self): ... # fine: overrides `A.foo`\n```", + "description": "## What it does\n\nChecks for methods that are decorated with `@override` but do not override any method in a\nsuperclass.\n\n## Why is this bad?\n\nDecorating a method with `@override` declares to the type checker that the intention is that it\nshould override a method from a superclass.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import override\n\n\nclass A:\n @override\n def foo(self): ... # error\n\n\nclass B(A):\n @override\n def ffooo(self): ... # error\n\n\nclass C:\n @override\n def __repr__(self): ... # fine: overrides `object.__repr__`\n\n\nclass D(A):\n @override\n def foo(self): ... # fine: overrides `A.foo`\n```", "default": "error", "oneOf": [ { @@ -1252,7 +1272,7 @@ }, "invalid-frozen-dataclass-subclass": { "title": "detects dataclasses with invalid frozen/non-frozen subclassing", - "description": "## What it does\n\nChecks for dataclasses with invalid frozen inheritance:\n\n- A frozen dataclass cannot inherit from a non-frozen dataclass.\n- A non-frozen dataclass cannot inherit from a frozen dataclass.\n\n## Why is this bad?\n\nPython raises a `TypeError` at runtime when either of these inheritance\npatterns occurs.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Base:\n x: int\n\n\n@dataclass(frozen=True)\nclass Child(Base): # error\n y: int\n\n\n@dataclass(frozen=True)\nclass FrozenBase:\n x: int\n\n\n@dataclass\nclass NonFrozenChild(FrozenBase): # error\n y: int\n```", + "description": "## What it does\n\nChecks for dataclasses with invalid frozen inheritance:\n\n- A frozen dataclass cannot inherit from a non-frozen dataclass.\n- A non-frozen dataclass cannot inherit from a frozen dataclass.\n\n## Why is this bad?\n\nPython raises a `TypeError` at runtime when either of these inheritance patterns occurs.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Base:\n x: int\n\n\n@dataclass(frozen=True)\nclass Child(Base): # error\n y: int\n\n\n@dataclass(frozen=True)\nclass FrozenBase:\n x: int\n\n\n@dataclass\nclass NonFrozenChild(FrozenBase): # error\n y: int\n```", "default": "error", "oneOf": [ { @@ -1262,7 +1282,7 @@ }, "invalid-generic-class": { "title": "detects invalid generic classes", - "description": "## What it does\n\nChecks for the creation of invalid generic classes\n\n## Why is this bad?\n\nThere are several requirements that you must follow when defining a generic class.\nMany of these result in `TypeError` being raised at runtime if they are violated.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing_extensions import Generic, TypeVar\n\nT = TypeVar(\"T\")\nU = TypeVar(\"U\", default=int)\nV = TypeVar(\"V\", covariant=True)\n\n\n# class uses both PEP-695 syntax and legacy syntax\nclass C[U](Generic[T]): ... # error\n\n\n# type parameter with default comes before type parameter without default\nclass D(Generic[U, T]): ... # error\n\n\n# covariant type parameter used in a position that requires contravariance\nclass E(Generic[V]): # error\n def set(self, value: V) -> None: ...\n```\n\n## References\n\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", + "description": "## What it does\n\nChecks for the creation of invalid generic classes\n\n## Why is this bad?\n\nThere are several requirements that you must follow when defining a generic class. Many of these\nresult in `TypeError` being raised at runtime if they are violated.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing_extensions import Generic, TypeVar\n\nT = TypeVar(\"T\")\nU = TypeVar(\"U\", default=int)\nV = TypeVar(\"V\", covariant=True)\n\n\n# class uses both PEP-695 syntax and legacy syntax\nclass C[U](Generic[T]): ... # error\n\n\n# type parameter with default comes before type parameter without default\nclass D(Generic[U, T]): ... # error\n\n\n# covariant type parameter used in a position that requires contravariance\nclass E(Generic[V]):\n def set(self, value: V) -> None: ... # error\n```\n\n## References\n\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", "default": "error", "oneOf": [ { @@ -1272,7 +1292,7 @@ }, "invalid-generic-enum": { "title": "detects generic enum classes", - "description": "## What it does\n\nChecks for enum classes that are also generic.\n\n## Why is this bad?\n\nEnum classes cannot be generic. Python does not support generic enums:\nattempting to create one will either result in an immediate `TypeError`\nat runtime, or will create a class that cannot be specialized in the way\nthat a normal generic class can.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom enum import Enum\nfrom typing import Generic, TypeVar\n\nT = TypeVar(\"T\")\n\n\n# enum class cannot be generic (class creation fails with `TypeError`)\nclass E[T](Enum): # error\n A = 1\n\n\n# enum class cannot be generic (class creation fails with `TypeError`)\nclass F(Enum, Generic[T]): # error\n A = 1\n\n\n# enum class cannot be generic -- the class creation does not immediately fail...\nclass G(Generic[T], Enum): # error\n A = 1\n\n\n# ...but this raises `KeyError`:\nx: G[int]\n```\n\n## References\n\n- [Python documentation: Enum](https://docs.python.org/3/library/enum.html)", + "description": "## What it does\n\nChecks for enum classes that are also generic.\n\n## Why is this bad?\n\nEnum classes cannot be generic. Python does not support generic enums: attempting to create one will\neither result in an immediate `TypeError` at runtime, or will create a class that cannot be\nspecialized in the way that a normal generic class can.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom enum import Enum\nfrom typing import Generic, TypeVar\n\nT = TypeVar(\"T\")\n\n\n# enum class cannot be generic (class creation fails with `TypeError`)\nclass E[T](Enum): # error\n A = 1\n\n\n# enum class cannot be generic (class creation fails with `TypeError`)\nclass F(Enum, Generic[T]): # error\n A = 1\n\n\n# enum class cannot be generic -- the class creation does not immediately fail...\nclass G(Generic[T], Enum): # error\n A = 1\n\n\n# ...but this raises `KeyError`:\nx: G[int]\n```\n\n## References\n\n- [Python documentation: Enum](https://docs.python.org/3/library/enum.html)", "default": "error", "oneOf": [ { @@ -1292,7 +1312,7 @@ }, "invalid-key": { "title": "detects invalid subscript accesses or TypedDict literal keys", - "description": "## What it does\n\nChecks for subscript accesses with invalid keys and `TypedDict` construction with an\nunknown key.\n\n## Why is this bad?\n\nSubscripting with an invalid key will raise a `KeyError` at runtime.\n\nCreating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is\n`closed=true` it also violates the expectations of the type.\n\n## Examples\n\n```python\nfrom typing import TypedDict\nfrom typing_extensions import NotRequired\n\n\nclass Person(TypedDict):\n name: NotRequired[str]\n age: NotRequired[int]\n\n\nalice = Person(name=\"Alice\", age=30)\n# KeyError: 'height'\nalice[\"height\"] # error\n\n# error\nbob: Person = {\"nickname\": \"Bob\", \"age\": 30} # typo!\n\n# error\ncarol = Person(name=\"Carol\", aeg=25) # typo!\n```", + "description": "## What it does\n\nChecks for subscript accesses with invalid keys and `TypedDict` construction with an unknown key.\n\n## Why is this bad?\n\nSubscripting with an invalid key will raise a `KeyError` at runtime.\n\nCreating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is `closed=true`\nit also violates the expectations of the type.\n\n## Examples\n\n```python\nfrom typing import TypedDict\nfrom typing_extensions import NotRequired\n\n\nclass Person(TypedDict):\n name: NotRequired[str]\n age: NotRequired[int]\n\n\nalice = Person(name=\"Alice\", age=30)\n# KeyError: 'height'\nalice[\"height\"] # error\n\n# error\nbob: Person = {\"nickname\": \"Bob\", \"age\": 30} # typo!\n\n# error\ncarol = Person(name=\"Carol\", aeg=25) # typo!\n```", "default": "error", "oneOf": [ { @@ -1302,7 +1322,7 @@ }, "invalid-legacy-positional-parameter": { "title": "detects incorrect usage of the legacy convention for specifying positional-only parameters", - "description": "## What it does\n\nChecks for parameters that appear to be attempting to use the legacy convention\nto specify that a parameter is positional-only, but do so incorrectly.\n\nThe \"legacy convention\" for specifying positional-only parameters was\nspecified in [PEP 484][pep-484]. It states that parameters with names starting with\n`__` should be considered positional-only by type checkers. [PEP 570][pep-570], introduced\nin Python 3.8, added dedicated syntax for specifying positional-only parameters,\nrendering the legacy convention obsolete. However, some codebases may still\nuse the legacy convention for compatibility with older Python versions.\n\n## Why is this bad?\n\nIn most cases, a type checker will not consider a parameter to be positional-only\nif it comes after a positional-or-keyword parameter, even if its name starts with\n`__`. This may be unexpected to the author of the code.\n\n## Example\n\n```python\n# `__y` is not considered positional-only\ndef f(x, __y): # error\n pass\n```\n\nUse instead:\n\n```python\ndef f(__x, __y): # If you need compatibility with Python <=3.7\n pass\n```\n\nor:\n\n```python\ndef f(x, y, /): # Python 3.8+ syntax\n pass\n```\n\n## References\n\n- [Typing spec: positional-only parameters (legacy syntax)](https://typing.python.org/en/latest/spec/historical.html#pos-only-double-underscore)\n- [Python glossary: parameters](https://docs.python.org/3/glossary.html#term-parameter)\n\n[pep-484]: https://peps.python.org/pep-0484/#positional-only-arguments\n[pep-570]: https://peps.python.org/pep-0570/", + "description": "## What it does\n\nChecks for parameters that appear to be attempting to use the legacy convention to specify that a\nparameter is positional-only, but do so incorrectly.\n\nThe \"legacy convention\" for specifying positional-only parameters was specified in\n[PEP 484][pep-484]. It states that parameters with names starting with `__` should be considered\npositional-only by type checkers. [PEP 570][pep-570], introduced in Python 3.8, added dedicated\nsyntax for specifying positional-only parameters, rendering the legacy convention obsolete. However,\nsome codebases may still use the legacy convention for compatibility with older Python versions.\n\n## Why is this bad?\n\nIn most cases, a type checker will not consider a parameter to be positional-only if it comes after\na positional-or-keyword parameter, even if its name starts with `__`. This may be unexpected to the\nauthor of the code.\n\n## Example\n\n```python\n# `__y` is not considered positional-only\ndef f(x, __y): # error\n pass\n```\n\nUse instead:\n\n```python\ndef f(__x, __y): # If you need compatibility with Python <=3.7\n pass\n```\n\nor:\n\n```python\ndef f(x, y, /): # Python 3.8+ syntax\n pass\n```\n\n## References\n\n- [Typing spec: positional-only parameters (legacy syntax)](https://typing.python.org/en/latest/spec/historical.html#pos-only-double-underscore)\n- [Python glossary: parameters](https://docs.python.org/3/glossary.html#term-parameter)\n\n[pep-484]: https://peps.python.org/pep-0484/#positional-only-arguments\n[pep-570]: https://peps.python.org/pep-0570/", "default": "warn", "oneOf": [ { @@ -1322,7 +1342,7 @@ }, "invalid-match-pattern": { "title": "detect invalid match patterns", - "description": "## What it does\n\nChecks for invalid match patterns.\n\n## Why is this bad?\n\nInvalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime.\nThis includes:\n\n- Using a non-type object in a class pattern.\n- Providing positional subpatterns when `__match_args__` is missing or has an invalid static type.\n- Matching against `collections.abc.Callable` with positional subpatterns.\n- Matching against a non-runtime-checkable protocol.\n- Matching against a `TypedDict`.\n- basedpython: a bare `case A:` that captures rather than naming a member of the subject's type.\n\n## Examples\n\n```python\nclass Point:\n __match_args__ = (\"x\", \"y\")\n\n\ndef describe(p: Point) -> None:\n match p:\n # TypeError at runtime: Point() accepts 2 positional sub-patterns (3 given)\n case Point(x, y, z): # error: [invalid-match-pattern]\n ...\n```\n\n```python\nNotAClass = 42\n\nmatch object():\n # TypeError at runtime: called match pattern must be a class\n case NotAClass(): # error: [invalid-match-pattern]\n ...\n```", + "description": "## What it does\n\nChecks for invalid match patterns.\n\n## Why is this bad?\n\nInvalid match patterns can cause a `TypeError` or a `SyntaxError` at runtime. This includes:\n\n- Using a non-type object in a class pattern.\n- Providing positional subpatterns when `__match_args__` is missing or has an invalid static type.\n- Matching against `collections.abc.Callable` with positional subpatterns.\n- Matching against a non-runtime-checkable protocol.\n- Matching against a `TypedDict`.\n- basedpython: a bare `case A:` that captures rather than naming a member of the subject's type.\n\n## Examples\n\n```python\nclass Point:\n __match_args__ = (\"x\", \"y\")\n\n\ndef describe(p: Point) -> None:\n match p:\n # TypeError at runtime: Point() accepts 2 positional sub-patterns (3 given)\n case Point(x, y, z): # error: [invalid-match-pattern]\n ...\n```\n\n```python\nNotAClass = 42\n\nmatch object():\n # TypeError at runtime: called match pattern must be a class\n case NotAClass(): # error: [invalid-match-pattern]\n ...\n```", "default": "error", "oneOf": [ { @@ -1332,7 +1352,7 @@ }, "invalid-metaclass": { "title": "detects invalid `metaclass=` arguments", - "description": "## What it does\n\nChecks for arguments to `metaclass=` that are invalid.\n\n## Why is this bad?\n\nPython allows arbitrary expressions to be used as the argument to `metaclass=`.\nThese expressions, however, need to be callable and accept the same arguments\nas `type.__new__`.\n\n## Example\n\n```python\n# TypeError: 'int' object is not callable\nclass B(metaclass=42): ... # error\n```\n\n## References\n\n- [Python documentation: Metaclasses](https://docs.python.org/3/reference/datamodel.html#metaclasses)", + "description": "## What it does\n\nChecks for arguments to `metaclass=` that are invalid.\n\n## Why is this bad?\n\nPython allows arbitrary expressions to be used as the argument to `metaclass=`. These expressions,\nhowever, need to be callable and accept the same arguments as `type.__new__`.\n\n## Example\n\n```python\n# TypeError: 'int' object is not callable\nclass B(metaclass=42): ... # error\n```\n\n## References\n\n- [Python documentation: Metaclasses](https://docs.python.org/3/reference/datamodel.html#metaclasses)", "default": "error", "oneOf": [ { @@ -1342,7 +1362,7 @@ }, "invalid-method-override": { "title": "detects method definitions that violate the Liskov Substitution Principle", - "description": "## What it does\n\nDetects method overrides that violate the [Liskov Substitution Principle][liskov-substitution-principle] (\"LSP\").\n\nThe LSP states that an instance of a subtype should be substitutable for an instance of its supertype.\nApplied to Python, this means:\n\n1. All argument combinations a superclass method accepts\n must also be accepted by an overriding subclass method.\n1. The return type of an overriding subclass method must be a subtype\n of the return type of the superclass method.\n\n## Why is this bad?\n\nViolating the Liskov Substitution Principle will lead to many of ty's assumptions and\ninferences being incorrect, which will mean that it will fail to catch many possible\ntype errors in your code.\n\n## Example\n\n```python\nclass Super:\n def method(self, x) -> int:\n return 42\n\n\nclass Sub(Super):\n # Liskov violation: `str` is not a subtype of `int`,\n # but the supertype method promises to return an `int`.\n def method(self, x) -> str: # error: [invalid-method-override]\n return \"foo\"\n\n\ndef accepts_super(s: Super) -> int:\n return s.method(x=42)\n\n\n# The result of this call is a string, but ty will infer it to be an `int`\n# due to the violation of the Liskov Substitution Principle.\naccepts_super(Sub())\n\n\nclass Sub2(Super):\n # Liskov violation: the superclass method can be called with a `x=`\n # keyword argument, but the subclass method does not accept it.\n def method(self, y) -> int: # error: [invalid-method-override]\n return 42\n\n\n# TypeError at runtime: method() got an unexpected keyword argument 'x'\n# ty cannot catch this error due to the violation of the Liskov Substitution Principle.\naccepts_super(Sub2())\n```\n\n## Common issues\n\n### Why does ty complain about my `__eq__` method?\n\n`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary\nobjects as their second argument, for example:\n\n```python\nclass A:\n x: int\n\n def __eq__(self, other: object) -> bool:\n # gracefully handle an object of an unexpected type\n # without raising an exception\n if not isinstance(other, A):\n return False\n return self.x == other.x\n```\n\nIf `A.__eq__` here were annotated as only accepting `A` instances for its second argument,\nit would imply that you wouldn't be able to use `==` between instances of `A` and\ninstances of unrelated classes without an exception possibly being raised. While some\nclasses in Python do indeed behave this way, the strongly held convention is that it should\nbe avoided wherever possible. As part of this check, therefore, ty enforces that `__eq__`\nand `__ne__` methods accept `object` as their second argument.\n\n### Why does ty disagree with Ruff about how to write my method?\n\nRuff has several rules that will encourage you to rename a parameter, or change its type\nsignature, if it thinks you're falling into a certain anti-pattern. For example, Ruff's\n[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an\nunused parameter should either be removed or renamed to start with `_`. Applying either of\nthese suggestions can cause ty to start reporting an `invalid-method-override` error if\nthe function in question is a method on a subclass that overrides a method on a superclass,\nand the change would cause the subclass method to no longer accept all argument combinations\nthat the superclass method accepts.\n\nThis can usually be resolved by adding [`@typing.override`][override] to your method\ndefinition. Ruff knows that a method decorated with `@typing.override` is intended to\noverride a method by the same name on a superclass, and avoids reporting rules like ARG002\nfor such methods; it knows that the changes recommended by ARG002 would violate the Liskov\nSubstitution Principle.\n\nCorrect use of `@override` is enforced by ty's `invalid-explicit-override` rule.\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle\n[override]: https://docs.python.org/3/library/typing.html#typing.override", + "description": "## What it does\n\nDetects method overrides that violate the\n[Liskov Substitution Principle][liskov-substitution-principle] (\"LSP\").\n\nThe LSP states that an instance of a subtype should be substitutable for an instance of its\nsupertype. Applied to Python, this means:\n\n1. All argument combinations a superclass method accepts must also be accepted by an overriding\n subclass method.\n1. The return type of an overriding subclass method must be a subtype of the return type of the\n superclass method.\n\n## Why is this bad?\n\nViolating the Liskov Substitution Principle will lead to many of ty's assumptions and inferences\nbeing incorrect, which will mean that it will fail to catch many possible type errors in your code.\n\n## Example\n\n```python\nclass Super:\n def method(self, x) -> int:\n return 42\n\n\nclass Sub(Super):\n # Liskov violation: `str` is not a subtype of `int`,\n # but the supertype method promises to return an `int`.\n def method(self, x) -> str: # error: [invalid-method-override]\n return \"foo\"\n\n\ndef accepts_super(s: Super) -> int:\n return s.method(x=42)\n\n\n# The result of this call is a string, but ty will infer it to be an `int`\n# due to the violation of the Liskov Substitution Principle.\naccepts_super(Sub())\n\n\nclass Sub2(Super):\n # Liskov violation: the superclass method can be called with a `x=`\n # keyword argument, but the subclass method does not accept it.\n def method(self, y) -> int: # error: [invalid-method-override]\n return 42\n\n\n# TypeError at runtime: method() got an unexpected keyword argument 'x'\n# ty cannot catch this error due to the violation of the Liskov Substitution Principle.\naccepts_super(Sub2())\n```\n\n## Common issues\n\n### Why does ty complain about my `__eq__` method?\n\n`__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary objects as their\nsecond argument, for example:\n\n```python\nclass A:\n x: int\n\n def __eq__(self, other: object) -> bool:\n # gracefully handle an object of an unexpected type\n # without raising an exception\n if not isinstance(other, A):\n return False\n return self.x == other.x\n```\n\nIf `A.__eq__` here were annotated as only accepting `A` instances for its second argument, it would\nimply that you wouldn't be able to use `==` between instances of `A` and instances of unrelated\nclasses without an exception possibly being raised. While some classes in Python do indeed behave\nthis way, the strongly held convention is that it should be avoided wherever possible. As part of\nthis check, therefore, ty enforces that `__eq__` and `__ne__` methods accept `object` as their\nsecond argument.\n\n### Why does ty disagree with Ruff about how to write my method?\n\nRuff has several rules that will encourage you to rename a parameter, or change its type signature,\nif it thinks you're falling into a certain anti-pattern. For example, Ruff's\n[ARG002](https://docs.astral.sh/ruff/rules/unused-method-argument/) rule recommends that an unused\nparameter should either be removed or renamed to start with `_`. Applying either of these\nsuggestions can cause ty to start reporting an `invalid-method-override` error if the function in\nquestion is a method on a subclass that overrides a method on a superclass, and the change would\ncause the subclass method to no longer accept all argument combinations that the superclass method\naccepts.\n\nThis can usually be resolved by adding [`@typing.override`][override] to your method definition.\nRuff knows that a method decorated with `@typing.override` is intended to override a method by the\nsame name on a superclass, and avoids reporting rules like ARG002 for such methods; it knows that\nthe changes recommended by ARG002 would violate the Liskov Substitution Principle.\n\nCorrect use of `@override` is enforced by ty's `invalid-explicit-override` rule.\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle\n[override]: https://docs.python.org/3/library/typing.html#typing.override", "default": "error", "oneOf": [ { @@ -1360,9 +1380,19 @@ } ] }, + "invalid-module-getattr-call": { + "title": "detects imports that fail while calling module-level `__getattr__`", + "description": "## What it does\n\nChecks for imports that fail when calling a module-level `__getattr__` function.\n\n## Why is this bad?\n\nIf a module defines `__getattr__`, Python calls it when a `from` import requests a name that is not\notherwise defined. The import raises an exception if `__getattr__` cannot accept the requested name.\n\n## Examples\n\n`module.py`:\n\n```python\ndef __getattr__() -> str:\n return \"fallback\"\n```\n\n`main.py`:\n\n```python\n# TypeError: __getattr__() takes 0 positional arguments but 1 was given\nfrom module import missing # error\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "invalid-named-tuple": { "title": "detects invalid `NamedTuple` class definitions", - "description": "## What it does\n\nChecks for invalidly defined `NamedTuple` classes.\n\n## Why is this bad?\n\nAn invalidly defined `NamedTuple` class may lead to the type checker\ndrawing incorrect conclusions. It may also lead to `TypeError`s or\n`AttributeError`s at runtime.\n\n## Examples\n\nA class definition cannot combine `NamedTuple` with other base classes\nin multiple inheritance; doing so raises a `TypeError` at runtime. The sole\nexception to this rule is `Generic[]`, which can be used alongside `NamedTuple`\nin a class's bases list.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple, object): ...\nTypeError: can only inherit from a NamedTuple type and Generic\n```\n\nFurther, `NamedTuple` field names cannot start with an underscore:\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... _bar: int\nValueError: Field names cannot start with an underscore: '_bar'\n```\n\n`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`,\n`_replace`, etc.) that cannot be overwritten. Attempting to assign to these attributes\nwithout a type annotation will raise an `AttributeError` at runtime.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... x: int\n... _asdict = 42\nAttributeError: Cannot overwrite NamedTuple attribute _asdict\n```\n\nFinally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type\nqualifiers. These qualifiers also cause a runtime error when annotations are evaluated eagerly:\n\n```pycon\n>>> from typing import ClassVar, NamedTuple\n>>> class Foo(NamedTuple):\n... x: ClassVar[int]\nTypeError: typing.ClassVar[int] is not valid as type argument\n```", + "description": "## What it does\n\nChecks for invalidly defined `NamedTuple` classes.\n\n## Why is this bad?\n\nAn invalidly defined `NamedTuple` class may lead to the type checker drawing incorrect conclusions.\nIt may also lead to `TypeError`s or `AttributeError`s at runtime.\n\n## Examples\n\nA class definition cannot combine `NamedTuple` with other base classes in multiple inheritance;\ndoing so raises a `TypeError` at runtime. The sole exception to this rule is `Generic[]`, which can\nbe used alongside `NamedTuple` in a class's bases list.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple, object): ...\nTypeError: can only inherit from a NamedTuple type and Generic\n```\n\nFurther, `NamedTuple` field names cannot start with an underscore:\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... _bar: int\nValueError: Field names cannot start with an underscore: '_bar'\n```\n\n`NamedTuple` classes also have certain synthesized attributes (like `_asdict`, `_make`, `_replace`,\netc.) that cannot be overwritten. Attempting to assign to these attributes without a type annotation\nwill raise an `AttributeError` at runtime.\n\n```pycon\n>>> from typing import NamedTuple\n>>> class Foo(NamedTuple):\n... x: int\n... _asdict = 42\nAttributeError: Cannot overwrite NamedTuple attribute _asdict\n```\n\nFinally, `NamedTuple` field annotations cannot use the `ClassVar` or `Final` type qualifiers. These\nqualifiers also cause a runtime error when annotations are evaluated eagerly:\n\n```pycon\n>>> from typing import ClassVar, NamedTuple\n>>> class Foo(NamedTuple):\n... x: ClassVar[int]\nTypeError: typing.ClassVar[int] is not valid as type argument\n```", "default": "error", "oneOf": [ { @@ -1372,7 +1402,7 @@ }, "invalid-named-tuple-override": { "title": "detects subclass members that override inherited `NamedTuple` fields", - "description": "## What it does\n\nChecks for subclass members that override inherited `NamedTuple` fields.\n\n## Why is this bad?\n\nReusing an inherited `NamedTuple` field name in a subclass creates a\nclass where tuple indexing and `repr()` still reflect the original\nfield, while attribute access follows the subclass member.\n\n## Default level\n\nThis rule is a warning by default because these overrides do not make\nthe class invalid at runtime.\n\n## Examples\n\n```python\nfrom typing import NamedTuple\n\n\nclass User(NamedTuple):\n name: str\n\n\nclass Admin(User):\n name = \"shadowed\" # error: [invalid-named-tuple-override]\n\n\nadmin = Admin(\"Alice\")\nadmin.name # \"shadowed\"\nadmin[0] # \"Alice\"\n```", + "description": "## What it does\n\nChecks for subclass members that override inherited `NamedTuple` fields.\n\n## Why is this bad?\n\nReusing an inherited `NamedTuple` field name in a subclass creates a class where tuple indexing and\n`repr()` still reflect the original field, while attribute access follows the subclass member.\n\n## Default level\n\nThis rule is a warning by default because these overrides do not make the class invalid at runtime.\n\n## Examples\n\n```python\nfrom typing import NamedTuple\n\n\nclass User(NamedTuple):\n name: str\n\n\nclass Admin(User):\n name = \"shadowed\" # error: [invalid-named-tuple-override]\n\n\nadmin = Admin(\"Alice\")\nadmin.name # \"shadowed\"\nadmin[0] # \"Alice\"\n```", "default": "warn", "oneOf": [ { @@ -1392,7 +1422,7 @@ }, "invalid-overload": { "title": "detects invalid `@overload` usages", - "description": "## What it does\n\nChecks for various invalid `@overload` usages.\n\n## Why is this bad?\n\nThe `@overload` decorator is used to define functions and methods that accepts different\ncombinations of arguments and return different types based on the arguments passed. This is\nmainly beneficial for type checkers. But, if the `@overload` usage is invalid, the type\nchecker may not be able to provide correct type information.\n\n## Examples\n\n### Single overload\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo(x: int) -> int: ... # error\ndef foo(x: int | None) -> int | None:\n return x\n```\n\n### Missing implementation\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo() -> None: ... # error\n@overload\ndef foo(x: int) -> int: ...\n```\n\n## References\n\n- [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload)", + "description": "## What it does\n\nChecks for various invalid `@overload` usages.\n\n## Why is this bad?\n\nThe `@overload` decorator is used to define functions and methods that accepts different\ncombinations of arguments and return different types based on the arguments passed. This is mainly\nbeneficial for type checkers. But, if the `@overload` usage is invalid, the type checker may not be\nable to provide correct type information.\n\n## Examples\n\n### Single overload\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo(x: int) -> int: ... # error\ndef foo(x: int | None) -> int | None:\n return x\n```\n\n### Missing implementation\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo() -> None: ... # error\n@overload\ndef foo(x: int) -> int: ...\n```\n\n## References\n\n- [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload)", "default": "error", "oneOf": [ { @@ -1402,7 +1432,7 @@ }, "invalid-parameter-default": { "title": "detects default values that can't be assigned to the parameter's annotated type", - "description": "## What it does\n\nChecks for default values that can't be\nassigned to the parameter's annotated type.\n\n## Why is this bad?\n\nThis breaks the rules of the type system and\nweakens a type checker's ability to accurately reason about your code.\n\n## Examples\n\n```python\ndef f(a: int = \"\"): ... # error\n```", + "description": "## What it does\n\nChecks for default values that can't be assigned to the parameter's annotated type.\n\n## Why is this bad?\n\nThis breaks the rules of the type system and weakens a type checker's ability to accurately reason\nabout your code.\n\n## Examples\n\n```python\ndef f(a: int = \"\"): ... # error\n```", "default": "error", "oneOf": [ { @@ -1432,7 +1462,7 @@ }, "invalid-protocol": { "title": "detects invalid protocol class definitions", - "description": "## What it does\n\nChecks for protocol classes that will raise `TypeError` at runtime.\n\n## Why is this bad?\n\nAn invalidly defined protocol class may lead to the type checker inferring\nunexpected things. It may also lead to `TypeError`s at runtime.\n\n## Examples\n\nA `Protocol` class cannot inherit from a non-`Protocol` class;\nthis raises a `TypeError` at runtime:\n\n```pycon\n>>> from typing import Protocol\n>>> class Foo(int, Protocol): ...\nTraceback (most recent call last):\n File \"\", line 1, in \n class Foo(int, Protocol): ...\nTypeError: Protocols can only inherit from other protocols, got \n```", + "description": "## What it does\n\nChecks for protocol classes that are invalid at runtime or do not satisfy the typing specification.\n\n## Why is this bad?\n\nAn invalidly defined protocol class may lead to the type checker inferring unexpected things or\naccepting unsafe operations. Some invalid protocol definitions also raise `TypeError` at runtime.\n\n## Examples\n\nA `Protocol` class cannot inherit from a non-`Protocol` class; this raises a `TypeError` at runtime:\n\n```pycon\n>>> from typing import Protocol\n>>> class Foo(int, Protocol): ...\nTraceback (most recent call last):\n File \"\", line 1, in \n class Foo(int, Protocol): ...\nTypeError: Protocols can only inherit from other protocols, got \n```\n\nA generic protocol's declared type-variable variance must match how that variable is used by its\nprotocol members. For example, a type variable that appears only in a method's return type must be\ncovariant:\n\n```py\nfrom typing import Protocol, TypeVar\n\nT = TypeVar(\"T\")\n\n\nclass Source(Protocol[T]): # error: [invalid-protocol]\n def read(self) -> T: ...\n```\n\nAlthough Python constructs this protocol successfully at runtime, it is invalid for static typing.\nDeclare the type variable with `TypeVar(\"T\", covariant=True)` instead.", "default": "error", "oneOf": [ { @@ -1442,7 +1472,7 @@ }, "invalid-raise": { "title": "detects `raise` statements that raise invalid exceptions or use invalid causes", - "description": "Checks for `raise` statements that raise non-exceptions or use invalid\ncauses for their raised exceptions.\n\n## Why is this bad?\n\nOnly subclasses or instances of `BaseException` can be raised.\nFor an exception's cause, the same rules apply, except that `None` is also\npermitted. Violating these rules results in a `TypeError` at runtime.\n\n## Examples\n\n```python\ndef something():\n raise NameError\n\n\ndef cause() -> None:\n pass\n\n\ndef f():\n try:\n something()\n except NameError:\n # error: \"Cannot raise object of type `Literal[\"oops!\"]`\"\n # error: \"Cannot use object of type `def cause()` as an exception cause\"\n raise \"oops!\" from cause\n\n\ndef g():\n # error: \"Cannot raise `NotImplemented`\"\n # error: \"Cannot use object of type `Literal[42]` as an exception cause\"\n raise NotImplemented from 42\n```\n\nUse instead:\n\n```python\ndef something():\n raise NameError\n\n\ndef f():\n try:\n something()\n except NameError as e:\n raise RuntimeError(\"oops!\") from e\n\n\ndef g():\n raise NotImplementedError from None\n```\n\n## References\n\n- [Python documentation: The `raise` statement](https://docs.python.org/3/reference/simple_stmts.html#raise)\n- [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions)", + "description": "Checks for `raise` statements that raise non-exceptions or use invalid causes for their raised\nexceptions.\n\n## Why is this bad?\n\nOnly subclasses or instances of `BaseException` can be raised. For an exception's cause, the same\nrules apply, except that `None` is also permitted. Violating these rules results in a `TypeError` at\nruntime.\n\n## Examples\n\n```python\ndef something():\n raise NameError\n\n\ndef cause() -> None:\n pass\n\n\ndef f():\n try:\n something()\n except NameError:\n # error: \"Cannot raise object of type `Literal[\"oops!\"]`\"\n # error: \"Cannot use object of type `def cause()` as an exception cause\"\n raise \"oops!\" from cause\n\n\ndef g():\n # error: \"Cannot raise `NotImplemented`\"\n # error: \"Cannot use object of type `Literal[42]` as an exception cause\"\n raise NotImplemented from 42\n```\n\nUse instead:\n\n```python\ndef something():\n raise NameError\n\n\ndef f():\n try:\n something()\n except NameError as e:\n raise RuntimeError(\"oops!\") from e\n\n\ndef g():\n raise NotImplementedError from None\n```\n\n## References\n\n- [Python documentation: The `raise` statement](https://docs.python.org/3/reference/simple_stmts.html#raise)\n- [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions)", "default": "error", "oneOf": [ { @@ -1482,7 +1512,7 @@ }, "invalid-return-type": { "title": "detects returned values that can't be assigned to the function's annotated return type", - "description": "## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body\nis handled by the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type\nis unsound, and will lead to ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```", + "description": "## What it does\n\nDetects returned values that can't be assigned to the function's annotated return type.\n\nNote that the special case of a function with a non-`None` return type and an empty body is handled\nby the separate `empty-body` error code.\n\n## Why is this bad?\n\nReturning an object of a type incompatible with the annotated return type is unsound, and will lead\nto ty inferring incorrect types elsewhere.\n\n## Examples\n\n```python\ndef func() -> int:\n return \"a\" # error: [invalid-return-type]\n```", "default": "error", "oneOf": [ { @@ -1522,7 +1552,7 @@ }, "invalid-static-resource": { "title": "detects a static resource import that cannot be read", - "description": "## What it does\n\nChecks for basedpython static resource imports that cannot be read.\n\n## Why is this bad?\n\n`import \"data/config.yaml\" as config` says the file is part of the program. A\npath that names nothing, a path that names a place on one machine, a file in a\nformat that is not `.json`, `.toml`, `.yaml` or `.yml`, and a document the\nformat's own parser rejects all leave the import with no value to bind.\n\n## Examples\n\n`main.by`:\n\n```by\n# error: [invalid-static-resource]\nimport \"data/config.txt\" as config\n\n# error: [invalid-static-resource]\nimport \"/etc/hosts.json\" as hosts\n\n# error: [invalid-static-resource]\nimport \"data/missing.json\" as missing\n```", + "description": "## What it does\n\nChecks for basedpython static resource imports that cannot be read.\n\n## Why is this bad?\n\n`import \"data/config.yaml\" as config` says the file is part of the program. A path that names\nnothing, a path that names a place on one machine, a file in a format that is not `.json`, `.toml`,\n`.yaml` or `.yml`, and a document the format's own parser rejects all leave the import with no value\nto bind.\n\n## Examples\n\n`main.by`:\n\n```by\n# error: [invalid-static-resource]\nimport \"data/config.txt\" as config\n\n# error: [invalid-static-resource]\nimport \"/etc/hosts.json\" as hosts\n\n# error: [invalid-static-resource]\nimport \"data/missing.json\" as missing\n```", "default": "error", "oneOf": [ { @@ -1542,7 +1572,7 @@ }, "invalid-syntax-in-forward-annotation": { "title": "detects invalid syntax in forward annotations", - "description": "## What it does\n\nChecks for string-literal annotations where the string cannot be\nparsed as a Python expression.\n\n## Why is this bad?\n\nType annotations are expected to be Python expressions that\ndescribe the expected type of a variable, parameter, attribute or\n`return` statement.\n\nType annotations are permitted to be string-literal expressions, in\norder to enable forward references to names not yet defined.\nHowever, it must be possible to parse the contents of that string\nliteral as a normal Python expression.\n\n## Example\n\n```python\ndef foo() -> \"instance of C\": # error\n return 42\n\n\nclass C: ...\n```\n\nUse instead:\n\n```python\ndef foo() -> \"C\":\n return C()\n\n\nclass C: ...\n```\n\n## References\n\n- [Typing spec: The meaning of annotations](https://typing.python.org/en/latest/spec/annotations.html#the-meaning-of-annotations)\n- [Typing spec: String annotations](https://typing.python.org/en/latest/spec/annotations.html#string-annotations)", + "description": "## What it does\n\nChecks for string-literal annotations where the string cannot be parsed as a Python expression.\n\n## Why is this bad?\n\nType annotations are expected to be Python expressions that describe the expected type of a\nvariable, parameter, attribute or `return` statement.\n\nType annotations are permitted to be string-literal expressions, in order to enable forward\nreferences to names not yet defined. However, it must be possible to parse the contents of that\nstring literal as a normal Python expression.\n\n## Example\n\n```python\ndef foo() -> \"instance of C\": # error\n return 42\n\n\nclass C: ...\n```\n\nUse instead:\n\n```python\ndef foo() -> \"C\":\n return C()\n\n\nclass C: ...\n```\n\n## References\n\n- [Typing spec: The meaning of annotations](https://typing.python.org/en/latest/spec/annotations.html#the-meaning-of-annotations)\n- [Typing spec: String annotations](https://typing.python.org/en/latest/spec/annotations.html#string-annotations)", "default": "error", "oneOf": [ { @@ -1552,7 +1582,7 @@ }, "invalid-total-ordering": { "title": "detects `@total_ordering` classes without an ordering method", - "description": "## What it does\n\nChecks for classes decorated with `@functools.total_ordering` that don't\ndefine any ordering method (`__lt__`, `__le__`, `__gt__`, or `__ge__`).\n\n## Why is this bad?\n\nThe `@total_ordering` decorator requires the class to define at least one\nordering method. If none is defined, Python raises a `ValueError` at runtime.\n\n## Example\n\n```python\nfrom functools import total_ordering\n\n\n# no ordering method defined\n@total_ordering # error\nclass MyClass:\n def __eq__(self, other: object) -> bool:\n return True\n```\n\nUse instead:\n\n```python\nfrom functools import total_ordering\n\n\n@total_ordering\nclass MyClass:\n def __eq__(self, other: object) -> bool:\n return True\n\n def __lt__(self, other: \"MyClass\") -> bool:\n return True\n```", + "description": "## What it does\n\nChecks for classes decorated with `@functools.total_ordering` that don't define any ordering method\n(`__lt__`, `__le__`, `__gt__`, or `__ge__`).\n\n## Why is this bad?\n\nThe `@total_ordering` decorator requires the class to define at least one ordering method. If none\nis defined, Python raises a `ValueError` at runtime.\n\n## Example\n\n```python\nfrom functools import total_ordering\n\n\n# no ordering method defined\n@total_ordering # error\nclass MyClass:\n def __eq__(self, other: object) -> bool:\n return True\n```\n\nUse instead:\n\n```python\nfrom functools import total_ordering\n\n\n@total_ordering\nclass MyClass:\n def __eq__(self, other: object) -> bool:\n return True\n\n def __lt__(self, other: \"MyClass\") -> bool:\n return True\n```", "default": "error", "oneOf": [ { @@ -1572,7 +1602,7 @@ }, "invalid-type-arguments": { "title": "detects invalid type arguments in generic specialization", - "description": "## What it does\n\nChecks for invalid type arguments in explicit type specialization.\n\n## Why is this bad?\n\nProviding the wrong number of type arguments or type arguments that don't\nsatisfy the type variable's bounds or constraints will lead to incorrect\ntype inference and may indicate a misunderstanding of the generic type's\ninterface.\n\n## Examples\n\nUsing legacy type variables:\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import Generic, TypeVar\n\nT1 = TypeVar(\"T1\", int, str)\nT2 = TypeVar(\"T2\", bound=int)\n\n\nclass Foo1(Generic[T1]): ...\n\n\nclass Foo2(Generic[T2]): ...\n\n\n# bytes does not satisfy T1's constraints\nFoo1[bytes] # error\n# str does not satisfy T2's bound\nFoo2[str] # error\n```\n\nUsing PEP 695 type variables:\n\n```python\nclass Foo[T]: ...\n\n\nclass Bar[T, U]: ...\n\n\n# too many arguments\nFoo[int, str] # error\n# too few arguments\nBar[int] # error\n```", + "description": "## What it does\n\nChecks for invalid type arguments in explicit type specialization.\n\n## Why is this bad?\n\nProviding the wrong number of type arguments or type arguments that don't satisfy the type\nvariable's bounds or constraints will lead to incorrect type inference and may indicate a\nmisunderstanding of the generic type's interface.\n\n## Examples\n\nUsing legacy type variables:\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import Generic, TypeVar\n\nT1 = TypeVar(\"T1\", int, str)\nT2 = TypeVar(\"T2\", bound=int)\n\n\nclass Foo1(Generic[T1]): ...\n\n\nclass Foo2(Generic[T2]): ...\n\n\n# bytes does not satisfy T1's constraints\nFoo1[bytes] # error\n# str does not satisfy T2's bound\nFoo2[str] # error\n```\n\nUsing PEP 695 type variables:\n\n```python\nclass Foo[T]: ...\n\n\nclass Bar[T, U]: ...\n\n\n# too many arguments\nFoo[int, str] # error\n# too few arguments\nBar[int] # error\n```", "default": "error", "oneOf": [ { @@ -1582,7 +1612,7 @@ }, "invalid-type-checking-constant": { "title": "detects invalid `TYPE_CHECKING` constant assignments", - "description": "## What it does\n\nChecks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an\nannotation not assignable from `bool`.\n\n## Why is this bad?\n\nThe name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional\ncode seen only by the type checker, and not at runtime. Normally this flag is imported from\n`typing` or `typing_extensions`, but it can also be defined locally. If defined locally, it\nmust be assigned the value `False` at runtime; the type checker will consider its value to\nbe `True`. If annotated, it must be annotated as a type that can accept `bool` values.\n\n## Examples\n\n```python\nTYPE_CHECKING: str # error\nTYPE_CHECKING = \"\" # error\n```", + "description": "## What it does\n\nChecks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an annotation not\nassignable from `bool`.\n\n## Why is this bad?\n\nThe name `TYPE_CHECKING` is reserved for a flag that can be used to provide conditional code seen\nonly by the type checker, and not at runtime. Normally this flag is imported from `typing` or\n`typing_extensions`, but it can also be defined locally. If defined locally, it must be assigned the\nvalue `False` at runtime; the type checker will consider its value to be `True`. If annotated, it\nmust be annotated as a type that can accept `bool` values.\n\n## Examples\n\n```python\nTYPE_CHECKING: str # error\nTYPE_CHECKING = \"\" # error\n```", "default": "error", "oneOf": [ { @@ -1592,7 +1622,7 @@ }, "invalid-type-form": { "title": "detects invalid type forms", - "description": "## What it does\n\nChecks for expressions that are used as [type expressions]\nbut cannot validly be interpreted as such.\n\n## Why is this bad?\n\nSuch expressions cannot be understood by ty.\nIn some cases, they might raise errors at runtime.\n\n## Examples\n\n```python\nfrom typing import Annotated\n\n# Int literals are not allowed in this context in type expressions\na: list[1] # error\n# `Annotated` expects at least two arguments\nb: Annotated[int] # error\n```\n\n[type expressions]: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions", + "description": "## What it does\n\nChecks for expressions that are used as [type expressions] but cannot validly be interpreted as\nsuch.\n\n## Why is this bad?\n\nSuch expressions cannot be understood by ty. In some cases, they might raise errors at runtime.\n\n## Examples\n\n```python\nfrom typing import Annotated\n\n# Int literals are not allowed in this context in type expressions\na: list[1] # error\n# `Annotated` expects at least two arguments\nb: Annotated[int] # error\n```\n\n[type expressions]: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions", "default": "error", "oneOf": [ { @@ -1602,7 +1632,7 @@ }, "invalid-type-guard-definition": { "title": "detects malformed type guard functions", - "description": "## What it does\n\nChecks for type guard functions without\na first non-self-like non-keyword-only non-variadic parameter.\n\n## Why is this bad?\n\nType narrowing functions must accept at least one positional argument\n(non-static methods must accept another in addition to `self`/`cls`).\n\nExtra parameters/arguments are allowed but do not affect narrowing.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeIs\n\n\n# no parameter\ndef f() -> TypeIs[int]: # error\n return True\n\n\n# no positional arguments allowed\ndef f(*, v: object) -> TypeIs[int]: # error\n return True\n\n\n# expected variadic arguments\ndef f(*args: object) -> TypeIs[int]: # error\n return True\n\n\nclass C:\n # only positional argument is `self`\n def f(self) -> TypeIs[int]: # error\n return True\n```", + "description": "## What it does\n\nChecks for type guard functions without a first non-self-like non-keyword-only non-variadic\nparameter.\n\n## Why is this bad?\n\nType narrowing functions must accept at least one positional argument (non-static methods must\naccept another in addition to `self`/`cls`).\n\nExtra parameters/arguments are allowed but do not affect narrowing.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeIs\n\n\n# no parameter\ndef f() -> TypeIs[int]: # error\n return True\n\n\n# no positional arguments allowed\ndef f(*, v: object) -> TypeIs[int]: # error\n return True\n\n\n# expected variadic arguments\ndef f(*args: object) -> TypeIs[int]: # error\n return True\n\n\nclass C:\n # only positional argument is `self`\n def f(self) -> TypeIs[int]: # error\n return True\n```", "default": "error", "oneOf": [ { @@ -1622,7 +1652,7 @@ }, "invalid-type-variable-constraints": { "title": "detects invalid type variable constraints", - "description": "## What it does\n\nChecks for constrained [type variables] with only one constraint,\nor that those constraints reference type variables.\n\n## Why is this bad?\n\nA constrained type variable must have at least two constraints.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import TypeVar\n\nI = TypeVar(\"I\", bound=int)\n# constraint references `I`\nS = TypeVar(\"S\", list[I], int) # error\n\n\n# a constrained type variable needs at least two constraints\ndef f[T: (int,)](): ... # error\n```\n\nUse instead:\n\n```python\nfrom typing import TypeVar\n\nU = TypeVar(\"U\", str, int) # valid constrained TypeVar\n\n# or\n\nT = TypeVar(\"T\", bound=str) # valid bound TypeVar\n\nV = TypeVar(\"V\", list[int], int) # valid constrained Type\n```\n\n[type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar", + "description": "## What it does\n\nChecks for constrained [type variables] with only one constraint, or that those constraints\nreference type variables.\n\n## Why is this bad?\n\nA constrained type variable must have at least two constraints.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import TypeVar\n\nI = TypeVar(\"I\", bound=int)\n# constraint references `I`\nS = TypeVar(\"S\", list[I], int) # error\n\n\n# a constrained type variable needs at least two constraints\ndef f[T: (int,)](): ... # error\n```\n\nUse instead:\n\n```python\nfrom typing import TypeVar\n\nU = TypeVar(\"U\", str, int) # valid constrained TypeVar\n\n# or\n\nT = TypeVar(\"T\", bound=str) # valid bound TypeVar\n\nV = TypeVar(\"V\", list[int], int) # valid constrained Type\n```\n\n[type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar", "default": "error", "oneOf": [ { @@ -1632,7 +1662,7 @@ }, "invalid-type-variable-default": { "title": "detects invalid type variable defaults", - "description": "## What it does\n\nChecks for [type variables] whose default type is not compatible with\nthe type variable's bound or constraints.\n\n## Why is this bad?\n\nIf a type variable has a bound, the default must be assignable to that\nbound (see: [bound rules]). If a type variable has constraints, the default\nmust be one of the constraints (see: [constraint rules]).\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeVar\n\nT = TypeVar(\"T\", bound=str, default=int) # error: [invalid-type-variable-default]\nU = TypeVar(\"U\", int, str, default=bytes) # error: [invalid-type-variable-default]\n```\n\n[bound rules]: https://typing.python.org/en/latest/spec/generics.html#bound-rules\n[constraint rules]: https://typing.python.org/en/latest/spec/generics.html#constraint-rules\n[type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar", + "description": "## What it does\n\nChecks for [type variables] whose default type is not compatible with the type variable's bound or\nconstraints.\n\n## Why is this bad?\n\nIf a type variable has a bound, the default must be assignable to that bound (see: [bound rules]).\nIf a type variable has constraints, the default must be one of the constraints (see:\n[constraint rules]).\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeVar\n\nT = TypeVar(\"T\", bound=str, default=int) # error: [invalid-type-variable-default]\nU = TypeVar(\"U\", int, str, default=bytes) # error: [invalid-type-variable-default]\n```\n\n[bound rules]: https://typing.python.org/en/latest/spec/generics.html#bound-rules\n[constraint rules]: https://typing.python.org/en/latest/spec/generics.html#constraint-rules\n[type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar", "default": "error", "oneOf": [ { @@ -1642,7 +1672,7 @@ }, "invalid-typed-dict-field": { "title": "detects invalid `TypedDict` field declarations", - "description": "## What it does\n\nDetects invalid `TypedDict` field declarations.\n\n## Why is this bad?\n\n`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the\nsubtype guarantees that `TypedDict` inheritance is meant to preserve.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Base(TypedDict):\n x: int\n\n\nclass Child(Base):\n x: str # error: [invalid-typed-dict-field]\n```", + "description": "## What it does\n\nDetects invalid `TypedDict` field declarations.\n\n## Why is this bad?\n\n`TypedDict` subclasses cannot redefine inherited fields incompatibly. Doing so breaks the subtype\nguarantees that `TypedDict` inheritance is meant to preserve.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Base(TypedDict):\n x: int\n\n\nclass Child(Base):\n x: str # error: [invalid-typed-dict-field]\n```", "default": "error", "oneOf": [ { @@ -1652,7 +1682,7 @@ }, "invalid-typed-dict-header": { "title": "detects invalid statements in `TypedDict` class headers", - "description": "## What it does\n\nDetects errors in `TypedDict` class headers, such as unexpected arguments\nor invalid base classes.\n\n## Why is this bad?\n\nThe typing spec states that `TypedDict`s are not permitted to have\ncustom metaclasses. Using `**` unpacking in a `TypedDict` header\nis also prohibited by ty, as it means that ty cannot statically determine\nwhether keys in the `TypedDict` are intended to be required or optional.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Meta(type): ...\n\n\nclass Foo(TypedDict, metaclass=Meta): # error: [invalid-typed-dict-header]\n ...\n\n\ndef f(options: dict[str, object]):\n class Bar(TypedDict, **options): # error: [invalid-typed-dict-header]\n ...\n```", + "description": "## What it does\n\nDetects errors in `TypedDict` class headers, such as unexpected arguments or invalid base classes.\n\n## Why is this bad?\n\nThe typing spec states that `TypedDict`s are not permitted to have custom metaclasses. Using `**`\nunpacking in a `TypedDict` header is also prohibited by ty, as it means that ty cannot statically\ndetermine whether keys in the `TypedDict` are intended to be required or optional.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Meta(type): ...\n\n\nclass Foo(TypedDict, metaclass=Meta): # error: [invalid-typed-dict-header]\n ...\n\n\ndef f(options: dict[str, object]):\n class Bar(TypedDict, **options): # error: [invalid-typed-dict-header]\n ...\n```", "default": "error", "oneOf": [ { @@ -1662,7 +1692,7 @@ }, "invalid-typed-dict-statement": { "title": "detects invalid statements in `TypedDict` class bodies", - "description": "## What it does\n\nDetects statements other than annotated declarations in `TypedDict` class bodies.\n\n## Why is this bad?\n\n`TypedDict` class bodies aren't allowed to contain any other types of statements. For\nexample, method definitions and field values aren't allowed. None of these will be\navailable on \"instances of the `TypedDict`\" at runtime (as `dict` is the runtime class of\nall \"`TypedDict` instances\").\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Foo(TypedDict):\n def bar(self): # error: [invalid-typed-dict-statement]\n pass\n```", + "description": "## What it does\n\nDetects statements other than annotated declarations in `TypedDict` class bodies.\n\n## Why is this bad?\n\n`TypedDict` class bodies aren't allowed to contain any other types of statements. For example,\nmethod definitions and field values aren't allowed. None of these will be available on \"instances of\nthe `TypedDict`\" at runtime (as `dict` is the runtime class of all \"`TypedDict` instances\").\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Foo(TypedDict):\n def bar(self): # error: [invalid-typed-dict-statement]\n pass\n```", "default": "error", "oneOf": [ { @@ -1682,7 +1712,7 @@ }, "invalid-yield": { "title": "detects yield expressions where the \"yield\" or \"send\" type is incompatible with the annotated return type", - "description": "## What it does\n\nDetects `yield` and `yield from` expressions where the \"yield\" or \"send\" type\nis incompatible with the generator function's annotated return type.\n\n## Why is this bad?\n\nYielding a value of a type that doesn't match the generator's declared yield type,\nor using `yield from` with a sub-iterator whose yield or send type is incompatible,\nis a type error that may cause downstream consumers of the generator to receive\nvalues of an unexpected type.\n\n## Examples\n\n```python\nfrom typing import Iterator\n\n\ndef gen() -> Iterator[int]:\n yield \"not an int\" # error: [invalid-yield]\n```", + "description": "## What it does\n\nDetects `yield` and `yield from` expressions where the \"yield\" or \"send\" type is incompatible with\nthe generator function's annotated return type.\n\n## Why is this bad?\n\nYielding a value of a type that doesn't match the generator's declared yield type, or using\n`yield from` with a sub-iterator whose yield or send type is incompatible, is a type error that may\ncause downstream consumers of the generator to receive values of an unexpected type.\n\n## Examples\n\n```python\nfrom typing import Iterator\n\n\ndef gen() -> Iterator[int]:\n yield \"not an int\" # error: [invalid-yield]\n```", "default": "error", "oneOf": [ { @@ -1692,7 +1722,7 @@ }, "isinstance-against-protocol": { "title": "reports invalid runtime checks against protocol classes", - "description": "## What it does\n\nReports invalid runtime checks against `Protocol` classes.\nThis includes explicit calls `isinstance()`/`issubclass()` against\nnon-runtime-checkable protocols, `issubclass()` calls against protocols\nthat have non-method members, and implicit `isinstance()` checks against\nnon-runtime-checkable protocols via pattern matching.\n\n## Why is this bad?\n\nThese calls (implicit or explicit) raise `TypeError` at runtime.\n\n## Examples\n\n```python\nfrom typing_extensions import Protocol, runtime_checkable\n\n\nclass HasX(Protocol):\n x: int\n\n\n@runtime_checkable\nclass HasY(Protocol):\n y: int\n\n\ndef f(arg: object, arg2: type):\n # not runtime-checkable\n isinstance(arg, HasX) # error: [isinstance-against-protocol]\n # not runtime-checkable\n issubclass(arg2, HasX) # error: [isinstance-against-protocol]\n\n\ndef g(arg: object):\n match arg:\n # not runtime-checkable\n case HasX(): # error: [isinstance-against-protocol]\n pass\n\n\ndef h(arg2: type):\n isinstance(arg2, HasY) # fine (runtime-checkable)\n\n # `HasY` is runtime-checkable, but has non-method members,\n # so it still can't be used in `issubclass` checks)\n issubclass(arg2, HasY) # error: [isinstance-against-protocol]\n```\n\n## References\n\n- [Typing documentation: `@runtime_checkable`](https://docs.python.org/3/library/typing.html#typing.runtime_checkable)", + "description": "## What it does\n\nReports invalid runtime checks against `Protocol` classes. This includes explicit calls\n`isinstance()`/`issubclass()` against non-runtime-checkable protocols, `issubclass()` calls against\nprotocols that have non-method members, and implicit `isinstance()` checks against\nnon-runtime-checkable protocols via pattern matching.\n\n## Why is this bad?\n\nThese calls (implicit or explicit) raise `TypeError` at runtime.\n\n## Examples\n\n```python\nfrom typing_extensions import Protocol, runtime_checkable\n\n\nclass HasX(Protocol):\n x: int\n\n\n@runtime_checkable\nclass HasY(Protocol):\n y: int\n\n\ndef f(arg: object, arg2: type):\n # not runtime-checkable\n isinstance(arg, HasX) # error: [isinstance-against-protocol]\n # not runtime-checkable\n issubclass(arg2, HasX) # error: [isinstance-against-protocol]\n\n\ndef g(arg: object):\n match arg:\n # not runtime-checkable\n case HasX(): # error: [isinstance-against-protocol]\n pass\n\n\ndef h(arg2: type):\n isinstance(arg2, HasY) # fine (runtime-checkable)\n\n # `HasY` is runtime-checkable, but has non-method members,\n # so it still can't be used in `issubclass` checks)\n issubclass(arg2, HasY) # error: [isinstance-against-protocol]\n```\n\n## References\n\n- [Typing documentation: `@runtime_checkable`](https://docs.python.org/3/library/typing.html#typing.runtime_checkable)", "default": "error", "oneOf": [ { @@ -1702,7 +1732,7 @@ }, "isinstance-against-typed-dict": { "title": "reports runtime checks against `TypedDict` classes", - "description": "## What it does\n\nReports runtime checks against `TypedDict` classes.\nThis includes explicit calls to `isinstance()`/`issubclass()` and implicit\nchecks performed by `match` class patterns.\n\n## Why is this bad?\n\nUsing a `TypedDict` class in these contexts raises `TypeError` at runtime.\n\n## Examples\n\n```python\nfrom typing_extensions import TypedDict\n\n\nclass Movie(TypedDict):\n name: str\n director: str\n\n\ndef f(arg: object, arg2: type):\n isinstance(arg, Movie) # error: [isinstance-against-typed-dict]\n issubclass(arg2, Movie) # error: [isinstance-against-typed-dict]\n\n\ndef g(arg: object):\n match arg:\n case Movie(): # error: [isinstance-against-typed-dict]\n pass\n```\n\n## References\n\n- [Typing specification: `TypedDict`](https://typing.python.org/en/latest/spec/typeddict.html)", + "description": "## What it does\n\nReports runtime checks against `TypedDict` classes. This includes explicit calls to\n`isinstance()`/`issubclass()` and implicit checks performed by `match` class patterns.\n\n## Why is this bad?\n\nUsing a `TypedDict` class in these contexts raises `TypeError` at runtime.\n\n## Examples\n\n```python\nfrom typing_extensions import TypedDict\n\n\nclass Movie(TypedDict):\n name: str\n director: str\n\n\ndef f(arg: object, arg2: type):\n isinstance(arg, Movie) # error: [isinstance-against-typed-dict]\n issubclass(arg2, Movie) # error: [isinstance-against-typed-dict]\n\n\ndef g(arg: object):\n match arg:\n case Movie(): # error: [isinstance-against-typed-dict]\n pass\n```\n\n## References\n\n- [Typing specification: `TypedDict`](https://typing.python.org/en/latest/spec/typeddict.html)", "default": "error", "oneOf": [ { @@ -1722,7 +1752,7 @@ }, "mismatched-type-name": { "title": "detects functional typing definitions whose declared name does not match the assigned variable", - "description": "## What it does\n\nChecks for functional typing definitions whose declared name does not match\nthe variable they are assigned to.\n\n## Why is this bad?\n\nConstructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`,\n`TypedDict`, and `TypeAliasType` all take a name argument that is\nnormally expected to match the assigned variable. A mismatch is usually a\ntypo and makes later diagnostics harder to understand.\n\n## Default level\n\nThis rule is a warning by default because ty can usually recover and\ncontinue understanding the resulting type.\n\n## Examples\n\n```python\nfrom typing import NewType, ParamSpec, TypeVar\nfrom typing_extensions import TypedDict\n\nT = TypeVar(\"U\") # error: [mismatched-type-name]\nP = ParamSpec(\"Q\") # error: [mismatched-type-name]\nUserId = NewType(\"Id\", int) # error: [mismatched-type-name]\nMovie = TypedDict(\"Film\", {\"title\": str}) # error: [mismatched-type-name]\n```", + "description": "## What it does\n\nChecks for functional typing definitions whose declared name does not match the variable they are\nassigned to.\n\n## Why is this bad?\n\nConstructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, `TypedDict`, and `TypeAliasType`\nall take a name argument that is normally expected to match the assigned variable. A mismatch is\nusually a typo and makes later diagnostics harder to understand.\n\n## Default level\n\nThis rule is a warning by default because ty can usually recover and continue understanding the\nresulting type.\n\n## Examples\n\n```python\nfrom typing import NewType, ParamSpec, TypeVar\nfrom typing_extensions import TypedDict\n\nT = TypeVar(\"U\") # error: [mismatched-type-name]\nP = ParamSpec(\"Q\") # error: [mismatched-type-name]\nUserId = NewType(\"Id\", int) # error: [mismatched-type-name]\nMovie = TypedDict(\"Film\", {\"title\": str}) # error: [mismatched-type-name]\n```", "default": "warn", "oneOf": [ { @@ -1760,6 +1790,16 @@ } ] }, + "missing-direct-dependency": { + "title": "detects imports of dependencies that are not declared directly", + "description": "## What it does\n\nChecks for imports from installable packages that the current project or PEP 723 script does not\ndeclare as direct dependencies.\n\nThe name used in dependency declarations can differ from the import name: for example, the `pillow`\npackage is imported as `PIL`.\n\n## Why is this bad?\n\nA dependency can be installed because another package requires it. Importing that dependency without\ndeclaring it makes your code rely on another package's dependency list. If that package removes the\ndependency, your imports can fail.\n\nDeclare the packages that provide your imports in `project.dependencies` or\n`project.optional-dependencies` in `pyproject.toml`. Non-package files, such as tests and\ndevelopment scripts, can also use dependencies declared in dependency groups.\n\nSee uv's [guide to managing dependencies](https://docs.astral.sh/uv/concepts/projects/dependencies/)\nfor how to add these declarations.\n\n## Rule status\n\nThis rule is disabled by default and requires uv integration.\n\nFor projects, enable uv workspace integration (`TY_UV=1`) and use an existing, synchronized\nenvironment. Running [`uv check`](https://docs.astral.sh/uv/reference/cli/#uv-check) synchronizes\nthe environment automatically before invoking ty, unless `--no-sync` is passed. For these checks, ty\nreads the dependency graph and module ownership returned by `uv workspace metadata` without changing\ninstalled packages. uv may update the lockfile to match the current dependency declarations. uv\n0.12.3 or later is required.\n\nFor PEP 723 scripts, enable uv script integration with `TY_UV=scripts` or `TY_UV=1`. ty synchronizes\neach script's environment and checks imports against its inline `dependencies` list. Declarations\nand environments from the enclosing workspace or other scripts do not apply.\n\n## Known limitations\n\nThe current workspace integration applies to directory checks. Explicit file arguments and\n`--config-file` bypass uv workspace discovery.\n\nImports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They\ncan use development-only dependencies, such as type stub packages, without requiring those packages\nas runtime dependencies.\n\nStandard-library imports and imports whose owning package cannot be identified unambiguously are\nalso not reported.\n\nImports of [namespace packages](https://docs.python.org/3/reference/import.html#namespace-packages)\nthemselves, such as `import ns`, are not reported: the namespace can contain modules from several\ninstallable packages. Imports of their submodules, such as `import ns.child`, are checked when the\nowning package is known. An `__init__.pyi` stub does not change this distinction.\n\nNative packages that ty can resolve only as namespace packages at runtime are also skipped. For\nother native modules, ty can use stubs to resolve the import and uv's ownership map to identify\nwhich package to declare.\n\nSome editable installations add the whole project directory to Python's import path, making both\npackage code and files such as `tests/test_app.py` importable. If uv does not identify which modules\nbelong to the installable package, ty allows dependency-group imports throughout that directory,\nincluding in package code, to avoid incorrectly flagging imports in tests and scripts.\n\n## Examples\n\nWith `requests` as a direct dependency, `urllib3` may also be installed because `requests` depends\non it:\n\n```python {data-mdtest=\"ignore\"}\nimport requests\nimport urllib3 # error: [missing-direct-dependency]\n```\n\nAdd `urllib3` to `project.dependencies` if your code imports it directly.", + "default": "ignore", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "missing-framework-stubs": { "title": "detects framework imports whose external stubs package is not installed", "description": "## What it does\nChecks for imports of frameworks that ship no inline type annotations\nwhen their external PEP 561 stubs package is not installed.\n\n## Why is this bad?\nWithout the stubs package the framework's types resolve from its untyped\nruntime source, so most framework-aware checking silently degrades to\n`Unknown`. Installing the stubs package restores precise types.\n\n## Example\n\n```py\nfrom django.db import models # warning: install `django-stubs` for precise types\n```", @@ -1772,7 +1812,17 @@ }, "missing-override-decorator": { "title": "detects methods that override a superclass member without an `@override` annotation", - "description": "## What it does\n\nChecks for methods that override a method or attribute in a superclass but are not decorated with `@override`.\n\nThis rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a project.\n\n## Exemptions\n\nOverriding `__init__`, `__new__`, `__init_subclass__`, or `__post_init__` does not require\n`@override`, even if the method is explicitly declared by a superclass.\n\n## Why is this bad?\n\nWithout an `@override` annotation, refactors can silently change whether a method is an override.\nRequiring `@override` on every override lets ty report when an intended override stops overriding\nanything, and when a method unexpectedly starts overriding a superclass member.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import override\n\n\nclass Parent:\n def method(self) -> int:\n return 1\n\n\nclass Child(Parent):\n # when the rule is enabled\n def method(self) -> int: # error\n return 2\n\n\nclass ExplicitChild(Parent):\n @override\n def method(self) -> int: # fine\n return 2\n```", + "description": "## What it does\n\nChecks for methods that override a method or attribute in a superclass but are not decorated with\n`@override`.\n\nThis rule is disabled by default. Enable it to opt in to strict `@override` enforcement for a\nproject.\n\n## Exemptions\n\nOverriding `__init__`, `__new__`, `__init_subclass__`, or `__post_init__` does not require\n`@override`, even if the method is explicitly declared by a superclass.\n\n## Why is this bad?\n\nWithout an `@override` annotation, refactors can silently change whether a method is an override.\nRequiring `@override` on every override lets ty report when an intended override stops overriding\nanything, and when a method unexpectedly starts overriding a superclass member.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import override\n\n\nclass Parent:\n def method(self) -> int:\n return 1\n\n\nclass Child(Parent):\n # when the rule is enabled\n def method(self) -> int: # error\n return 2\n\n\nclass ExplicitChild(Parent):\n @override\n def method(self) -> int: # fine\n return 2\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, + "missing-slot": { + "title": "detects assignments to declared attributes without instance storage", + "description": "## What it does\n\nChecks for assignments to declared attributes that have no matching `__slots__` entry on the class\nor its bases, and no instance dictionary to store their values.\n\n## Why is this bad?\n\nMost Python objects store their attributes in an \"instance dictionary\". Assigning to a new attribute\nadds an entry to this dictionary; deleting that attribute removes it again. Accordingly, most Python\nobjects allow for **arbitrary attributes to be set and read**. The advantage of this is that it\nallows for many dynamic features; the disadvantage is that it can be costly in terms of memory, and\ncan easily allow for typos to slip in accidentally, e.g.:\n\n```py\nclass Foo:\n def __init__(self, x):\n self.x = x\n\n def update_x(self, x):\n self.xx = x # oops, this was meant to be the same attribute set in `__init__`,\n # but ended up being an entirely separate one!\n```\n\nDefining `__slots__` lets a class reserve space for a fixed set of instance attributes instead.\nUnless an instance dictionary is inherited from a base class or requested by including `\"__dict__\"`\nin `__slots__`, instances of the class have no dictionary in which to store additional attributes.\nAttempting to assign to an attribute not declared in `__slots__` will often raise `AttributeError`\nat runtime if the instance has no instance dictionary.\n\n## Examples\n\n### Class definitions\n\n```python\nclass Item:\n __slots__ = ()\n value: int\n\n\nItem().value = 1 # error: [missing-slot]\n```\n\nIf you control the class, include the attribute in `__slots__` to make the assignment valid:\n\n```python\nclass Item:\n __slots__ = (\"value\",)\n value: int\n\n\nItem().value = 1\n```\n\n### Stub files\n\nStub files can use properties to indicate that instances have attributes that are readable and\nwritable but do not appear in `__slots__`, for example:\n\n```pyi\nclass Item:\n __slots__ = ()\n @property\n def value(self) -> int: ...\n @value.setter\n def value(self, value: int) -> None: ...\n```\n\n## References\n\n- [Python data model: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots)", "default": "error", "oneOf": [ { @@ -1782,7 +1832,7 @@ }, "missing-type-argument": { "title": "detects generic types used without explicit type parameters in type expressions", - "description": "## What it does\n\nChecks for generic types used without type parameters in type expressions.\n\n## Why is this bad?\n\nUsing a generic type without specifying its type parameters results in the\ntype parameters being implicitly filled with `Unknown`, reducing the\nprecision of type checking. Explicit type parameters make the intended types\nclear and enable the type checker to catch more errors.\n\n## Examples\n\n```python\nimport re\n\n\ndef handle(m: re.Match) -> str: # error: [missing-type-argument]\n return m.string\n\n\n# Use explicit type parameters instead:\ndef handle(m: re.Match[str]) -> str:\n return m.string\n```", + "description": "## What it does\n\nChecks for generic types used without type parameters in type expressions.\n\n## Why is this bad?\n\nUsing a generic type without specifying its type parameters results in the type parameters being\nimplicitly filled with `Unknown`, reducing the precision of type checking. Explicit type parameters\nmake the intended types clear and enable the type checker to catch more errors.\n\n## Examples\n\n```python\nimport re\n\n\ndef handle(m: re.Match) -> str: # error: [missing-type-argument]\n return m.string\n\n\n# Use explicit type parameters instead:\ndef handle(m: re.Match[str]) -> str:\n return m.string\n```", "default": "error", "oneOf": [ { @@ -1792,7 +1842,7 @@ }, "missing-typed-dict-key": { "title": "detects missing required keys in `TypedDict` constructors", - "description": "## What it does\n\nDetects missing required keys in `TypedDict` constructor calls.\n\n## Why is this bad?\n\n`TypedDict` requires all non-optional keys to be provided during construction.\nMissing items can lead to a `KeyError` at runtime.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Person(TypedDict):\n name: str\n age: int\n\n\n# missing required key 'age'\nalice: Person = {\"name\": \"Alice\"} # error\n\nalice[\"age\"] # KeyError\n```", + "description": "## What it does\n\nDetects missing required keys in `TypedDict` constructor calls.\n\n## Why is this bad?\n\n`TypedDict` requires all non-optional keys to be provided during construction. Missing items can\nlead to a `KeyError` at runtime.\n\n## Example\n\n```python\nfrom typing import TypedDict\n\n\nclass Person(TypedDict):\n name: str\n age: int\n\n\n# missing required key 'age'\nalice: Person = {\"name\": \"Alice\"} # error\n\nalice[\"age\"] # KeyError\n```", "default": "error", "oneOf": [ { @@ -1812,7 +1862,7 @@ }, "no-matching-overload": { "title": "detects calls that do not match any overload", - "description": "## What it does\n\nChecks for calls to an overloaded function that do not match any of the overloads.\n\n## Why is this bad?\n\nFailing to provide the correct arguments to one of the overloads will raise a `TypeError`\nat runtime.\n\n## Examples\n\n```python\nfrom typing import overload\n\n\n@overload\ndef func(x: int): ...\n@overload\ndef func(x: bool): ...\ndef func(x: int | bool): ...\n\n\nfunc(\"string\") # error: [no-matching-overload]\n```", + "description": "## What it does\n\nChecks for calls to an overloaded function that do not match any of the overloads.\n\n## Why is this bad?\n\nFailing to provide the correct arguments to one of the overloads will raise a `TypeError` at\nruntime.\n\n## Examples\n\n```python\nfrom typing import overload\n\n\n@overload\ndef func(x: int): ...\n@overload\ndef func(x: bool): ...\ndef func(x: int | bool): ...\n\n\nfunc(\"string\") # error: [no-matching-overload]\n```", "default": "error", "oneOf": [ { @@ -1822,7 +1872,7 @@ }, "non-callable-init-subclass": { "title": "detects class definitions that will fail due to non-callable `__init_subclass__`", - "description": "## What it does\n\nChecks for class definitions that will fail due to non-callable `__init_subclass__`\nmethods.\n\n## Why is this bad?\n\nIf a class defines a non-callable `__init_subclass__` method/attribute, any attempt\nto subclass that class will raise a `TypeError` at runtime.\n\n## Examples\n\n```python\nclass Super:\n __init_subclass__ = None\n\n\nclass Sub(Super): ... # error: [non-callable-init-subclass]\n```\n\n## References\n\n- [Python data model: Customizing class creation](https://docs.python.org/3/reference/datamodel.html#customizing-class-creation)", + "description": "## What it does\n\nChecks for class definitions that will fail due to non-callable `__init_subclass__` methods.\n\n## Why is this bad?\n\nIf a class defines a non-callable `__init_subclass__` method/attribute, any attempt to subclass that\nclass will raise a `TypeError` at runtime.\n\n## Examples\n\n```python\nclass Super:\n __init_subclass__ = None\n\n\nclass Sub(Super): ... # error: [non-callable-init-subclass]\n```\n\n## References\n\n- [Python data model: Customizing class creation](https://docs.python.org/3/reference/datamodel.html#customizing-class-creation)", "default": "error", "oneOf": [ { @@ -1922,7 +1972,7 @@ }, "override-of-final-method": { "title": "detects overrides of final methods", - "description": "## What it does\n\nChecks for methods on subclasses that override superclass methods decorated with `@final`.\n\n## Why is this bad?\n\nDecorating a method with `@final` declares to the type checker that it should not be\noverridden on any subclass.\n\n## Example\n\n```python\nfrom typing import final\n\n\nclass A:\n @final\n def foo(self): ...\n\n\nclass B(A):\n def foo(self): ... # error\n```", + "description": "## What it does\n\nChecks for methods on subclasses that override superclass methods decorated with `@final`.\n\n## Why is this bad?\n\nDecorating a method with `@final` declares to the type checker that it should not be overridden on\nany subclass.\n\n## Example\n\n```python\nfrom typing import final\n\n\nclass A:\n @final\n def foo(self): ...\n\n\nclass B(A):\n def foo(self): ... # error\n```", "default": "error", "oneOf": [ { @@ -1932,7 +1982,7 @@ }, "override-of-final-variable": { "title": "detects overrides of Final class variables", - "description": "## What it does\n\nChecks for class variables on subclasses that override a superclass variable\nthat has been declared as `Final`.\n\n## Why is this bad?\n\nDeclaring a variable as `Final` indicates to the type checker that it should not be\noverridden on any subclass.\n\n## Example\n\n```python\nfrom typing import Final\n\n\nclass A:\n X: Final[int] = 1\n\n\nclass B(A):\n X = 2 # error\n```", + "description": "## What it does\n\nChecks for class variables on subclasses that override a superclass variable that has been declared\nas `Final`.\n\n## Why is this bad?\n\nDeclaring a variable as `Final` indicates to the type checker that it should not be overridden on\nany subclass.\n\n## Example\n\n```python\nfrom typing import Final\n\n\nclass A:\n X: Final[int] = 1\n\n\nclass B(A):\n X = 2 # error\n```", "default": "error", "oneOf": [ { @@ -1972,7 +2022,7 @@ }, "possibly-missing-attribute": { "title": "detects references to possibly missing attributes", - "description": "## What it does\n\nChecks for possibly missing attributes.\n\n## Why is this bad?\n\nAttempting to access a missing attribute will raise an `AttributeError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of\nfalse positives it can produce.\n\n## Examples\n\n```python\nclass A:\n if __name__ == \"__main__\":\n c = 0\n\n\n# AttributeError: type object 'A' has no attribute 'c'\nA.c # error\n```", + "description": "## What it does\n\nChecks for possibly missing attributes.\n\n## Why is this bad?\n\nAttempting to access a missing attribute will raise an `AttributeError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of false positives it can produce.\n\n## Examples\n\n```python\nclass A:\n if __name__ == \"__main__\":\n c = 0\n\n\n# AttributeError: type object 'A' has no attribute 'c'\nA.c # error\n```", "default": "error", "oneOf": [ { @@ -1982,7 +2032,7 @@ }, "possibly-missing-implicit-call": { "title": "detects implicit calls to possibly missing methods", - "description": "## What it does\n\nChecks for implicit calls to possibly missing methods.\n\n## Why is this bad?\n\nExpressions such as `x[y]` and `x * y` call methods\nunder the hood (`__getitem__` and `__mul__` respectively).\nCalling a missing method will raise an `AttributeError` at runtime.\n\n## Examples\n\n```python\nimport datetime\n\n\nclass A:\n if datetime.date.today().weekday() != 6:\n\n def __getitem__(self, v): ...\n\n\n# TypeError: 'A' object is not subscriptable\nA()[0] # error\n```", + "description": "## What it does\n\nChecks for implicit calls to possibly missing methods.\n\n## Why is this bad?\n\nExpressions such as `x[y]` and `x * y` call methods under the hood (`__getitem__` and `__mul__`\nrespectively). Calling a missing method will raise an `AttributeError` at runtime.\n\n## Examples\n\n```python\nimport datetime\n\n\nclass A:\n if datetime.date.today().weekday() != 6:\n\n def __getitem__(self, v): ...\n\n\n# TypeError: 'A' object is not subscriptable\nA()[0] # error\n```", "default": "warn", "oneOf": [ { @@ -1992,7 +2042,7 @@ }, "possibly-missing-import": { "title": "detects possibly missing imports", - "description": "## What it does\n\nChecks for imports of symbols that may be missing.\n\n## Why is this bad?\n\nImporting a missing module or name will raise a `ModuleNotFoundError`\nor `ImportError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of\nfalse positives it can produce.\n\n## Examples\n\n`module.py`:\n\n```python\nimport datetime\n\nif datetime.date.today().weekday() != 6:\n a = 1\n```\n\n`main.py`:\n\n```python\n# ImportError: cannot import name 'a' from 'module'\nfrom module import a # error\n```", + "description": "## What it does\n\nChecks for imports of symbols that may be missing.\n\n## Why is this bad?\n\nImporting a missing module or name will raise a `ModuleNotFoundError` or `ImportError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of false positives it can produce.\n\n## Examples\n\n`module.py`:\n\n```python\nimport datetime\n\nif datetime.date.today().weekday() != 6:\n a = 1\n```\n\n`main.py`:\n\n```python\n# ImportError: cannot import name 'a' from 'module'\nfrom module import a # error\n```", "default": "error", "oneOf": [ { @@ -2002,7 +2052,7 @@ }, "possibly-missing-submodule": { "title": "detects accesses of submodules that may not be available as attributes on their parent module", - "description": "## What it does\n\nChecks for accesses of submodules that might not've been imported.\n\n## Why is this bad?\n\nWhen module `a` has a submodule `b`, `import a` isn't generally enough to let you access\n`a.b.` You either need to explicitly `import a.b`, or else you need the `__init__.py` file\nof `a` to include `from . import b`. Without one of those, `a.b` is an `AttributeError`.\n\n## Examples\n\n```python\nimport html\n\n# AttributeError: module 'html' has no attribute 'parser'\nhtml.parser # error\n```", + "description": "## What it does\n\nChecks for accesses of submodules that might not've been imported.\n\n## Why is this bad?\n\nWhen module `a` has a submodule `b`, `import a` isn't generally enough to let you access `a.b.` You\neither need to explicitly `import a.b`, or else you need the `__init__.py` file of `a` to include\n`from . import b`. Without one of those, `a.b` is an `AttributeError`.\n\n## Examples\n\n```python\nimport html\n\n# AttributeError: module 'html' has no attribute 'parser'\nhtml.parser # error\n```", "default": "warn", "oneOf": [ { @@ -2012,7 +2062,7 @@ }, "possibly-unresolved-reference": { "title": "detects references to possibly undefined names", - "description": "## What it does\n\nChecks for references to names that are possibly not defined.\n\n## Why is this bad?\n\nUsing an undefined variable will raise a `NameError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of\nfalse positives it can produce.\n\n## Example\n\n```python\nfor i in range(int(input())):\n x = i\n\n# NameError: name 'x' is not defined\nprint(x) # error\n```", + "description": "## What it does\n\nChecks for references to names that are possibly not defined.\n\n## Why is this bad?\n\nUsing an undefined variable will raise a `NameError` at runtime.\n\n## Rule status\n\nThis rule is currently disabled by default because of the number of false positives it can produce.\n\n## Example\n\n```python\nfor i in range(int(input())):\n x = i\n\n# NameError: name 'x' is not defined\nprint(x) # error\n```", "default": "error", "oneOf": [ { @@ -2032,7 +2082,7 @@ }, "pydantic-discarded-extra-argument": { "title": "detects extra constructor arguments that Pydantic silently discards", - "description": "## What it does\n\nChecks for extra keyword arguments that Pydantic silently discards when a model uses\n`extra=\"ignore\"`, either implicitly or explicitly.\n\n## Why is this bad?\n\nA discarded argument has no effect on the constructed model, but it may indicate a misspelled field\nname or an incorrect assumption about the model's schema.\n\n## Example\n\n```python {data-mdtest=\"ignore\"}\nfrom pydantic import BaseModel\n\n\nclass User(BaseModel):\n name: str\n admin: bool = False\n\n\nuser = User(name=\"Alice\", admni=True) # error: [pydantic-discarded-extra-argument]\n```\n\nIf the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument,\nor explicitly configure the model with `extra=\"allow\"`.", + "description": "## What it does\n\nChecks for extra keyword arguments that Pydantic silently discards when a model uses\n`extra=\"ignore\"`, either implicitly or explicitly.\n\n## Why is this bad?\n\nA discarded argument has no effect on the constructed model, but it may indicate a misspelled field\nname or an incorrect assumption about the model's schema.\n\n## Example\n\n```python {data-mdtest=\"ignore\"}\nfrom pydantic import BaseModel\n\n\nclass User(BaseModel):\n name: str\n admin: bool = False\n\n\nuser = User(name=\"Alice\", admni=True) # error: [pydantic-discarded-extra-argument]\n```\n\nIf the field name has been misspelled, fix the typo. Otherwise, consider removing the extra\nargument, or explicitly configure the model with `extra=\"allow\"`.", "default": "warn", "oneOf": [ { @@ -2082,7 +2132,7 @@ }, "redundant-final-classvar": { "title": "detects redundant combinations of `ClassVar` and `Final`", - "description": "## What it does\n\nChecks for redundant combinations of the `ClassVar` and `Final` type qualifiers.\n\n## Why is this bad?\n\nAn attribute that is marked `Final` in a class body is implicitly a class variable.\nMarking it as `ClassVar` is therefore redundant.\n\nNote that this diagnostic is not emitted for dataclass fields or protocol members,\nwhere `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`.\n\n## Examples\n\n```python\nfrom typing import ClassVar, Final\n\n\nclass C:\n # redundant\n x: ClassVar[Final[int]] = 1 # error\n # redundant\n y: Final[ClassVar[int]] = 1 # error\n```", + "description": "## What it does\n\nChecks for redundant combinations of the `ClassVar` and `Final` type qualifiers.\n\n## Why is this bad?\n\nAn attribute that is marked `Final` in a class body is implicitly a class variable. Marking it as\n`ClassVar` is therefore redundant.\n\nNote that this diagnostic is not emitted for dataclass fields or protocol members, where\n`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`.\n\n## Examples\n\n```python\nfrom typing import ClassVar, Final\n\n\nclass C:\n # redundant\n x: ClassVar[Final[int]] = 1 # error\n # redundant\n y: Final[ClassVar[int]] = 1 # error\n```", "default": "warn", "oneOf": [ { @@ -2102,7 +2152,7 @@ }, "refutable-destructuring": { "title": "detects a destructuring binder whose pattern may not match, with nothing to handle the failure", - "description": "## What it does\n\nChecks for a basedpython destructuring binder whose pattern may not match the\nvalue it destructures, with nothing to handle the failure.\n\n## Why is this bad?\n\nA destructuring binder — a `let` statement, a `for` target, a `with` item, a\nparameter — binds its captures unconditionally. A pattern that does not match\nleaves them unbound, which is a `NameError` at the first use.\n\nA `let` statement can handle the failure with an `else` block, but only if the\nblock diverges: control that falls out of it reaches the same unbound captures.\n\n## Examples\n\n```by\ndef f(value: int | str) -> int:\n let int(n) := value # error: [refutable-destructuring]\n return n\n\ndef g(value: int | str) -> int:\n let int(n) := value else: # error: [refutable-destructuring]\n print(\"not an int\")\n return n # error: [possibly-unresolved-reference]\n```\n\nUse a pattern that matches every value of the type, or an `else` block that\ndiverges:\n\n```by\ndef f(value: int | str) -> int:\n let int(n) := value else:\n return 0\n return n\n```", + "description": "## What it does\n\nChecks for a basedpython destructuring binder whose pattern may not match the value it destructures,\nwith nothing to handle the failure.\n\n## Why is this bad?\n\nA destructuring binder — a `let` statement, a `for` target, a `with` item, a parameter — binds its\ncaptures unconditionally. A pattern that does not match leaves them unbound, which is a `NameError`\nat the first use.\n\nA `let` statement can handle the failure with an `else` block, but only if the block diverges:\ncontrol that falls out of it reaches the same unbound captures.\n\n## Examples\n\n```by\ndef f(value: int | str) -> int:\n let int(n) := value # error: [refutable-destructuring]\n return n\n\ndef g(value: int | str) -> int:\n let int(n) := value else: # error: [refutable-destructuring]\n print(\"not an int\")\n return n # error: [possibly-unresolved-reference]\n```\n\nUse a pattern that matches every value of the type, or an `else` block that diverges:\n\n```by\ndef f(value: int | str) -> int:\n let int(n) := value else:\n return 0\n return n\n```", "default": "error", "oneOf": [ { @@ -2112,7 +2162,7 @@ }, "refutable-unpacking": { "title": "detects an unpacking whose value is not known to have the number of elements the targets require", - "description": "## What it does\n\nChecks for an unpacking assignment whose value is not known to have the number\nof elements the targets require.\n\n## Why is this bad?\n\n`a, b = value` binds both names unconditionally, but the unpacking only succeeds\nif `value` yields exactly two elements. A `tuple[int, ...]`, a `list[int]`, or\nany other iterable whose length is not part of its type satisfies the annotation\nat every length, so nothing rules out a `ValueError` at runtime.\n\nA starred target absorbs any number of elements, so it only requires the ones\naround it: `a, *rest = value` still needs at least one element, and reports for\nthe same reason. A splatted argument is the same question against a parameter\nlist: `f(*value)` binds the parameters positionally, so a length that does not\nmatch raises `TypeError` rather than `ValueError`.\n\nThree values are left alone: one whose type is `Any`, which has opted out of\nchecking altogether; one whose element type is `Unknown`, which ty fills in\nwhere the code said nothing at all; and an unannotated parameter, whose type is\nbounded by what its function's body asks of it — including the unpacking itself.\n\n## Examples\n\n```python\ndef f() -> tuple[int, ...]:\n return ()\n\n\ndef take(a: int, b: int) -> None: ...\n\n\na, b = f() # error: [refutable-unpacking]\ntake(*f()) # error: [refutable-unpacking]\n```\n\nGive the value a length the type carries, or narrow it to one:\n\n```python\ndef f() -> tuple[int, int]:\n return (1, 2)\n\n\na, b = f()\n\n\ndef g(values: tuple[int, ...]) -> None:\n if len(values) == 2:\n c, d = values # ok — narrowed to `tuple[int, int]`\n```", + "description": "## What it does\n\nChecks for an unpacking assignment whose value is not known to have the number of elements the\ntargets require.\n\n## Why is this bad?\n\n`a, b = value` binds both names unconditionally, but the unpacking only succeeds if `value` yields\nexactly two elements. A `tuple[int, ...]`, a `list[int]`, or any other iterable whose length is not\npart of its type satisfies the annotation at every length, so nothing rules out a `ValueError` at\nruntime.\n\nA starred target absorbs any number of elements, so it only requires the ones around it:\n`a, *rest = value` still needs at least one element, and reports for the same reason. A splatted\nargument is the same question against a parameter list: `f(*value)` binds the parameters\npositionally, so a length that does not match raises `TypeError` rather than `ValueError`.\n\nThree values are left alone: one whose type is `Any`, which has opted out of checking altogether;\none whose element type is `Unknown`, which ty fills in where the code said nothing at all; and an\nunannotated parameter, whose type is bounded by what its function's body asks of it — including the\nunpacking itself.\n\n## Examples\n\n```python\ndef f() -> tuple[int, ...]:\n return ()\n\n\ndef take(a: int, b: int) -> None: ...\n\n\na, b = f() # error: [refutable-unpacking]\ntake(*f()) # error: [refutable-unpacking]\n```\n\nGive the value a length the type carries, or narrow it to one:\n\n```python\ndef f() -> tuple[int, int]:\n return (1, 2)\n\n\na, b = f()\n\n\ndef g(values: tuple[int, ...]) -> None:\n if len(values) == 2:\n c, d = values # ok — narrowed to `tuple[int, int]`\n```", "default": "error", "oneOf": [ { @@ -2152,7 +2202,7 @@ }, "shadowed-type-variable": { "title": "detects type variables that shadow type variables from outer scopes", - "description": "## What it does\n\nChecks for type variables in nested generic classes or functions that shadow type variables\nfrom an enclosing scope.\n\n## Why is this bad?\n\nShadowing type variables makes the code confusing and is disallowed by the typing spec.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nclass Outer[T]:\n # `T` is already used by `Outer`\n class Inner[T]: ... # error\n\n # `T` is already used by `Outer`\n def method[T](self, x: T) -> T: # error\n return x\n```\n\n## References\n\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", + "description": "## What it does\n\nChecks for type variables in nested generic classes or functions that shadow type variables from an\nenclosing scope.\n\n## Why is this bad?\n\nShadowing type variables makes the code confusing and is disallowed by the typing spec.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nclass Outer[T]:\n # `T` is already used by `Outer`\n class Inner[T]: ... # error\n\n # `T` is already used by `Outer`\n def method[T](self, x: T) -> T: # error\n return x\n```\n\n## References\n\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", "default": "error", "oneOf": [ { @@ -2162,7 +2212,7 @@ }, "static-assert-error": { "title": "Failed static assertion", - "description": "## What it does\n\nMakes sure that the argument of `static_assert` is statically known to be true.\n\n## Why is this bad?\n\nA `static_assert` call represents an explicit request from the user\nfor the type checker to emit an error if the argument cannot be verified\nto evaluate to `True` in a boolean context.\n\n## Examples\n\n```python\nfrom ty_extensions import static_assert\n\n# evaluates to `False`\nstatic_assert(1 + 1 == 3) # error\n\n# does not have a statically known truthiness\nstatic_assert(int(2.0 * 3.0) == 6) # error\n```", + "description": "## What it does\n\nMakes sure that the argument of `static_assert` is statically known to be true.\n\n## Why is this bad?\n\nA `static_assert` call represents an explicit request from the user for the type checker to emit an\nerror if the argument cannot be verified to evaluate to `True` in a boolean context.\n\n## Examples\n\n```python\nfrom ty_extensions import static_assert\n\n# evaluates to `False`\nstatic_assert(1 + 1 == 3) # error\n\n# does not have a statically known truthiness\nstatic_assert(int(2.0 * 3.0) == 6) # error\n```", "default": "error", "oneOf": [ { @@ -2172,7 +2222,7 @@ }, "subclass-of-dataclass-with-order": { "title": "detects subclasses of dataclasses with `order=True`", - "description": "## What it does\n\nChecks for classes that inherit from a dataclass with `order=True`.\n\n## Why is this bad?\n\nWhen a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`)\nare generated that compare instances as tuples of their fields. These methods raise a\n`TypeError` at runtime when comparing instances of different classes in the inheritance\nhierarchy, even if one is a subclass of the other.\n\nThis violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class instances cannot be\nused in all contexts where parent class instances are expected.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass(order=True)\nclass Parent:\n value: int\n\n\nclass Child(Parent): # error\n pass\n\n\n# At runtime, this raises TypeError:\n# Child(1) < Parent(2)\n```\n\nConsider using [`functools.total_ordering`][total_ordering] instead, which does not have this limitation.\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle\n[total_ordering]: https://docs.python.org/3/library/functools.html#functools.total_ordering", + "description": "## What it does\n\nChecks for classes that inherit from a dataclass with `order=True`.\n\n## Why is this bad?\n\nWhen a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`) are\ngenerated that compare instances as tuples of their fields. These methods raise a `TypeError` at\nruntime when comparing instances of different classes in the inheritance hierarchy, even if one is a\nsubclass of the other.\n\nThis violates the [Liskov Substitution Principle][liskov-substitution-principle] because child class\ninstances cannot be used in all contexts where parent class instances are expected.\n\n## Example\n\n```python\nfrom dataclasses import dataclass\n\n\n@dataclass(order=True)\nclass Parent:\n value: int\n\n\nclass Child(Parent): # error\n pass\n\n\n# At runtime, this raises TypeError:\n# Child(1) < Parent(2)\n```\n\nConsider using [`functools.total_ordering`][total_ordering] instead, which does not have this\nlimitation.\n\n[liskov-substitution-principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle\n[total_ordering]: https://docs.python.org/3/library/functools.html#functools.total_ordering", "default": "warn", "oneOf": [ { @@ -2272,7 +2322,7 @@ }, "type-assertion-failure": { "title": "detects failed type assertions", - "description": "## What it does\n\nChecks for `assert_type()` and `assert_never()` calls where the actual type\nis not the same as the asserted type.\n\n## Why is this bad?\n\n`assert_type()` allows confirming the inferred type of a certain value.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```python\nfrom typing import assert_type\n\n\ndef _(x: int):\n assert_type(x, int) # fine\n # Actual type does not match asserted type\n assert_type(x, str) # error\n```", + "description": "## What it does\n\nChecks for `assert_type()` and `assert_never()` calls where the actual type is not the same as the\nasserted type.\n\n## Why is this bad?\n\n`assert_type()` allows confirming the inferred type of a certain value.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```python\nfrom typing import assert_type\n\n\ndef _(x: int):\n assert_type(x, int) # fine\n # Actual type does not match asserted type\n assert_type(x, str) # error\n```", "default": "error", "oneOf": [ { @@ -2292,7 +2342,7 @@ }, "unavailable-implicit-super-arguments": { "title": "detects invalid `super()` calls where implicit arguments are unavailable.", - "description": "## What it does\n\nDetects invalid `super()` calls where implicit arguments like the enclosing class or first method argument are unavailable.\n\n## Why is this bad?\n\nWhen `super()` is used without arguments, Python tries to find two things:\nthe nearest enclosing class and the first argument of the immediately enclosing function (typically self or cls).\nIf either of these is missing, the call will fail at runtime with a `RuntimeError`.\n\n## Examples\n\n```python\n# no enclosing class or function found\nsuper() # error\n\n\ndef func():\n # no enclosing class or first argument exists\n super() # error\n\n\nclass A:\n # no enclosing function to provide the first argument\n f = super() # error\n\n def method(self):\n def nested():\n # first argument does not exist in this nested function\n super() # error\n\n # first argument does not exist in this lambda\n lambda: super() # error\n\n # argument is not available in generator expression\n (super() for _ in range(10)) # error\n\n super() # okay! both enclosing class and first argument are available\n```\n\n## References\n\n- [Python documentation: super()](https://docs.python.org/3/library/functions.html#super)", + "description": "## What it does\n\nDetects invalid `super()` calls where implicit arguments like the enclosing class or first method\nargument are unavailable.\n\n## Why is this bad?\n\nWhen `super()` is used without arguments, Python tries to find two things: the nearest enclosing\nclass and the first argument of the immediately enclosing function (typically self or cls). If\neither of these is missing, the call will fail at runtime with a `RuntimeError`.\n\n## Examples\n\n```python\n# no enclosing class or function found\nsuper() # error\n\n\ndef func():\n # no enclosing class or first argument exists\n super() # error\n\n\nclass A:\n # no enclosing function to provide the first argument\n f = super() # error\n\n def method(self):\n def nested():\n # first argument does not exist in this nested function\n super() # error\n\n # first argument does not exist in this lambda\n lambda: super() # error\n\n # argument is not available in generator expression\n (super() for _ in range(10)) # error\n\n super() # okay! both enclosing class and first argument are available\n```\n\n## References\n\n- [Python documentation: super()](https://docs.python.org/3/library/functions.html#super)", "default": "error", "oneOf": [ { @@ -2302,7 +2352,7 @@ }, "unbound-type-variable": { "title": "detects type variables used outside of their bound scope", - "description": "## What it does\n\nChecks for type variables that are used in a scope where they are not bound\nto any enclosing generic context.\n\n## Why is this bad?\n\nUsing a type variable outside of a scope that binds it has no well-defined meaning.\n\n## Examples\n\n```python\nfrom typing import TypeVar, Generic\n\nT = TypeVar(\"T\")\nS = TypeVar(\"S\")\n\n# unbound type variable in module scope\nx: T # error\n\n\nclass C(Generic[T]):\n # S is not in this class's generic context\n x: list[S] = [] # error\n```\n\n## References\n\n- [Typing spec: Scoping rules for type variables](https://typing.python.org/en/latest/spec/generics.html#scoping-rules-for-type-variables)", + "description": "## What it does\n\nChecks for type variables that are used in a scope where they are not bound to any enclosing generic\ncontext.\n\n## Why is this bad?\n\nUsing a type variable outside of a scope that binds it has no well-defined meaning.\n\n## Examples\n\n```python\nfrom typing import TypeVar, Generic\n\nT = TypeVar(\"T\")\nS = TypeVar(\"S\")\n\n# unbound type variable in module scope\nx: T # error\n\n\nclass C(Generic[T]):\n # S is not in this class's generic context\n x: list[S] = [] # error\n```\n\n## References\n\n- [Typing spec: Scoping rules for type variables](https://typing.python.org/en/latest/spec/generics.html#scoping-rules-for-type-variables)", "default": "error", "oneOf": [ { @@ -2452,7 +2502,7 @@ }, "unresolved-attribute": { "title": "detects references to unresolved attributes", - "description": "## What it does\n\nChecks for unresolved attributes.\n\n## Why is this bad?\n\nAccessing an unbound attribute will raise an `AttributeError` at runtime.\nAn unresolved attribute is not guaranteed to exist from the type alone,\nso this could also indicate that the object is not of the type that the user expects.\n\n## Examples\n\n```python\nclass A: ...\n\n\n# AttributeError: 'A' object has no attribute 'foo'\nA().foo # error\n```", + "description": "## What it does\n\nChecks for unresolved attributes.\n\n## Why is this bad?\n\nAccessing an unbound attribute will raise an `AttributeError` at runtime. An unresolved attribute is\nnot guaranteed to exist from the type alone, so this could also indicate that the object is not of\nthe type that the user expects.\n\n## Examples\n\n```python\nclass A: ...\n\n\n# AttributeError: 'A' object has no attribute 'foo'\nA().foo # error\n```", "default": "error", "oneOf": [ { @@ -2462,7 +2512,7 @@ }, "unresolved-global": { "title": "detects `global` statements with no definition in the global scope", - "description": "## What it does\n\nDetects variables declared as `global` in an inner scope that have no explicit\nbindings or declarations in the global scope.\n\n## Why is this bad?\n\nFunction bodies with `global` statements can run in any order (or not at all), which makes\nit hard for static analysis tools to infer the types of globals without\nexplicit definitions or declarations.\n\n## Example\n\n### Assigning without a global-scope declaration\n\n```python\ndef f():\n # unresolved global\n global x # error\n x = 42\n\n\ndef g():\n print(x) # unresolved reference\n```\n\n### Use instead\n\n#### Declare the global\n\n```python\nx: int\n\n\ndef f():\n global x\n x = 42\n\n\ndef g():\n print(x)\n```\n\n#### Initialize the global\n\n```python\nx: int | None = None\n\n\ndef f():\n global x\n x = 42\n\n\ndef g():\n print(x)\n```", + "description": "## What it does\n\nDetects variables declared as `global` in an inner scope that have no explicit bindings or\ndeclarations in the global scope.\n\n## Why is this bad?\n\nFunction bodies with `global` statements can run in any order (or not at all), which makes it hard\nfor static analysis tools to infer the types of globals without explicit definitions or\ndeclarations.\n\n## Example\n\n### Assigning without a global-scope declaration\n\n```python\ndef f():\n # unresolved global\n global x # error\n x = 42\n\n\ndef g():\n print(x) # unresolved reference\n```\n\n### Use instead\n\n#### Declare the global\n\n```python\nx: int\n\n\ndef f():\n global x\n x = 42\n\n\ndef g():\n print(x)\n```\n\n#### Initialize the global\n\n```python\nx: int | None = None\n\n\ndef f():\n global x\n x = 42\n\n\ndef g():\n print(x)\n```", "default": "warn", "oneOf": [ { @@ -2472,7 +2522,7 @@ }, "unresolved-import": { "title": "detects unresolved imports", - "description": "## What it does\n\nChecks for import statements for which the module cannot be resolved.\n\n## Why is this bad?\n\nImporting a module that cannot be resolved will raise a `ModuleNotFoundError`\nat runtime.\n\n## Examples\n\n```python\n# ModuleNotFoundError: No module named 'foo'\nimport foo # error\n```", + "description": "## What it does\n\nChecks for import statements for which the module cannot be resolved.\n\n## Why is this bad?\n\nImporting a module that cannot be resolved will raise a `ModuleNotFoundError` at runtime.\n\n## Examples\n\n```python\n# ModuleNotFoundError: No module named 'foo'\nimport foo # error\n```", "default": "error", "oneOf": [ { @@ -2530,6 +2580,16 @@ } ] }, + "unsound-assignment": { + "title": "detects assignments that unsoundly assign a type that is not a subtype of the declared type", + "description": "## What it does\n\nDetects variable assignments that unsoundly assign a type that is not a [subtype] of a variable's\ndeclared type.\n\nThis rule is a stricter version of `invalid-assignment`. Whereas that rule also flags assignments to\nattributes and subscripts, however, this rule is only applied to variable assignments.\n\nThis rule has no effect on stub files.\n\n## Why is this bad?\n\nBy default, type checkers consider an assignment valid if the inferred type of the assigned value is\n[assignable] to the target's declared type. However, this makes it easy for incorrect types to\npercolate through your code unexpectedly due to a single expression being inferred as `Any`. This\ncan easily lead to runtime errors that are not caught by the type checker:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\n# error: \"Unsound assignment: `Any` is not a subtype of `int`\"\nmy_integer: int = returns_any()\n\n# Fails at runtime, even though the type checker infers both operands as being of type `int`!\nmy_integer + 42\n```\n\nThis rule treats [\"fully static\"][fully-static] declared types as \"typed boundaries\" for your code.\nWith this rule enabled, ty would emit an error on the `my_integer: int = returns_any()` assignment,\nsince the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of\n`int`. This helps prevent the unsoundness from spreading far from its original source (in this case,\nthe return type of the `returns_any` function).\n\nNote that this rule is only applied to assignments where the declared type is\n[fully static][fully-static]. It will not trigger if `Any` or `Unknown` appear anywhere in the\ndeclared type, either implicitly or explicitly:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\nexplicitly_dynamic: Any = returns_any() # no error\nalso_dynamic: list[Any] = returns_any() # no error\n\n# no `unsound-assignment` error, since `list` is implicitly the same as `list[Unknown]`\n# (which is what the `missing-type-argument` error is complaining about)\n#\n# error: [missing-type-argument]\nimplicitly_dynamic: list = returns_any()\n```\n\nThis rule works especially well when combined with ty's `missing-type-argument` rule.\n\n## Examples\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return 42\n\n\n# error: \"Unsound assignment: `Any` is not a subtype of `int`\"\nmy_integer: int = returns_any()\n\nanother_integer: int\n\n# error: \"Unsound assignment: `Any` is not a subtype of `int`\"\nanother_integer = returns_any()\n```\n\nNarrow the value before assigning it to fix the diagnostics:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return 42\n\n\nvalue = returns_any()\nassert isinstance(value, int)\nmy_integer: int = value # no error: `Any & int` is a subtype of `int`\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for advanced users wanting additional soundness\nchecks from their type checker, not for users who have just started to use type checkers on their\nPython code.\n\n## See also\n\n- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather\n than unsound assignments\n- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound\n assignments\n\n[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable\n[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type\n[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype", + "default": "ignore", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "unsound-cast": { "title": "detects a plain `cast` that is not a widening", "description": "## What it does\nChecks for a plain `cast` that is not a widening — one whose value is not\nalready known to be the target type.\n\n## Why is this bad?\n`cast` reinterprets a value without looking at it, so it is only ever\ntruthful when the checker can already prove the value is the target.\nCasting *down* — `object` to `int` — makes a claim about the value that\nnothing verifies, and the program carries on with a type it may not have.\n\nThe two suffixed forms make the claim honest by saying what happens when\nit turns out to be false: `cast!` raises a `TypeError`, and `cast?`\nyields `None`, so its type is ` | None`.\n\nA gradual `Any` or `Unknown` value is not \"already the target\" either —\nnothing is known about it, which is exactly when a check is worth having.\n\n## Examples\n```by\ndef f(a: object, b: int, c: Any):\n b cast object # ok — `int` is already an `object`\n a cast int # error: `object` is not already an `int`\n c cast int # error: nothing is known about an `Any`\n\n a cast! int # raises unless `a` really is an `int`\n a cast? int # `int | None`\n```", @@ -2542,7 +2602,7 @@ }, "unsound-return-statement": { "title": "detects return statements that unsoundly return a type that is not a subtype of the function's annotated return type", - "description": "## What it does\n\nDetects `return` statements that unsoundly return a type that is not a [subtype] of the function's\nannotated return type.\n\nThis lint is a stricter version of `invalid-return-type`.\n\n## Why is this bad?\n\nBy default, type checkers consider a `return` statement valid if the inferred type of the object\nbeing returned is [assignable] to the annotated return type of the function it's in. However, this\nmakes it easy for incorrect types to percolate through your code unexpectedly due to a single\nexpression being inferred as `Any`. This can easily lead to runtime errors that are not caught by\nthe type checker:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\ndef returns_int() -> int:\n # error: \"Unsound return statement: `Any` is not a subtype of `int`\"\n return returns_any()\n\n\n# fails at runtime, even though the type checker infers both operands as being of type `int`!\nreturns_int() + 42\n```\n\nThis rule allows you to use [\"fully static\"][fully-static] return types as \"typed boundaries\" for\nyour code. With this rule enabled, ty would emit an error on the `return returns_any()` statement\nin `returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not\na subtype of `int`. This helps prevent the unsoundness from spreading far from its original source\n(in this case, the return type of the `returns_any` function).\n\nNote that this rule is only applied to functions annotated as returning\n[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in\nyour return type, either implicitly or explicitly:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\n# error: [missing-type-argument]\ndef returns_unparameterized_tuple() -> tuple:\n # no error, since the return type is implicitly `tuple[Unknown, ...]`\n # (which is what the `missing-type-argument` error is complaining about on the line above!)\n return returns_any()\n\n\ndef returns_list_of_any() -> list[Any]:\n # no error, since the return type is explicitly `list[Any]`\n return returns_any()\n```\n\nThis rule works especially well when combined with ty's\n`missing-type-argument` rule, and the Ruff rules [`ANN201`][ann201],\n[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all\nthese rules at once effectively makes it much less likely that a `return` statement can lead to\nunsoundness \"leaking\" out of a function unless that function has been *explicitly* annotated with\na dynamic type in some way (`-> Any` or `-> tuple[Any]`, for example).\n\nThis rule is analogous to mypy's [`no-any-return`][no-any-return] error code, which is enabled by\nmypy’s [`--strict`][mypy-strict] mode and can also be enabled on its own using mypy’s\n[`--warn-return-any`][warn-return-any] option.\n\n## Examples\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef returns_int() -> int:\n # error: \"Unsound return statement: `Any` is not a subtype of `int`\"\n return returns_any()\n```\n\nNarrow the type to a subtype of `int` to fix the diagnostic:\n\n```py\nfrom typing import Any\nfrom typing_extensions import reveal_type\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef returns_int() -> int:\n my_int = returns_any()\n assert isinstance(my_int, int)\n reveal_type(my_int) # revealed: Any & int\n return my_int # no error: `Any & int` is a subtype of `int`\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for advanced users wanting additional soundness\nchecks from their type checker, not for users who have just started to use type checkers on their\nPython code.\n\n## See also\n\n- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound `return` statements\n\n[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/\n[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/\n[ann204]: https://docs.astral.sh/ruff/rules/missing-return-type-special-method/\n[ann205]: https://docs.astral.sh/ruff/rules/missing-return-type-static-method/\n[ann206]: https://docs.astral.sh/ruff/rules/missing-return-type-class-method/\n[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable\n[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type\n[mypy-strict]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict\n[no-any-return]: https://mypy.readthedocs.io/en/stable/error_code_list2.html#code-no-any-return\n[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype\n[warn-return-any]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-warn-return-any", + "description": "## What it does\n\nDetects `return` statements that unsoundly return a type that is not a [subtype] of the function's\nannotated return type.\n\nThis lint is a stricter version of `invalid-return-type`.\n\n## Why is this bad?\n\nBy default, type checkers consider a `return` statement valid if the inferred type of the object\nbeing returned is [assignable] to the annotated return type of the function it's in. However, this\nmakes it easy for incorrect types to percolate through your code unexpectedly due to a single\nexpression being inferred as `Any`. This can easily lead to runtime errors that are not caught by\nthe type checker:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\ndef returns_int() -> int:\n # error: \"Unsound return statement: `Any` is not a subtype of `int`\"\n return returns_any()\n\n\n# fails at runtime, even though the type checker infers both operands as being of type `int`!\nreturns_int() + 42\n```\n\nThis rule allows you to use [\"fully static\"][fully-static] return types as \"typed boundaries\" for\nyour code. With this rule enabled, ty would emit an error on the `return returns_any()` statement in\n`returns_int`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not a\nsubtype of `int`. This helps prevent the unsoundness from spreading far from its original source (in\nthis case, the return type of the `returns_any` function).\n\nNote that this rule is only applied to functions annotated as returning [fully static][fully-static]\ntypes. It will not trigger if `Any` or `Unknown` appear anywhere in your return type, either\nimplicitly or explicitly:\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\n# error: [missing-type-argument]\ndef returns_unparameterized_tuple() -> tuple:\n # no error, since the return type is implicitly `tuple[Unknown, ...]`\n # (which is what the `missing-type-argument` error is complaining about on the line above!)\n return returns_any()\n\n\ndef returns_list_of_any() -> list[Any]:\n # no error, since the return type is explicitly `list[Any]`\n return returns_any()\n```\n\nThis rule works especially well when combined with ty's `missing-type-argument` and\n`unsound-assignment` rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202],\n[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once\neffectively makes it much less likely that a `return` statement can lead to unsoundness \"leaking\"\nout of a function unless that function has been *explicitly* annotated with a dynamic type in some\nway (`-> Any` or `-> tuple[Any]`, for example).\n\nThis rule is analogous to mypy's [`no-any-return`][no-any-return] error code, which is enabled by\nmypy’s [`--strict`][mypy-strict] mode and can also be enabled on its own using mypy’s\n[`--warn-return-any`][warn-return-any] option.\n\n## Examples\n\n```py\nfrom typing import Any\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef returns_int() -> int:\n # error: \"Unsound return statement: `Any` is not a subtype of `int`\"\n return returns_any()\n```\n\nNarrow the type to a subtype of `int` to fix the diagnostic:\n\n```py\nfrom typing import Any\nfrom typing_extensions import reveal_type\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef returns_int() -> int:\n my_int = returns_any()\n assert isinstance(my_int, int)\n reveal_type(my_int) # revealed: Any & int\n return my_int # no error: `Any & int` is a subtype of `int`\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for advanced users wanting additional soundness\nchecks from their type checker, not for users who have just started to use type checkers on their\nPython code.\n\n## See also\n\n- `unsound-yield` is a similar rule that triggers on unsound `yield` expressions rather than unsound\n `return` statements\n- `unsound-assignment` is a similar rule that triggers on unsound assignments\n\n[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/\n[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/\n[ann204]: https://docs.astral.sh/ruff/rules/missing-return-type-special-method/\n[ann205]: https://docs.astral.sh/ruff/rules/missing-return-type-static-method/\n[ann206]: https://docs.astral.sh/ruff/rules/missing-return-type-class-method/\n[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable\n[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type\n[mypy-strict]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict\n[no-any-return]: https://mypy.readthedocs.io/en/stable/error_code_list2.html#code-no-any-return\n[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype\n[warn-return-any]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-warn-return-any", "default": "ignore", "oneOf": [ { @@ -2552,7 +2612,7 @@ }, "unsound-yield": { "title": "detects yield expressions that unsoundly yield a type that is not a subtype of the generator's annotated yield type", - "description": "## What it does\n\nDetects `yield` and `yield from` expressions that unsoundly yield a type that is not a [subtype] of\nthe generator function's annotated yield type.\n\nThis lint is a stricter version of `invalid-yield`.\n\n## Why is this bad?\n\nBy default, type checkers consider a yielded value valid if its inferred type is [assignable] to the\ngenerator's annotated yield type. However, this\nmakes it easy for incorrect types to percolate through your code unexpectedly due to a single\nexpression being inferred as `Any`. This can easily lead to runtime errors that are not caught by\nthe type checker:\n\n```py\nfrom typing import Any, Generator\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\ndef integers() -> Generator[int]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n\n\n# Fails at runtime, even though the type checker infers `integers` as yielding only `int`s!\nsum(integers())\n```\n\nThis rule treats [fully static][fully-static] yield types as \"typed boundaries\" for your code. With this rule enabled, ty would emit an error on the `yield returns_any()` statement\nin `integers`, since the `returns_any()` call is inferred as having type `Any`, and `Any` is not\na subtype of `int`. This helps prevent the unsoundness from spreading far from its original source\n(in this case, the return type of the `returns_any` function).\n\nNote that this rule is only applied to functions annotated as yielding\n[fully static][fully-static] types. It will not trigger if `Any` or `Unknown` appear anywhere in\nyour function's yield type, either implicitly or explicitly. It will still trigger on functions that have non-fully-static send and/or return types, however:\n\n```py\nfrom typing import Any, Generator\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\ndef dynamic_yield_type() -> Generator[Any]:\n yield returns_any()\n\n\ndef static_yield_type() -> Generator[int, Any, Any]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n```\n\nThis rule works especially well when combined with ty's\n`missing-type-argument` rule, and the Ruff rules [`ANN201`][ann201],\n[`ANN202`][ann202], [`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all\nthese rules at once effectively makes it much less likely that a `yield` expression can lead to\nunsoundness \"leaking\" out of a function unless that function has been *explicitly* annotated with\na dynamic type in some way (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example).\n\n## Examples\n\n```py\nfrom typing import Any, Iterator\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\ndef any_iterator() -> Iterator[Any]:\n yield \"foo\"\n\n\ndef integers() -> Iterator[int]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n # error: \"Unsound `yield from`: `Any` is not a subtype of `int`\"\n yield from any_iterator()\n```\n\nNarrow the value before yielding it to fix the diagnostics:\n\n```py\nfrom typing import Any, Iterator\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef any_iterator() -> Iterator[Any]:\n yield \"foo\"\n\n\ndef integers() -> Iterator[int]:\n value = returns_any()\n assert isinstance(value, int)\n yield value\n\n for value in any_iterator():\n assert isinstance(value, int)\n yield value\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for users who want stricter soundness checks at\ngenerator boundaries.\n\n## See also\n\n- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather than unsound `yield` expressions\n\n[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/\n[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/\n[ann204]: https://docs.astral.sh/ruff/rules/missing-return-type-special-method/\n[ann205]: https://docs.astral.sh/ruff/rules/missing-return-type-static-method/\n[ann206]: https://docs.astral.sh/ruff/rules/missing-return-type-class-method/\n[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable\n[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type\n[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype", + "description": "## What it does\n\nDetects `yield` and `yield from` expressions that unsoundly yield a type that is not a [subtype] of\nthe generator function's annotated yield type.\n\nThis lint is a stricter version of `invalid-yield`.\n\n## Why is this bad?\n\nBy default, type checkers consider a yielded value valid if its inferred type is [assignable] to the\ngenerator's annotated yield type. However, this makes it easy for incorrect types to percolate\nthrough your code unexpectedly due to a single expression being inferred as `Any`. This can easily\nlead to runtime errors that are not caught by the type checker:\n\n```py\nfrom typing import Any, Generator\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\ndef integers() -> Generator[int]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n\n\n# Fails at runtime, even though the type checker infers `integers` as yielding only `int`s!\nsum(integers())\n```\n\nThis rule treats [\"fully static\"][fully-static] yield types as \"typed boundaries\" for your code.\nWith this rule enabled, ty would emit an error on the `yield returns_any()` statement in `integers`,\nsince the `returns_any()` call is inferred as having type `Any`, and `Any` is not a subtype of\n`int`. This helps prevent the unsoundness from spreading far from its original source (in this case,\nthe return type of the `returns_any` function).\n\nNote that this rule is only applied to functions annotated as yielding [fully static][fully-static]\ntypes. It will not trigger if `Any` or `Unknown` appear anywhere in your function's yield type,\neither implicitly or explicitly. It will still trigger on functions that have non-fully-static send\nand/or return types, however:\n\n```py\nfrom typing import Any, Generator\n\n\ndef returns_any() -> Any:\n return \"not an integer\"\n\n\ndef dynamic_yield_type() -> Generator[Any]:\n # no error\n yield returns_any()\n\n\ndef static_yield_type() -> Generator[int, Any, Any]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n```\n\nThis rule works especially well when combined with ty's `missing-type-argument` and\n`unsound-assignment` rules, as well as the Ruff rules [`ANN201`][ann201], [`ANN202`][ann202],\n[`ANN204`][ann204], [`ANN205`][ann205], and [`ANN206`][ann206]. Enabling all these rules at once\neffectively makes it much less likely that a `yield` expression can lead to unsoundness \"leaking\"\nout of a function unless that function has been *explicitly* annotated with a dynamic type in some\nway (`-> Generator[Any]` or `-> Generator[tuple[Any]]`, for example).\n\n## Examples\n\n```py\nfrom typing import Any, Iterator\n\n\ndef returns_any() -> Any:\n return \"foo\"\n\n\ndef any_iterator() -> Iterator[Any]:\n yield \"foo\"\n\n\ndef integers() -> Iterator[int]:\n # error: \"Unsound `yield`: `Any` is not a subtype of `int`\"\n yield returns_any()\n # error: \"Unsound `yield from`: `Any` is not a subtype of `int`\"\n yield from any_iterator()\n```\n\nNarrow the value before yielding it to fix the diagnostics:\n\n```py\nfrom typing import Any, Iterator\n\n\ndef returns_any() -> Any:\n return 42\n\n\ndef any_iterator() -> Iterator[Any]:\n yield \"foo\"\n\n\ndef integers() -> Iterator[int]:\n value = returns_any()\n assert isinstance(value, int)\n yield value\n\n for value in any_iterator():\n assert isinstance(value, int)\n yield value\n```\n\n## Default level\n\nThis rule is disabled by default. It is intended for users who want stricter soundness checks at\ngenerator boundaries.\n\n## See also\n\n- `unsound-return-statement` is a similar rule that triggers on unsound `return` statements rather\n than unsound `yield` expressions\n- `unsound-assignment` is a similar rule that triggers on unsound assignments\n\n[ann201]: https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/\n[ann202]: https://docs.astral.sh/ruff/rules/missing-return-type-private-function/\n[ann204]: https://docs.astral.sh/ruff/rules/missing-return-type-special-method/\n[ann205]: https://docs.astral.sh/ruff/rules/missing-return-type-static-method/\n[ann206]: https://docs.astral.sh/ruff/rules/missing-return-type-class-method/\n[assignable]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable\n[fully-static]: https://typing.python.org/en/latest/spec/glossary.html#term-fully-static-type\n[subtype]: https://typing.python.org/en/latest/spec/glossary.html#term-subtype", "default": "ignore", "oneOf": [ { @@ -2572,7 +2632,7 @@ }, "unsupported-base": { "title": "detects class bases that are unsupported as ty could not feasibly calculate the class's MRO", - "description": "## What it does\n\nChecks for class definitions that have bases which are unsupported by ty.\n\n## Why is this bad?\n\nIf a class has a base that is an instance of a complex type such as a union type,\nty will not be able to resolve the [method resolution order] (MRO) for the class.\nThis will lead to an inferior understanding of your codebase and unpredictable\ntype-checking behavior.\n\n## Examples\n\n```python\nimport datetime\n\n\nclass A: ...\n\n\nclass B: ...\n\n\nif datetime.date.today().weekday() != 6:\n C = A\nelse:\n C = B\n\n\nclass D(C): ... # error: [unsupported-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", + "description": "## What it does\n\nChecks for class definitions that have bases which are unsupported by ty.\n\n## Why is this bad?\n\nIf a class has a base that is an instance of a complex type such as a union type, ty will not be\nable to resolve the [method resolution order] (MRO) for the class. This will lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Examples\n\n```python\nimport datetime\n\n\nclass A: ...\n\n\nclass B: ...\n\n\nif datetime.date.today().weekday() != 6:\n C = A\nelse:\n C = B\n\n\nclass D(C): ... # error: [unsupported-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", "default": "warn", "oneOf": [ { @@ -2582,7 +2642,7 @@ }, "unsupported-bool-conversion": { "title": "detects boolean conversion where the object incorrectly implements `__bool__`", - "description": "## What it does\n\nChecks for bool conversions where the object doesn't correctly implement `__bool__`.\n\n## Why is this bad?\n\nIf an exception is raised when you attempt to evaluate the truthiness of an object,\nusing the object in a boolean context will fail at runtime.\n\n## Examples\n\n```python\nclass NotBoolable:\n __bool__ = None\n\n def __lt__(self, other: object) -> \"NotBoolable\":\n return self\n\n\nb1 = NotBoolable()\nb2 = NotBoolable()\n\n# exception raised here\nif b1: # error\n pass\n\n# exception raised here\nb1 and b2 # error\n# exception raised here\nnot b1 # error\n\n# A chained comparison converts the result of `b1 < b2` to bool.\n# exception raised here\nb1 < b2 < b1 # error\n```", + "description": "## What it does\n\nChecks for bool conversions where the object doesn't correctly implement `__bool__`.\n\n## Why is this bad?\n\nIf an exception is raised when you attempt to evaluate the truthiness of an object, using the object\nin a boolean context will fail at runtime.\n\n## Examples\n\n```python\nclass NotBoolable:\n __bool__ = None\n\n def __lt__(self, other: object) -> \"NotBoolable\":\n return self\n\n\nb1 = NotBoolable()\nb2 = NotBoolable()\n\n# exception raised here\nif b1: # error\n pass\n\n# exception raised here\nb1 and b2 # error\n# exception raised here\nnot b1 # error\n\n# A chained comparison converts the result of `b1 < b2` to bool.\n# exception raised here\nb1 < b2 < b1 # error\n```", "default": "error", "oneOf": [ { @@ -2592,7 +2652,7 @@ }, "unsupported-dynamic-base": { "title": "detects dynamic class bases that are unsupported as ty could not feasibly calculate the class's MRO", - "description": "## What it does\n\nChecks for dynamic class definitions (using `type()`) that have bases\nwhich are unsupported by ty.\n\nThis is equivalent to `unsupported-base` but applies to classes created\nvia `type()` rather than `class` statements.\n\n## Why is this bad?\n\nIf a dynamically created class has a base that is an unsupported type\nsuch as `type[T]`, ty will not be able to resolve the\n[method resolution order] (MRO) for the class. This may lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Default level\n\nThis rule is disabled by default because it will not cause a runtime error,\nand may be noisy on codebases that use `type()` in highly dynamic ways.\n\n## Examples\n\n```python\nclass Base: ...\n\n\ndef factory(base: type[Base]) -> type:\n # `base` has type `type[Base]`, not `type[Base]` itself\n return type(\"Dynamic\", (base,), {}) # error: [unsupported-dynamic-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", + "description": "## What it does\n\nChecks for dynamic class definitions (using `type()`) that have bases which are unsupported by ty.\n\nThis is equivalent to `unsupported-base` but applies to classes created via `type()` rather than\n`class` statements.\n\n## Why is this bad?\n\nIf a dynamically created class has a base that is an unsupported type such as `type[T]`, ty will not\nbe able to resolve the [method resolution order] (MRO) for the class. This may lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Default level\n\nThis rule is disabled by default because it will not cause a runtime error, and may be noisy on\ncodebases that use `type()` in highly dynamic ways.\n\n## Examples\n\n```python\nclass Base: ...\n\n\ndef factory(base: type[Base]) -> type:\n # `base` has type `type[Base]`, not `type[Base]` itself\n return type(\"Dynamic\", (base,), {}) # error: [unsupported-dynamic-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", "default": "warn", "oneOf": [ { @@ -2602,7 +2662,7 @@ }, "unsupported-operator": { "title": "detects binary, unary, or comparison expressions where the operands don't support the operator", - "description": "## What it does\n\nChecks for binary expressions, comparisons, and unary expressions where\nthe operands don't support the operator.\n\n## Why is this bad?\n\nAttempting to use an unsupported operator will raise a `TypeError` at\nruntime.\n\n## Examples\n\n```python\nclass A: ...\n\n\n# TypeError: unsupported operand type(s) for +: 'A' and 'A'\nA() + A() # error\n```", + "description": "## What it does\n\nChecks for binary expressions, comparisons, and unary expressions where the operands don't support\nthe operator.\n\n## Why is this bad?\n\nAttempting to use an unsupported operator will raise a `TypeError` at runtime.\n\n## Examples\n\n```python\nclass A: ...\n\n\n# TypeError: unsupported operand type(s) for +: 'A' and 'A'\nA() + A() # error\n```", "default": "error", "oneOf": [ { @@ -2612,7 +2672,7 @@ }, "unusable-resource-key": { "title": "detects a key in a static resource that python cannot name", - "description": "## What it does\n\nChecks for keys in an imported static resource that python cannot name.\n\n## Why is this bad?\n\nA static resource is read through attributes, so a key that is not a valid\npython identifier — `build-backend`, `class`, `2` — has no attribute to be read\nthrough, and is left out of the value the import binds. The document still holds\nit; nothing in the program can reach it.\n\nNames with two leading underscores are left out for the same reason: python\nmangles `__x` inside a class body, so the attribute the reader would write is\nnot the one that would exist.\n\n## Examples\n\n`data/project.json`:\n\n```json\n{ \"build-backend\": \"hatchling.build\", \"root\": \".\" }\n```\n\n`main.by`:\n\n```by\n# error: [unusable-resource-key]\nimport \"data/project.json\" as project\n\nreveal_type(project.root) # revealed: \".\"\n```", + "description": "## What it does\n\nChecks for keys in an imported static resource that python cannot name.\n\n## Why is this bad?\n\nA static resource is read through attributes, so a key that is not a valid python identifier —\n`build-backend`, `class`, `2` — has no attribute to be read through, and is left out of the value\nthe import binds. The document still holds it; nothing in the program can reach it.\n\nNames with two leading underscores are left out for the same reason: python mangles `__x` inside a\nclass body, so the attribute the reader would write is not the one that would exist.\n\n## Examples\n\n`data/project.json`:\n\n```json\n{ \"build-backend\": \"hatchling.build\", \"root\": \".\" }\n```\n\n`main.by`:\n\n```by\n# error: [unusable-resource-key]\nimport \"data/project.json\" as project\n\nreveal_type(project.root) # revealed: \".\"\n```", "default": "warn", "oneOf": [ { @@ -2622,7 +2682,7 @@ }, "unused-awaitable": { "title": "detects awaitable objects that are used as expression statements without being awaited", - "description": "## What it does\n\nChecks for awaitable objects (such as coroutines) used as expression\nstatements without being awaited.\n\n## Why is this bad?\n\nCalling an `async def` function returns a coroutine object. If the\ncoroutine is never awaited, the body of the async function will never\nexecute, which is almost always a bug. Python emits a\n`RuntimeWarning: coroutine was never awaited` at runtime in this case.\n\n## Examples\n\n```python\nasync def fetch_data() -> str:\n return \"data\"\n\n\nasync def main() -> None:\n # Warning: coroutine is not awaited\n fetch_data() # error\n await fetch_data() # OK\n```", + "description": "## What it does\n\nChecks for awaitable objects (such as coroutines) used as expression statements without being\nawaited.\n\n## Why is this bad?\n\nCalling an `async def` function returns a coroutine object. If the coroutine is never awaited, the\nbody of the async function will never execute, which is almost always a bug. Python emits a\n`RuntimeWarning: coroutine was never awaited` at runtime in this case.\n\n## Examples\n\n```python\nasync def fetch_data() -> str:\n return \"data\"\n\n\nasync def main() -> None:\n # Warning: coroutine is not awaited\n fetch_data() # error\n await fetch_data() # OK\n```", "default": "warn", "oneOf": [ { @@ -2632,7 +2692,7 @@ }, "unused-ignore-comment": { "title": "detects unused `ty: ignore` comments", - "description": "## What it does\n\nChecks for `ty: ignore` directives that are no longer applicable.\n\n## Why is this bad?\n\nA `ty: ignore` directive that no longer matches any diagnostic violations is likely\nincluded by mistake, and should be removed to avoid confusion.\n\n## Examples\n\n```py\n# error\na = 20 / 2 # ty: ignore[division-by-zero]\n```\n\nUse instead:\n\n```py\na = 20 / 2\n```\n\n## Options\n\nSet [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)\nto `false` to prevent this rule from reporting unused `type: ignore` comments.", + "description": "## What it does\n\nChecks for `ty: ignore` directives that are no longer applicable.\n\n## Why is this bad?\n\nA `ty: ignore` directive that no longer matches any diagnostic violations is likely included by\nmistake, and should be removed to avoid confusion.\n\n## Examples\n\n```py\n# error\na = 20 / 2 # ty: ignore[division-by-zero]\n```\n\nUse instead:\n\n```py\na = 20 / 2\n```\n\n## Options\n\nSet\n[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)\nto `false` to prevent this rule from reporting unused `type: ignore` comments.", "default": "warn", "oneOf": [ { @@ -2652,7 +2712,7 @@ }, "unused-type-ignore-comment": { "title": "detects unused `type: ignore` comments", - "description": "## What it does\n\nChecks for `type: ignore` directives that are no longer applicable.\n\n## Why is this bad?\n\nA `type: ignore` directive that no longer matches any diagnostic violations is likely\nincluded by mistake, and should be removed to avoid confusion.\n\n## Examples\n\n```py\n# error\na = 20 / 2 # type: ignore\n```\n\nUse instead:\n\n```py\na = 20 / 2\n```\n\n## Options\n\nThis rule is skipped if [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)\nto `false`.", + "description": "## What it does\n\nChecks for `type: ignore` directives that are no longer applicable.\n\n## Why is this bad?\n\nA `type: ignore` directive that no longer matches any diagnostic violations is likely included by\nmistake, and should be removed to avoid confusion.\n\n## Examples\n\n```py\n# error\na = 20 / 2 # type: ignore\n```\n\nUse instead:\n\n```py\na = 20 / 2\n```\n\n## Options\n\nThis rule is skipped if\n[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)\nto `false`.", "default": "warn", "oneOf": [ { @@ -2662,7 +2722,7 @@ }, "useless-overload-body": { "title": "detects `@overload`-decorated functions with non-stub bodies", - "description": "## What it does\n\nChecks for various `@overload`-decorated functions that have non-stub bodies.\n\n## Why is this bad?\n\nFunctions decorated with `@overload` are ignored at runtime; they are overridden\nby the implementation function that follows the series of overloads. While it is\nnot illegal to provide a body for an `@overload`-decorated function, it may indicate\na misunderstanding of how the `@overload` decorator works.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo(x: int) -> int:\n # will never be executed\n return x + 1 # error\n\n\n@overload\ndef foo(x: str) -> str:\n # will never be executed\n return \"Oh no, got a string\" # error\n\n\ndef foo(x: int | str) -> int | str:\n raise Exception(\"unexpected type encountered\")\n```\n\nUse instead:\n\n```py\nfrom typing import assert_never, overload\n\n\n@overload\ndef foo(x: int) -> int: ...\n\n\n@overload\ndef foo(x: str) -> str: ...\n\n\ndef foo(x: int | str) -> int | str:\n if isinstance(x, int):\n return x + 1\n elif isinstance(x, str):\n return \"Oh no, got a string\"\n else:\n assert_never(x)\n```\n\n## References\n\n- [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload)", + "description": "## What it does\n\nChecks for various `@overload`-decorated functions that have non-stub bodies.\n\n## Why is this bad?\n\nFunctions decorated with `@overload` are ignored at runtime; they are overridden by the\nimplementation function that follows the series of overloads. While it is not illegal to provide a\nbody for an `@overload`-decorated function, it may indicate a misunderstanding of how the\n`@overload` decorator works.\n\n## Example\n\n```toml\n[environment]\npython-version = \"3.11\"\n```\n\n```py\nfrom typing import overload\n\n\n@overload\ndef foo(x: int) -> int:\n # will never be executed\n return x + 1 # error\n\n\n@overload\ndef foo(x: str) -> str:\n # will never be executed\n return \"Oh no, got a string\" # error\n\n\ndef foo(x: int | str) -> int | str:\n raise Exception(\"unexpected type encountered\")\n```\n\nUse instead:\n\n```py\nfrom typing import assert_never, overload\n\n\n@overload\ndef foo(x: int) -> int: ...\n\n\n@overload\ndef foo(x: str) -> str: ...\n\n\ndef foo(x: int | str) -> int | str:\n if isinstance(x, int):\n return x + 1\n elif isinstance(x, str):\n return \"Oh no, got a string\"\n else:\n assert_never(x)\n```\n\n## References\n\n- [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload)", "default": "warn", "oneOf": [ { @@ -2672,7 +2732,7 @@ }, "zero-stepsize-in-slice": { "title": "detects a slice step size of zero", - "description": "## What it does\n\nChecks for a step size of zero in slices when the operation is known to fail.\n\n## Why is this bad?\n\nPython's built-in sequence types raise a `ValueError` when sliced with a step size of zero.\n\n## Known problems\n\nThis check is not exhaustive. It reports zero-step slices for certain built-in sequence\ntypes where the operation is known to fail. A custom `__getitem__` implementation can\naccept or reject such a slice, so ty cannot detect every runtime failure.\n\n## Examples\n\n```python\nvalues = list(range(10))\n# ValueError: slice step cannot be zero\nvalues[1:10:0] # error\n\ntuple_values = (1, 2, 3)\n# ValueError: slice step cannot be zero\ntuple_values[1:10:0] # error\n```", + "description": "## What it does\n\nChecks for a step size of zero in slices when the operation is known to fail.\n\n## Why is this bad?\n\nPython's built-in sequence types raise a `ValueError` when sliced with a step size of zero.\n\n## Known problems\n\nThis check is not exhaustive. It reports zero-step slices for certain built-in sequence types where\nthe operation is known to fail. A custom `__getitem__` implementation can accept or reject such a\nslice, so ty cannot detect every runtime failure.\n\n## Examples\n\n```python\nvalues = list(range(10))\n# ValueError: slice step cannot be zero\nvalues[1:10:0] # error\n\ntuple_values = (1, 2, 3)\n# ValueError: slice step cannot be zero\ntuple_values[1:10:0] # error\n```", "default": "error", "oneOf": [ { diff --git a/uv.lock b/uv.lock index 0051e47069..bf15461af1 100644 --- a/uv.lock +++ b/uv.lock @@ -2,11 +2,45 @@ version = 1 revision = 3 requires-python = ">=3.7" resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version >= '3.8' and python_full_version < '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", + "python_full_version >= '3.10' and python_full_version < '3.12'", + "python_full_version >= '3.8' and python_full_version < '3.10'", "python_full_version < '3.8'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +astral-dev-toolchain-hyperfine = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-insta = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-shear = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-codspeed = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-nextest = "2026-08-27T00:00:00Z" +astral-dev-toolchain-cargo-fuzz = "2026-09-01T00:00:00Z" + +[manifest] +build-constraints = [ + { name = "flit-core", specifier = "==4.0.2" }, + { name = "hatch-fancy-pypi-readme", specifier = "==25.1.0" }, + { name = "hatch-vcs", specifier = "==0.5.0" }, + { name = "hatchling", specifier = "==1.32.0" }, + { name = "packaging", specifier = "==26.3" }, + { name = "pathspec", specifier = "==1.1.1" }, + { name = "pluggy", specifier = "==1.6.0" }, + { name = "setuptools", specifier = "==84.0.0" }, + { name = "setuptools-scm", specifier = "==10.2.1" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = "==2.4.1" }, + { name = "tomlkit", specifier = "==0.15.1" }, + { name = "trove-classifiers", specifier = "==2026.6.1.19" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "==4.16.0" }, + { name = "uv-build", specifier = "==0.12.3" }, + { name = "vcs-versioning", specifier = "==2.2.4" }, + { name = "wheel", specifier = "==0.47.0" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -50,32 +84,201 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" }, ] +[[package]] +name = "astral-dev-toolchain-cargo-codspeed" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/0e/a7d82d3b80efb376676ad9e08b8d5b397fd2993fe436a012d116480edfe0/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd304fbc053073e6e8e1b68283d36731788908ce49599e7680582cd87864f11b", size = 962023, upload-time = "2026-08-25T22:20:29.839Z" }, + { url = "https://files.pythonhosted.org/packages/18/1c/a972d08a06e2646a3642a398a607608510a1ca8e3fae8c609806d783dbd7/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e04158926ee6c6ff6cdaaa6df46c05d7855e00b5b0c1550a747772cdcdc60c2", size = 941050, upload-time = "2026-08-25T22:20:31.607Z" }, + { url = "https://files.pythonhosted.org/packages/d8/86/e803608dbe08ba83a2361dc21fa4f97c7c6b5e84a478637ddd9269aacb2d/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e7196c632bb0103ebbf60a75ab076677ad03038fe92a88cbe27b1eb56e63d2b", size = 871539, upload-time = "2026-08-25T22:20:33.397Z" }, + { url = "https://files.pythonhosted.org/packages/23/11/fb3246e194a1699f2a4493ba9501da8d65d3f49074330cce4244e3ded8c7/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e1005b0a7e4eb750172f2d3d7c6ccb3e78e9b55fa4bcd62f54350181dca6a1", size = 922701, upload-time = "2026-08-25T22:20:34.902Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d9/bfb2f45e36c1525f0c9810405f605f39d7c8b4dcf250629b141b89eb89e6/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-win_amd64.whl", hash = "sha256:ac995814067402b6604c6d8d9ab76700288f015494c53c4e75da09d5bc2ee5f7", size = 824114, upload-time = "2026-08-25T22:20:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/ce/34/cc139216c71c1bcb67c1623420843646a36748b7ab13d3ae502b5fdbb84d/astral_dev_toolchain_cargo_codspeed-5.0.1-py3-none-win_arm64.whl", hash = "sha256:4bb1b696762454f924a85e0949ab8da1e788124683b644d3b997115d61455700", size = 783231, upload-time = "2026-08-25T22:20:37.889Z" }, +] + +[[package]] +name = "astral-dev-toolchain-cargo-fuzz" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/1a/64ee8361d5f96236a8b9e91e42745529c1585d2049f221416085b0d0840c/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ae22e17a288f4b815e509095cc15f3bacd42b3342f50106f85bbdff87f651cd4", size = 918577, upload-time = "2026-08-31T16:45:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/2f/84/673b1cd197cbff3de46eb8950bd4504f31c11059bf00f64e6ccd5d529cd9/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:802ad95a41270a09fb6285889bedd132590a21512547b89f3a1c82a6918797e1", size = 894396, upload-time = "2026-08-31T16:45:36.308Z" }, + { url = "https://files.pythonhosted.org/packages/f8/40/cf98874e2b0614da6fe3cb4eeff9f4520ce149036398798bb9c8d7ed7c79/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25b65340d3f18c4b18b8f7e54855b64c53e6526864b192d32a189b59ded41198", size = 835209, upload-time = "2026-08-31T16:45:37.689Z" }, + { url = "https://files.pythonhosted.org/packages/37/b6/1ab2d25e68fe1d743adbbd6ec1e20af7411ec8b18dcd7fbce16a61ef3f31/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:612108f626b6ee0e5d63b1a0e257c89c13abffbd8fff539ccbffb7205b33093f", size = 882948, upload-time = "2026-08-31T16:45:38.972Z" }, + { url = "https://files.pythonhosted.org/packages/78/d2/a2a182b716dd695d138945446b03bfcd0378928f246f514afceac676d5a4/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-win_amd64.whl", hash = "sha256:8c617c6162bea2c77fcce21c07279bd2f2a93e1b5243dabdc5e85c61f66a45fe", size = 763904, upload-time = "2026-08-31T16:45:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/19/9d/e823bd76ae1ae5dd69c4a77c45fbc01c9745a49075a19bb3aa97b75e2652/astral_dev_toolchain_cargo_fuzz-0.13.2-py3-none-win_arm64.whl", hash = "sha256:efda924de1a449026d6b6baa7a52c92638f539965951db86a71b3e79804afae5", size = 732469, upload-time = "2026-08-31T16:45:42.026Z" }, +] + +[[package]] +name = "astral-dev-toolchain-cargo-insta" +version = "1.48.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/80/ab2d6d0093b478b06707ae9511df0ccc933c704102f691a320d5f4b9c5d6/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1f56e9f74edaebca156f448fc1d4d7923c1812a5cac40ae13ccdcd0136a83c8", size = 2264457, upload-time = "2026-08-25T20:54:10.226Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3c/f18198513bd8c2b44ee0cbf85e27952c43150ca02d32e84369125bc31e2c/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d92eedb58b69b8c55f576967e089cd80dc6e7482cf925c694f5373f16c647055", size = 2148611, upload-time = "2026-08-25T20:54:11.705Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/fac1dc449054ca3ed39609ff71e919696cc96ee3d35988111159c8a86465/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94745ceb0da64d14fc71dabe34febb84a27f321bf1f9bae5064fa8364a1fee2f", size = 1987021, upload-time = "2026-08-25T20:54:13.304Z" }, + { url = "https://files.pythonhosted.org/packages/1f/11/d931a49ec49080630a800be4e4a58a8498041b64728a9ee1f6da853f1c38/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317f5a40d4a074e5dbc6b77203b390120f1a72c226edbf52319d339287ae096f", size = 2145780, upload-time = "2026-08-25T20:54:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1a/e37987fb11c7382e417a7de6f2b0a7f6451a94f0b2c45eb89561dc56c14d/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-win_amd64.whl", hash = "sha256:314b2d7e01e281f065d27ef9e311072cfe9dfd6e23eebb1cc36f1cd76ca12b0f", size = 2155460, upload-time = "2026-08-25T20:54:16.34Z" }, + { url = "https://files.pythonhosted.org/packages/85/df/2de935d19813fef6ce4bb9112912600d8ffe57ad15f849057f0b096d272c/astral_dev_toolchain_cargo_insta-1.48.0-py3-none-win_arm64.whl", hash = "sha256:1fae5bb442cead7d5f125e6f5019d3f4c817d129690439c824851449fc636ff2", size = 1996852, upload-time = "2026-08-25T20:54:17.731Z" }, +] + +[[package]] +name = "astral-dev-toolchain-cargo-nextest" +version = "0.9.143" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/2e/6091f2ecc6936499edfd82628bc7f6f512d47ad292ed37b2f782f12f7ab9/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2a5b4b8629bc71595e6c57bce2463fc0ad98efd2676ae4c2c0aebebe72142b90", size = 7314765, upload-time = "2026-08-26T15:28:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/edfe750a7e9732a232b4e4ba30b847f067de957966f181d0665f6d46c224/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d152cb4ce165452a30cc63d92046847210102f9231300d86b4aaf1cfe4f839ad", size = 6995862, upload-time = "2026-08-26T15:28:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/67141069cee76d531d3887e27210c0b16a568585afdd11baf8965cd6b857/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6501029b9f9597a720a496997da1aad8e31069a352a80415441eff15d15637d", size = 9880036, upload-time = "2026-08-26T15:28:22.33Z" }, + { url = "https://files.pythonhosted.org/packages/74/1d/8636da96addb470098a120b55d275d05e8d57925a760515a710d9c2a8245/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a58a0dafb1d6a54b4e80b837a7cbcf2999b2dfd133eb6261b3d61c714b295c81", size = 10068151, upload-time = "2026-08-26T15:28:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/9c/68/7b73566785938b8d09d28ce170c77ed6fbe1d3f5790c481324417b5bf4d7/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-win_amd64.whl", hash = "sha256:824aec957bf38828671f22e70354b21e95c4a4a1aa8fbaef04f98ae3bee5fe09", size = 6689195, upload-time = "2026-08-26T15:28:26.859Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/f2b26fa3111bfac4a5ab59957a431c95a97dba900f894fcf9cbd01299928/astral_dev_toolchain_cargo_nextest-0.9.143-py3-none-win_arm64.whl", hash = "sha256:efdda891201e148c0da299a4f8f26c8a7c7ca510c12d96112ae4d51b1b203ef6", size = 6245567, upload-time = "2026-08-26T15:28:28.754Z" }, +] + +[[package]] +name = "astral-dev-toolchain-cargo-shear" +version = "1.13.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/7d/6f44e9e1d0e29b9cd2903eeee8e9cc7612241cd195c7628841f3f9034409/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2142f359d3aa21a1e996ca6f89683f5b990fa568ec24e60036761c225300c35d", size = 1480556, upload-time = "2026-08-25T21:13:01.403Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/cbf7407c2c9391a986ebd8a05fe2c4aa72c0228723650062928e68f376e9/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:359e17bbfb1b388633dc87277dd6ed13f99d987923c40a574377e44b7449b3d7", size = 1342579, upload-time = "2026-08-25T21:13:02.992Z" }, + { url = "https://files.pythonhosted.org/packages/63/7d/1b7e465833026658ffad9f7a504b5f2d6e2b0977000f06faa14192acce3d/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e9e5ca2854a3b12e511e4f30f47c49fa36aafae79c65c2badf14c4caf47998", size = 1466122, upload-time = "2026-08-25T21:13:04.441Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/bc9865a14140f5a990f9bbb7ff5b36e2e0f9151d819570fedb516c26fdd6/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb239c6e9f4abe609b02dbf2933113378bdce845bcaa5ee0000bc7d04750a5ad", size = 1572315, upload-time = "2026-08-25T21:13:05.851Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/db879c8c71810df57b290af915fd4e4cd588d4b8161267d593d45100d7a1/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-win_amd64.whl", hash = "sha256:b682608009b22130ff3b99ab782ce7ee3555711989c2a84ed627be1a016643d6", size = 1476915, upload-time = "2026-08-25T21:13:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7e/62dc9d09eec545a555030fbe8fdb117006ad5684ebe8da4dad8c81a6a251/astral_dev_toolchain_cargo_shear-1.13.4-py3-none-win_arm64.whl", hash = "sha256:91874654b0dcb2b0333a615f81e3cde3f9c10d3148b180254764fce606930af0", size = 1370034, upload-time = "2026-08-25T21:13:08.764Z" }, +] + +[[package]] +name = "astral-dev-toolchain-hyperfine" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/82/0109da6323b34d69d48df702438c0f3df4c22513e95154c26b6c7d266a13/astral_dev_toolchain_hyperfine-1.20.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:31b43291b77e9c583c3abebcc65995978cdad75195eb8182ce7eefc96f7dc53d", size = 609130, upload-time = "2026-08-26T16:09:29.018Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c2/bade068349c37f8c40ee1da1ab2853cb3a10ba51898e19bc6bfbbfa9933b/astral_dev_toolchain_hyperfine-1.20.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:828d4b33fa0d2ddb9c74fd8559e1359e74007ed71148e06ddafe0cbee0219c1b", size = 582152, upload-time = "2026-08-26T16:09:30.369Z" }, + { url = "https://files.pythonhosted.org/packages/a0/54/0bb44acfe37a4a278023592fc703a1f7a7402f76635ba4dcdb14a6075e86/astral_dev_toolchain_hyperfine-1.20.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aafd0ca4620b89685bdec078657567833b462a8dceba930f3ac395d81a8dde17", size = 622444, upload-time = "2026-08-26T16:09:31.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1e/1ff7bb5b38fd7059577ec6fab9405a09b126b8775f73cad144bc43048499/astral_dev_toolchain_hyperfine-1.20.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a90dcfd64798ce5f82b62b4a05ea923fe76407abcc5b49ffaef30b902d1e3e70", size = 656263, upload-time = "2026-08-26T16:09:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/49/9e/036f671adf8dd42ca949f6358afe31f93490294f167d51d592362f2789c4/astral_dev_toolchain_hyperfine-1.20.0-py3-none-win_amd64.whl", hash = "sha256:c6e1667d641a06554a0d984b0b372dfecec833db1c4c6e84b56c6d56194ac45d", size = 588524, upload-time = "2026-08-26T16:09:34.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c2/eea4375484705603b7f83f97d2ea7f7f30b8e6ffa55fb4ec81dcd8a3b0a6/astral_dev_toolchain_hyperfine-1.20.0-py3-none-win_arm64.whl", hash = "sha256:2995536b8fa75c3137b8b74a728fe3ae4dc93dcbda226989355bfc3921ac6d55", size = 555078, upload-time = "2026-08-26T16:09:36.041Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + [[package]] name = "basedpython" version = "0.0.1a9" source = { editable = "." } [package.dev-dependencies] +basedpython-docs = [ + { name = "basedpython-pygments", marker = "python_full_version >= '3.12'" }, + { name = "zensical", marker = "python_full_version >= '3.12'" }, +] dev = [ + { name = "astral-dev-toolchain-cargo-codspeed", marker = "python_full_version >= '3.12'" }, + { name = "astral-dev-toolchain-cargo-fuzz", marker = "python_full_version >= '3.12'" }, + { name = "astral-dev-toolchain-cargo-insta", marker = "python_full_version >= '3.12'" }, + { name = "astral-dev-toolchain-cargo-nextest", marker = "python_full_version >= '3.12'" }, + { name = "astral-dev-toolchain-cargo-shear", marker = "python_full_version >= '3.12'" }, + { name = "astral-dev-toolchain-hyperfine", marker = "python_full_version >= '3.12'" }, { name = "prek", marker = "python_full_version >= '3.12'" }, ] docs = [ - { name = "basedpython-pygments", marker = "python_full_version >= '3.12'" }, - { name = "zensical", marker = "python_full_version >= '3.12'" }, + { name = "mkdocs", marker = "python_full_version >= '3.12'" }, + { name = "mkdocs-github-admonitions-plugin", marker = "python_full_version >= '3.12'" }, + { name = "mkdocs-llmstxt", marker = "python_full_version >= '3.12'" }, + { name = "mkdocs-material", marker = "python_full_version >= '3.12'" }, + { name = "mkdocs-redirects", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, ] release = [ { name = "rooster", marker = "python_full_version >= '3.12'" }, ] +ruff-lsp-test = [ + { name = "lsprotocol", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygls", marker = "python_full_version >= '3.12'" }, + { name = "pytest", marker = "python_full_version >= '3.12'" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.12'" }, + { name = "python-lsp-jsonrpc", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +ty-ecosystem = [ + { name = "ecosystem-analyzer", marker = "python_full_version >= '3.13'" }, +] +typeshed-docstrings = [ + { name = "docstring-adder", marker = "python_full_version >= '3.10'" }, +] +typeshed-formatting = [ + { name = "black", marker = "python_full_version >= '3.10'" }, +] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "prek", marker = "python_full_version >= '3.12'", specifier = "==0.4.12" }] -docs = [ +basedpython-docs = [ { name = "basedpython-pygments", marker = "python_full_version >= '3.12'", directory = "python/basedpython-pygments" }, { name = "zensical", marker = "python_full_version >= '3.12'" }, ] +dev = [ + { name = "astral-dev-toolchain-cargo-codspeed", marker = "python_full_version >= '3.12'", specifier = ">=5.0.1" }, + { name = "astral-dev-toolchain-cargo-fuzz", marker = "python_full_version >= '3.12'", specifier = ">=0.13.2" }, + { name = "astral-dev-toolchain-cargo-insta", marker = "python_full_version >= '3.12'", specifier = ">=1.48.0" }, + { name = "astral-dev-toolchain-cargo-nextest", marker = "python_full_version >= '3.12'", specifier = ">=0.9.143" }, + { name = "astral-dev-toolchain-cargo-shear", marker = "python_full_version >= '3.12'", specifier = ">=1.13.4" }, + { name = "astral-dev-toolchain-hyperfine", marker = "python_full_version >= '3.12'", specifier = ">=1.20.0" }, + { name = "prek", marker = "python_full_version >= '3.12'", specifier = "==0.4.12" }, +] +docs = [ + { name = "mkdocs", marker = "python_full_version >= '3.12'", specifier = ">=1.6.1" }, + { name = "mkdocs-github-admonitions-plugin", marker = "python_full_version >= '3.12'", specifier = ">=0.1.1" }, + { name = "mkdocs-llmstxt", marker = "python_full_version >= '3.12'", specifier = ">=0.2.0" }, + { name = "mkdocs-material", marker = "python_full_version >= '3.12'", specifier = ">=9.7.7" }, + { name = "mkdocs-redirects", marker = "python_full_version >= '3.12'", specifier = ">=1.2.3" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'", specifier = ">=6.0.3" }, +] release = [{ name = "rooster", marker = "python_full_version >= '3.12'", specifier = "==0.1.1" }] +ruff-lsp-test = [ + { name = "lsprotocol", marker = "python_full_version >= '3.12'", specifier = ">=2023.0.0" }, + { name = "packaging", marker = "python_full_version >= '3.12'", specifier = ">=23.1" }, + { name = "pygls", marker = "python_full_version >= '3.12'", specifier = ">=1.1.0,<2" }, + { name = "pytest", marker = "python_full_version >= '3.12'", specifier = ">=9.0.3,<10" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.12'", specifier = ">=0.21.2" }, + { name = "python-lsp-jsonrpc", marker = "python_full_version >= '3.12'", specifier = ">=1.0.0" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'", specifier = ">=4.7.1" }, +] +ty-ecosystem = [{ name = "ecosystem-analyzer", marker = "python_full_version >= '3.13'", git = "https://github.com/astral-sh/ecosystem-analyzer" }] +typeshed-docstrings = [{ name = "docstring-adder", marker = "python_full_version >= '3.10'", git = "https://github.com/astral-sh/docstring-adder.git" }] +typeshed-formatting = [{ name = "black", marker = "python_full_version >= '3.10'", git = "https://github.com/psf/black.git" }] [[package]] name = "basedpython-pygments" @@ -88,6 +291,46 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pygments", specifier = ">=2.19" }] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "black" +version = "26.5.2.dev21+ge34bb1bef" +source = { git = "https://github.com/psf/black.git#e34bb1bef70e83410c2816dfd07485ec026959aa" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] + +[[package]] +name = "cattrs" +version = "23.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/d4/27f9fd840e74d51b6d6a024d39ff495b56ffde71d28eb82758b7b85d0617/cattrs-23.1.2.tar.gz", hash = "sha256:db1c821b8c537382b2c7c66678c3790091ca0275ac486c76f3c8f3920e83c657", size = 39998, upload-time = "2023-06-02T00:20:33.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/ba/05df14efaa0624fac6b1510e87f5ce446208d2f6ce50270a89b6268aebfe/cattrs-23.1.2-py3-none-any.whl", hash = "sha256:b2bb14311ac17bed0d58785e5a60f022e5431aca3932e3fc5cc8ed8639de50a4", size = 50845, upload-time = "2023-06-02T00:20:31.635Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -191,6 +434,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", size = 306122, upload-time = "2026-07-07T14:34:36.607Z" }, + { url = "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", size = 206284, upload-time = "2026-07-07T14:34:38.166Z" }, + { url = "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", size = 226837, upload-time = "2026-07-07T14:34:39.77Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", size = 222199, upload-time = "2026-07-07T14:34:41.391Z" }, + { url = "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", size = 214344, upload-time = "2026-07-07T14:34:42.986Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", size = 199988, upload-time = "2026-07-07T14:34:44.685Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", size = 211908, upload-time = "2026-07-07T14:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", size = 209320, upload-time = "2026-07-07T14:34:47.753Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", size = 200980, upload-time = "2026-07-07T14:34:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", size = 216545, upload-time = "2026-07-07T14:34:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", size = 146256, upload-time = "2026-07-07T14:34:52.509Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", size = 156413, upload-time = "2026-07-07T14:34:54.117Z" }, + { url = "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", size = 147887, upload-time = "2026-07-07T14:34:55.51Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -214,11 +557,47 @@ wheels = [ [[package]] name = "deepmerge" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/6c/9f4577a36d5f463a3a3f8322bd65d33e1a1a6b6ba1d692a5ebc3cba19015/deepmerge-3.0.tar.gz", hash = "sha256:14ed69f063de64b7743985c732ccff5d6c34ff4560946e7fbfd99086b853b9ce", size = 22279, upload-time = "2026-08-17T05:50:53.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/d7/7f19bedd30b90b72865aeec3a29127bed6dee6c9ef0324bb5b4d424bb0e3/deepmerge-3.0-py3-none-any.whl", hash = "sha256:c8541c3e186dc88d19a5513ad3a0b2d0b22beaa780969fc0c13b995a64265365", size = 14855, upload-time = "2026-08-17T05:50:52.218Z" }, +] + +[[package]] +name = "docstring-adder" +version = "0.1.0" +source = { git = "https://github.com/astral-sh/docstring-adder.git#86fa6dd7681b9bb712084e1f6117410bc39928bf" } +dependencies = [ + { name = "rich-argparse" }, + { name = "termcolor" }, + { name = "tomli" }, + { name = "typeshed-client" }, + { name = "typing-extensions" }, + { name = "uv" }, +] + +[[package]] +name = "ecosystem-analyzer" +version = "0.1.0" +source = { git = "https://github.com/astral-sh/ecosystem-analyzer#a31ef389f3cc304b59623df7fe4b068419a323a4" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "mypy-primer" }, + { name = "typing-extensions" }, +] + +[[package]] +name = "ghp-import" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] @@ -283,6 +662,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -295,6 +692,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "lsprotocol" +version = "2023.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/fe/f7671a4fb28606ff1663bba60aff6af21b1e43a977c74c33db13cb83680f/lsprotocol-2023.0.0.tar.gz", hash = "sha256:c9d92e12a3f4ed9317d3068226592860aab5357d93cf5b2451dc244eee8f35f2", size = 69399, upload-time = "2023-11-16T18:32:44.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/5b/f18eb1823a4cee9bed70cdcc25eed5a75845367c42e63a79010a7c34f8a7/lsprotocol-2023.0.0-py3-none-any.whl", hash = "sha256:e85fc87ee26c816adca9eb497bb3db1a7c79c477a11563626e712eaccf926a05", size = 70789, upload-time = "2023-11-16T18:32:46.479Z" }, +] + [[package]] name = "markdown" version = "3.10.3" @@ -304,10 +714,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10' and python_full_version < '3.12'", +] dependencies = [ { name = "mdurl" }, ] @@ -316,6 +745,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markdownify" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" }, +] + [[package]] name = "marko" version = "2.2.3" @@ -421,6 +863,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -430,6 +884,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-github-admonitions-plugin" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/62/37f2080af26ec1d569bb21eb2a4d54f5ea54d36a92938abbbc37c6ab671b/mkdocs_github_admonitions_plugin-0.1.1.tar.gz", hash = "sha256:7f81520a0681b9955952d73b21ce99b923921830b6b6d1ace9b3fb95cd1fb61f", size = 5151, upload-time = "2025-06-02T09:45:12.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/a3/8cfd5d9a651612b0d2a15176341b040d43d27b063e17684cb353ec7ce789/mkdocs_github_admonitions_plugin-0.1.1-py3-none-any.whl", hash = "sha256:824dc821764171943c1043c88218d4af0329693870ba9be657f890d484a0aa85", size = 5493, upload-time = "2025-06-02T09:45:11.068Z" }, +] + +[[package]] +name = "mkdocs-llmstxt" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/25/263ea9c16d1f95f30d9eb1b76e63eb50a88a1ec9fad1829281bab7a371eb/mkdocs_llmstxt-0.2.0.tar.gz", hash = "sha256:104f10b8101167d6baf7761942b4743869be3d8f8a8d909f4e9e0b63307f709e", size = 41376, upload-time = "2025-04-08T13:18:48.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/29/0a33f7d8499a01dd7fd0d90fb163b2d8eefa9c90ac0ecbc1a7770e50614e/mkdocs_llmstxt-0.2.0-py3-none-any.whl", hash = "sha256:907de892e0c8be74002e8b4d553820c2b5bbcf03cc303b95c8bca48fb49c1a29", size = 23244, upload-time = "2025-04-08T13:18:47.516Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-redirects" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "properdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/25/49725f78ca5d3026b09973f7a2b3a8b179cc2e8c15e43d5a13bc79f6b274/mkdocs_redirects-1.2.3.tar.gz", hash = "sha256:5e980330999299729a2d6a125347d1af78023d68a23681a4de3053ce7dfe2e51", size = 7712, upload-time = "2026-03-28T13:57:41.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/871b1cddc01d2ba1637b858eeeabc2e3013dc8df591306b5567b98ef0870/mkdocs_redirects-1.2.3-py3-none-any.whl", hash = "sha256:ec7312fff462d03ec16395d0c001006a418f8d0c21cdf2b47ff11cf839dc3ce0", size = 6245, upload-time = "2026-03-28T13:57:40.466Z" }, +] + [[package]] name = "msgpack" version = "1.2.1" @@ -503,6 +1074,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "mypy-primer" +version = "0.1.0" +source = { git = "https://github.com/hauntsaninja/mypy_primer?rev=3058720299b812c393ad926bcca96eede20fa683#3058720299b812c393ad926bcca96eede20fa683" } + [[package]] name = "packaging" version = "26.2" @@ -512,6 +1097,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prek" version = "0.4.12" @@ -536,6 +1157,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/1d/e2c0fc222904ef73df1739b11a83edc29e38bc4bc61259f2ca6d2f15abb0/prek-0.4.12-py3-none-win_arm64.whl", hash = "sha256:45e34a24fba4a4e4568682477158591698efc2375b8d1d418ae424691c4bd01b", size = 5632819, upload-time = "2026-08-03T11:28:31.743Z" }, ] +[[package]] +name = "properdocs" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/29/f27a4e1eddf72ed3db6e47818fbafe6debbf09fd7051f9c1a007239b46ef/properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e", size = 276141, upload-time = "2026-03-20T20:07:48.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/4d/fc923f5c85318ee8cc903566dc4e0ebe41b2dfc1d2ecf5546db232397ed6/properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd", size = 225406, upload-time = "2026-03-20T20:07:46.875Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -756,6 +1400,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/47/9a5d33c552d52e554efe7a39b5022b5455709d75093089ac867750379df0/pygit2-1.19.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5af3422728f61958b6a0faaf7de754ecae5bed689adc31d1b727da3c697a752b", size = 1224962, upload-time = "2026-06-13T08:06:03.897Z" }, ] +[[package]] +name = "pygls" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/94/534c11ba5475df09542e48d751a66e0448d52bbbb92cbef5541deef7760d/pygls-1.2.1.tar.gz", hash = "sha256:04f9b9c115b622dcc346fb390289066565343d60245a424eca77cb429b911ed8", size = 45274, upload-time = "2023-11-30T14:13:28.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/31/3799444d3f072ffca1a35eb02a48f964384cc13f001125e87d9f0748687b/pygls-1.2.1-py3-none-any.whl", hash = "sha256:7dcfcf12b6f15beb606afa46de2ed348b65a279c340ef2242a9a35c22eeafe94", size = 55983, upload-time = "2023-11-30T14:13:23.233Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -778,6 +1434,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-lsp-jsonrpc" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ujson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/45/1c2a272950679af529f7360af6ee567ef266f282e451be926329e8d50d84/python-lsp-jsonrpc-1.0.0.tar.gz", hash = "sha256:7bec170733db628d3506ea3a5288ff76aa33c70215ed223abdb0d95e957660bd", size = 10011, upload-time = "2021-04-14T21:19:20.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/ee/754bfd5f6bfe7162c10d3ecb0aeef6f882f91d3231596c83f761a75efd0b/python_lsp_jsonrpc-1.0.0-py3-none-any.whl", hash = "sha256:079b143be64b0a378bdb21dff5e28a8c1393fe7e8a654ef068322d754e545fc7", size = 8507, upload-time = "2021-04-14T21:19:18.698Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/968c22e06ab6570788964e2d5a702db9a3816e20ffde380b2b1385541d64/pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc", size = 154847, upload-time = "2026-01-30T01:03:32.268Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/2061bb4b300e6921f7968724b185237627a8a3dc4f311e34079dfadf9b65/pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009", size = 238610, upload-time = "2026-01-30T01:03:33.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/64/abf6e43523ea9b4aea69bfe22788a518806741107238674e5c0fb6fc8dc1/pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1", size = 252493, upload-time = "2026-01-30T01:03:35.715Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fb/bcb6784c87d1de182afb284f37b07bc172eebec91ddc20e83aec767e4963/pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6", size = 255651, upload-time = "2026-01-30T01:03:36.961Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0c/0c33752be2209498661903f6f240779aea5c9adbd85d22336ce3f7718e81/pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037", size = 104346, upload-time = "2026-01-30T01:03:38.069Z" }, + { url = "https://files.pythonhosted.org/packages/51/2a/f125667ce48105bf1f4e50e03cfa7b24b8c4f47684d7f1cf4dcb6f6b1c15/pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3", size = 161464, upload-time = "2026-01-30T01:03:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/40/df/065a30790a7ca6bb48ad9018dd44668ed9135610ebf56a2a4cb8e513fd5c/pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1", size = 246159, upload-time = "2026-01-30T01:03:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1c/fd09976a7e04960dabc07ab0e0072c7813d566ec67d5490a4c600683c158/pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db", size = 259120, upload-time = "2026-01-30T01:03:41.233Z" }, + { url = "https://files.pythonhosted.org/packages/52/49/59fdc6fc5a390ae9f308eadeb97dfc70fc2d804ffc49dd39fc97604622ec/pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1", size = 262196, upload-time = "2026-01-30T01:03:42.696Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/d6734dccf0080e3dc00a55b0827ab5af30c886f8bc127bbc04bc3445daec/pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a", size = 103510, upload-time = "2026-01-30T01:03:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -858,12 +1615,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } @@ -871,6 +1656,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rich-argparse" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/35/1cceccc5fcb50fa2ed53e2aa278cd032f3902682a73e763fb1ac3be8e6fa/rich_argparse-1.8.0-py3-none-any.whl", hash = "sha256:d2a3ce7854654e2253c578763ab0a32f05016f23a55fadba7b9a91b6c0e92142", size = 25616, upload-time = "2026-05-01T15:18:42.395Z" }, +] + [[package]] name = "rooster" version = "0.1.1" @@ -899,6 +1696,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -980,13 +1804,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] +[[package]] +name = "typeshed-client" +version = "2.8.2" +source = { git = "https://github.com/JelleZijlstra/typeshed_client?rev=9d4b258bbad66421ee2d9ea8cf02768ffb782a1a#9d4b258bbad66421ee2d9ea8cf02768ffb782a1a" } +dependencies = [ + { name = "importlib-resources" }, + { name = "typing-extensions" }, +] + [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -1001,9 +1834,181 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "ujson" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/7a/c8bb37c8f6f3623d60c33d15d18cd6d6655d0f9c3eb31a9969f76361b199/ujson-5.13.0.tar.gz", hash = "sha256:d62e3d7625384c08082abad81a077af587fdef2761bb14c3822f4234b8d07d75", size = 7166784, upload-time = "2026-06-14T22:36:50.209Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/a4/ff15f528d386f47b1972859f25587da017d5be84c75076b380fedde318fe/ujson-5.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:770643b4752266c5a466149848b78c3874940926a4ecef304f518b2b6cdb432f", size = 56497, upload-time = "2026-06-14T22:34:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d2/92e83af35cd65d3b7c47fb0e24927aa2b478897af8b0dd0ee19abcd50c03/ujson-5.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8e8c1203cb1a27720debc334f840a9170da741503522f86999710cb4738fbe3", size = 54301, upload-time = "2026-06-14T22:35:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/96/9c/3eb2d21778dbd0a9f1adae7ce73e4f6c981efa7fab534eb28afde31fd25e/ujson-5.13.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:65c1813fdd742fe3c249d9c417fa490e5b54e8a91bf343a88486ec50d175c444", size = 59968, upload-time = "2026-06-14T22:35:01.612Z" }, + { url = "https://files.pythonhosted.org/packages/9a/52/f5b72747349c66552396166a13424a717df70e76a18ac5bbedc84c244407/ujson-5.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5aa9bf16f0131812720dd4ae70bc1a0cf68f79e93c07c66100d328e75944b567", size = 53434, upload-time = "2026-06-14T22:35:02.644Z" }, + { url = "https://files.pythonhosted.org/packages/4b/73/103c2c6df7bfe57e01fb6006551188b4dc9f873cb5f72146bd5b177b75f9/ujson-5.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82c17b904c03c2b9629486ec91a8fa46a15f10d03504284f54ed7257a917d9f1", size = 54976, upload-time = "2026-06-14T22:35:03.759Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ba/44fcf17cc93be7c6647fef2e556d703791659280e6409623dea854e6db7d/ujson-5.13.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0b5f6983b2469db00e540b68fa8297b7a0ccd0d5173c60cc3e84f336b09395f", size = 58239, upload-time = "2026-06-14T22:35:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/0d/60/10f6c92b549cff72e555a900ee7128b992ebf510bad0eebd946e72b68ecb/ujson-5.13.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b877fe7926107d25d54b655c5b8dd94b294b22c157233163ad29fdb54ac10cf4", size = 57876, upload-time = "2026-06-14T22:35:05.998Z" }, + { url = "https://files.pythonhosted.org/packages/71/5e/b8b2806996c75d4c34cf92ec04c5fff58a645a32632f27836886a718cb87/ujson-5.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f3cae1c811e787b9500e2830af8632dcb32a78dea5baf15aac51d681c59a59dd", size = 1037734, upload-time = "2026-06-14T22:35:07.081Z" }, + { url = "https://files.pythonhosted.org/packages/d4/02/baaf3ad0bb4b2c3bec3d9ff8a59768d39d20201146742e8581f8a0f87e77/ujson-5.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:820b78a6a183ab6591b2ea888020032ef0fe328d852af9a5c8d8084ababb2218", size = 1197048, upload-time = "2026-06-14T22:35:08.319Z" }, + { url = "https://files.pythonhosted.org/packages/8e/aa/a945aeba9d463e335169b3c0ee60adc147e22e80bb636fe0f185bed1247f/ujson-5.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1209c985d1f4c2ae085ced7325509650cdb3533bb7294558dd1f26d48377942", size = 1090118, upload-time = "2026-06-14T22:35:10.014Z" }, + { url = "https://files.pythonhosted.org/packages/cc/68/57e084026ab657ab0443017fd0aa51009300bbd2eb09f5113af113703e8b/ujson-5.13.0-cp310-cp310-win32.whl", hash = "sha256:326553ae6c063c8246974906a6c137a0780fe46d143abebc52cd2cadda0f1814", size = 40008, upload-time = "2026-06-14T22:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/56/8a/da9bb80765a5ea582ff8c1bd2f3c3909c05733314c55652d1c0bd34514f8/ujson-5.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:0354d6b50b0d153ed7c629845b18a953d0c727b7e768fd94a94e0602abfa1f29", size = 40947, upload-time = "2026-06-14T22:35:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b1/eaf4308ff7e7dc5aba78d7479102a69fa5fbe65e1b02f16c62b553a0b506/ujson-5.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:7b4a05f61a96553995da6a4d502e07bc8aa5d07a90031df006239c6b00ce1c83", size = 38975, upload-time = "2026-06-14T22:35:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/58/dc/2fcf821896803248122835c800e74f4582de9d6092efb37152acd7f79bdc/ujson-5.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b7badefa73f96bad9e295ea22bd06967b851c8aad68c74196437e3584f25de5", size = 56496, upload-time = "2026-06-14T22:35:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/83db69f96dc12509c9510084c0389c4aff4dcf427f9b613d1a23abd446ce/ujson-5.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:09effd42924a80df20a63b31a1ede905e66b0ce24aafe7a4cbedb05c783f8bb3", size = 54300, upload-time = "2026-06-14T22:35:15.522Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/6d87206988172015781b9ff842c0a7eca897c026b2e7a95e11d86d46ddf5/ujson-5.13.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:89d0bfc986d02b4ce76b00e0f560bc8d30dfe8c05a1bfd8529e085eb6c1a77d9", size = 59976, upload-time = "2026-06-14T22:35:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/d28ca5e6c3d8c467a4c6296404067f4eee9d67dfeeebeb3c5fb0bd6cb958/ujson-5.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cdd9618e07b3b142a02f0ab8227fd52453688b8e8e60ac0511f13a25fa8009db", size = 53476, upload-time = "2026-06-14T22:35:17.664Z" }, + { url = "https://files.pythonhosted.org/packages/a9/99/d6bb0e18188954326b38499ae61b04ce8ead6425b8844384e537a491267b/ujson-5.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0806683e8171ec06817e6af22f14ba0cd2f16def8a2ffb22a28a10615249355d", size = 54962, upload-time = "2026-06-14T22:35:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5f/8f1ce659a59ef9510fa47b95773442049a383c533a645b2da8beeeec90f1/ujson-5.13.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1cf46c79498c81f088cad4165b1669a78bba7bfbeb778c7cc1aff316e062b0d", size = 58265, upload-time = "2026-06-14T22:35:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8e/9d11af9d1d19ea239b0d289cf62a611cb4f2da9ec0d46b826767f4ab1768/ujson-5.13.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2282039eb26f08ed1a1381360395f8a310f39a45ef7314cb0f258a7d2917ea3", size = 57874, upload-time = "2026-06-14T22:35:20.896Z" }, + { url = "https://files.pythonhosted.org/packages/25/e2/7891c4a1c954307ab6a575686897a4963aad36c284742216f52dc40cba4a/ujson-5.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c75eb7fac0fe92925b959cf2fa18f88d9fb76b10781fd2a6ccb895d5fb89171", size = 1037746, upload-time = "2026-06-14T22:35:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/8a4ce87d3eaaee398f4238f74f128c6eb34269f041659995b2c09710ae7c/ujson-5.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:12078e81def2790140583abefc1b979f3c77be15d53c524bf0e232f669822052", size = 1197022, upload-time = "2026-06-14T22:35:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fb/a1d0a6a83a13adee3a13cad308dcd3251ac53c4bd781f737242ad2f10d19/ujson-5.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12192a37c6c0e476554b62647acdf6139a47b6f13d8bad8dd2316763c4cee12", size = 1090116, upload-time = "2026-06-14T22:35:24.421Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/4aed5dce0161d6b8c95c5da760477602a05e2a540a762424395acad9aec0/ujson-5.13.0-cp311-cp311-win32.whl", hash = "sha256:ae53b3f046529c193d533ca8111492330b204d6007611cdfa20e8b764c7c1389", size = 39974, upload-time = "2026-06-14T22:35:25.779Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c5/f9f3cf19f6ac29e07782eafed562fe0a7cb451b44bd1664c58fe8974235b/ujson-5.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:2275bbaaea3eddd2e8ec0863e28a420f5f520b14a760fc3f1e49fd07a974448c", size = 40941, upload-time = "2026-06-14T22:35:26.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/ec/46058bbbbe45e054cdb9f1dc0bf416fbbd5fec06bf3b4987c2437e496350/ujson-5.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:32a59e7151fe2fec8fdf9a565ee66fcf87918d48827e21cdab5e15ff9f274b78", size = 38974, upload-time = "2026-06-14T22:35:27.871Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/b66deca15da1f7faf6952d8eddf55978482bcbfd294ed2afe2c526ea325f/ujson-5.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf81570ac056cb058f9117b52ca5dd800bfe9381d0076d0bb30a08a54591d654", size = 56743, upload-time = "2026-06-14T22:35:28.863Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/b03bcc9eaf4621ac9008dec90918d8fb4839d611666cb99eb255696c67fe/ujson-5.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7edf16359c52ed53406e216565d83e6b98c23c3cb9a0a01673f2493f8fb15edf", size = 54390, upload-time = "2026-06-14T22:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/f98c6c1a4ed9d92d39d5d2d133f2b6fce5da11ea50c341117aedde8011c4/ujson-5.13.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:24539618fb3243cfdf27dab9a850acab80798a01501e9586b61fb9ecd016a891", size = 60047, upload-time = "2026-06-14T22:35:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/f68e14cf476d149945211142f4c20782c1f232c489e8edcc4f4b58ce4997/ujson-5.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fdde6341d213b29f413b5fa9fad1392d5408074c75f0900ed949e97e546fa5df", size = 53437, upload-time = "2026-06-14T22:35:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1a/5718237cf4141e5be46ff371387e90b01f27774cb6f0f79ff4803a2430ca/ujson-5.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:229faf041ef249ee3fd57bac1cedb123d2718ab63f6ccd50eca95ea902eb0dca", size = 55057, upload-time = "2026-06-14T22:35:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6f/7f55c1e9e0be87beebaed553fa186ad5f6d5d639cbaa9d49f78f2f91c3a9/ujson-5.13.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d02f31c2f59cc6a1c2c3633b377701fc2d8e876cc01950735d7a01132ccc233", size = 58186, upload-time = "2026-06-14T22:35:34.055Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c4/9a34ade542426f56a0bc042f774073d1c247ae7575363c27587788cb2b2f/ujson-5.13.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea7204e9fa7538bfbb1396e1ee8c2bbcd3818b3633ef5bb14d4fdea52994d14d", size = 57935, upload-time = "2026-06-14T22:35:35.05Z" }, + { url = "https://files.pythonhosted.org/packages/36/06/407633f0709e168107f56368bd5a0fa8fe07acd7f1d3000710bc0bb07470/ujson-5.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c5a2478a3a1fa4421f7e035b87194eea0cf44c7971a3f32ad1b42a0dfd63c03", size = 1037685, upload-time = "2026-06-14T22:35:36.022Z" }, + { url = "https://files.pythonhosted.org/packages/c3/df/eb5bd92dc1b23254fea5b2022007baff5491a7478bfcf7e9260d3a10f1ac/ujson-5.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b535e0970c96957e999cfe5ec89361f0e8d0bb987fb5d5144f6f495cb3ed9e19", size = 1197141, upload-time = "2026-06-14T22:35:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1c/65f2ce1a0411ec9a87339db01f0d5d554a49c4248ec68ab52a1b7e14e9c4/ujson-5.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d0ad1207694988498fca7e0bb28eba7564fa33261d2f9fdf66a3aaab376b803", size = 1090225, upload-time = "2026-06-14T22:35:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/73/53/310aabff0704f9c7ef0d3f431ce8b8e3147c3cca25334a205615c511f65e/ujson-5.13.0-cp312-cp312-win32.whl", hash = "sha256:d6bc9fa43a49e403c68c7eb164eef0feee9dd29474a7c6e0d3b6267025371990", size = 40075, upload-time = "2026-06-14T22:35:40.44Z" }, + { url = "https://files.pythonhosted.org/packages/b5/23/d3536d8945d1bb00248d998c8dcbe678a884681ad181072daecfafe4eea6/ujson-5.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6692d49ff970aaa5008f4a6fe06974bc91fd957bf13173f765e46d8ba44906ea", size = 41097, upload-time = "2026-06-14T22:35:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/72/a1/4b147c06ee5bb14bec6e26786358c8510c4d75e28b88146a6ac7620f1f71/ujson-5.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:5737ffe0740a788b0e6255f0ffb281db49305fd6e6a587be44c73d9e92b554c4", size = 38875, upload-time = "2026-06-14T22:35:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/fe8a467d8ff5821e076b96f398d3acfe3cd568d900e6ccb41b215592b152/ujson-5.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46998fc8d11aec34a20e2010905e7059732a3d192d9a3c3fe4f9ffd146c87ec8", size = 56746, upload-time = "2026-06-14T22:35:43.398Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/c7ab82d6471dfa7e4fd68ae6ff2c6a50d077c05d6ecdea0cec8af635b2c4/ujson-5.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ee03ce288ba25b05cf0de87203165642277a25caa4f00a437e13152e5214e310", size = 54388, upload-time = "2026-06-14T22:35:44.586Z" }, + { url = "https://files.pythonhosted.org/packages/10/e6/4e9e998d991ff88bbc93b21daa63bba2baa61c6f952dbcec937cf7304ebe/ujson-5.13.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:cdf33b588a81b05d0b585c66f83050c49cb670623424d10e4d1ad37ba2f7eed9", size = 60051, upload-time = "2026-06-14T22:35:45.567Z" }, + { url = "https://files.pythonhosted.org/packages/9c/11/876dff43f05417a01c6119f0fa10e01f1226631c5927ef08f56876b2bb67/ujson-5.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cabd73c114ce93c21d7db2e2d8e16217fd8a5b2b3ec754629eebef5c262d47f", size = 53438, upload-time = "2026-06-14T22:35:46.623Z" }, + { url = "https://files.pythonhosted.org/packages/09/02/f9dbf6c3e46d700eb1d9ed637567221a06eeb1ec289633be992ef54d7a34/ujson-5.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ffc61fc756a64f4d169a78cc638d769e3c324f45fc51997626abf4e5e5dd6460", size = 55060, upload-time = "2026-06-14T22:35:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3d/7e49a70265a1e5ed1b5e8edd5f54d57ae41e2134faeae9b16f6f5a0eae20/ujson-5.13.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c00323c13a35822c9a67a26c0b2a0787510bf1ef490922b58009b362d1a3e21", size = 58189, upload-time = "2026-06-14T22:35:48.617Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/b64278f67e19052f09810576c7e50b3da8d4f5218b226046324d4d5c24b4/ujson-5.13.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496e662a6b46d5f936d77fb68259cece19213bb2301ddd520dbd75ac7c90c5f4", size = 57941, upload-time = "2026-06-14T22:35:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8c/51513357a5c75bf3e5bae46accfdb3e6e6f5caeb72ca8b253ec45ba853fb/ujson-5.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fd41b86444df14f8b4b7afaaa9f27bacfbf8c18380872317aeab6cd125dcede", size = 1037688, upload-time = "2026-06-14T22:35:50.699Z" }, + { url = "https://files.pythonhosted.org/packages/54/5a/dc6afe071d6b977390d2dc41e15800a2716f317988dd03187cffe7b4d624/ujson-5.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bf3c2c4ea55d4187903fcdc689a9bf5b0fc72d8c0eaff39db18c1f337c8832c1", size = 1197141, upload-time = "2026-06-14T22:35:52.052Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/b473d101412c68527cb502a8728f96ab307aa7bfa75d6ea2037e2c7f74e8/ujson-5.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6eca7751d61045a9b1e7f9a8c97ac24b164f085b60bef1c4668654bb2338011", size = 1090235, upload-time = "2026-06-14T22:35:53.589Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0a/8e583cce90f9f91ca1bedb3e628b6f5642aed91feb29b197431268d4c4be/ujson-5.13.0-cp313-cp313-win32.whl", hash = "sha256:b63d3820f978bc8e98cc3f1fe26a33b0d2ea237733a23fe5e9cb5d51f466bd97", size = 40069, upload-time = "2026-06-14T22:35:55.019Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b5/1fe203bc294e98fdd65606883692ad8dc0aaac73838b89c99c3513404424/ujson-5.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:17a59d5cf23ef98f7c9314524976b4b288374d83200add01d953024fb06404f9", size = 41098, upload-time = "2026-06-14T22:35:55.966Z" }, + { url = "https://files.pythonhosted.org/packages/50/5e/aceadce24fdb7cbc67f02286b1d4e91a575aaef5afb876c9908d6e6e5769/ujson-5.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:b49516fbe803ff30d6caa9ccc3799ec7f968992747ce3099eae4758928577b53", size = 38877, upload-time = "2026-06-14T22:35:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/b5139d696f5328f3cab70b9ec046f15e3f49497a4de6280974640602f539/ujson-5.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc9dfd41fed397ab03bb9d9fe1cbd83301211c772a17536033ce7d68877ac82b", size = 56897, upload-time = "2026-06-14T22:35:57.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/477183aeddfdf0f88ae039ffee0ed866cfb993da0c0c9aa915807554aef8/ujson-5.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca7ef2fa6c408a7c0f558e4d33d93b32ddc35ed6d3cfc505747931a64b7465d5", size = 54451, upload-time = "2026-06-14T22:35:58.932Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/55e5f23e156b4c8bca095d828b4cd3180c0b42aa3501ef88836d79606fea/ujson-5.13.0-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a554b2e5bee85030369514cef8b0b913cebe1a4c2c0c13541966d50bcba22b1a", size = 60053, upload-time = "2026-06-14T22:35:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/08c6cf5548bd6f4bb557c9fa7e8edf87324bb04c17249d1966028d61dde0/ujson-5.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea939ff629ab03ae970d03eca6d1febd8ed55ba38ca44aec64ce997537cd3fa0", size = 53481, upload-time = "2026-06-14T22:36:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b3/0ac9a03551467784067f505df1bb875c639ba32f1da79ce467ab15911ada/ujson-5.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b98bf2faa5e37ecfe752226ea08290031e375a0c43d425a0b955fb3e702a2a71", size = 55058, upload-time = "2026-06-14T22:36:02.297Z" }, + { url = "https://files.pythonhosted.org/packages/ba/be/ec91029aec067174473d022fa0f6c3c1431a173f888d7599739f05c668eb/ujson-5.13.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a4b92344b16e414aeb609e57f62c466500e53c94f1698f5b149dc0b7223ec3e", size = 58225, upload-time = "2026-06-14T22:36:03.321Z" }, + { url = "https://files.pythonhosted.org/packages/29/33/a948f329252ece3f9c93d177243de6e677927ebc6ac44256742dbbef3c39/ujson-5.13.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df805aad707507a1fa165fb716218ca3a89f142125dc4b23c9fcc08fa402d97", size = 57930, upload-time = "2026-06-14T22:36:04.385Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0c/c33655218b8e0a8adbf066de0b999cae5c324061f3eaa4dda17423145d9e/ujson-5.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7576bdbef327c3528f011002a2d74486f6fe4e33289bdb7a042b7f1a6e9d8285", size = 1037728, upload-time = "2026-06-14T22:36:05.467Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/d286947525ea7ce3f2d8dc55c15b9ffbe425bc455c96af7b8f8a402599a9/ujson-5.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6eee5d7cce3f32a468905f9ff61807a60287a90258d849460f6fa826e810870d", size = 1197146, upload-time = "2026-06-14T22:36:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3c/9eb916377050b0785f048a34588c1c390ddd41ae00b78db68ee1ad022356/ujson-5.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:144e9d8a454cfa727e0f755e1863738ed68068583bda5463052cb446835bd56c", size = 1090223, upload-time = "2026-06-14T22:36:08.329Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/242fd97a2628b842d4bfaa9b18e1f68187f934d67503291ebbaab1254637/ujson-5.13.0-cp314-cp314-win32.whl", hash = "sha256:576f35c35b918d67d41b933878062ec0a5c9f4d1e9e14e04aeef35384963feae", size = 41223, upload-time = "2026-06-14T22:36:09.644Z" }, + { url = "https://files.pythonhosted.org/packages/23/f3/7f2bd9ca0c507142d0c22347b3d6f8803be1d8851c31707e57f5923fdbea/ujson-5.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:d5e206e9f849ead27e51ef8da44e52b38da7c6dbd929a7340ab44533edcda8d7", size = 42265, upload-time = "2026-06-14T22:36:11.043Z" }, + { url = "https://files.pythonhosted.org/packages/b0/29/3e9a8fba321c031315f6d263510969a5d01f41fc471b5be107e413c1b2f8/ujson-5.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc470179775468f9a007d3a6a2734624248c94bf47c6645e808c7e50a5070d1a", size = 40205, upload-time = "2026-06-14T22:36:12.286Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/1c543837c6a3c6672361882a0fa269bd02daf9cc4c0ca88a9dccd9df98d9/ujson-5.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:69b4e36bb7d5f413ba8c00c8006b2ec627cc5ace97301462f6aadb66ec9d2979", size = 57402, upload-time = "2026-06-14T22:36:13.238Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/39862f0f7174ff07cfd1e2d0c9065ded34aeebdb7db8daf2f0e5bf89b46f/ujson-5.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b644d50f66de5490c1823c7176618cead5e8e8a88cba9f40a6308ca52e79267", size = 54973, upload-time = "2026-06-14T22:36:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/02/66/f53d3b32c3f177f846ca6b624e832f29000d8a213a2d8768e254bd470ced/ujson-5.13.0-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:15107aaa4f559d55201165ec32abb35c283a861be1fa67229578cb7d93fcd93a", size = 60683, upload-time = "2026-06-14T22:36:15.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d4/dddc4646d2633c85c938c2ded7d5a9711cdad5be1e13b31b7dad76f61c83/ujson-5.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e343c5f0c058523f1edbf6ae4eceb4e0d934205a53bbdd8d9a945c83324662a", size = 54167, upload-time = "2026-06-14T22:36:16.952Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/d8608c3f4d3f05e6441364b63fde1d279700135c1a6577a773662c07fbcc/ujson-5.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02200035bc80e830f076ffc1b329a94c295aee6d9de8c9043647cb9a7bd4f76f", size = 55568, upload-time = "2026-06-14T22:36:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/dd12b735aaba0806c3d70c18184d50e1f9712e0757c7c0a4f376450cfe28/ujson-5.13.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f19b81b73ff28f5c5022ee794f94122bfcda07a76423078e349465d71223a1", size = 59086, upload-time = "2026-06-14T22:36:19.071Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/ad41e8752d5ec3a590a5e7b426a54e36b7aab911d9b5a4f7384dc62507ab/ujson-5.13.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82e1393e6dbe3c95fdfc95c6c528890e191351a1f024ef51126cf1f22543af52", size = 58667, upload-time = "2026-06-14T22:36:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/b44a6afb77b94118655c029081b7932d64bb4c5b1c8ba2b7f5808b5d0bc2/ujson-5.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38afcf994b28ed85ea2420e2a8d79a37d0a77348b3daf53850c16edda66f942d", size = 1038553, upload-time = "2026-06-14T22:36:21.245Z" }, + { url = "https://files.pythonhosted.org/packages/7e/93/fab1d786174c8780eb3e386c73f1925a435e97fbf77c957fea4fca83994d/ujson-5.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1bdf2518971586f2b413156c49d9dd8b56cc990a8647081e1bd00af60564d469", size = 1197938, upload-time = "2026-06-14T22:36:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bc/2f073bb708f9d128f5d1cb39063a5f6421b1ce94c61be8661c55a189f407/ujson-5.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:751ad01042472f1c7c02f5c597c7aee79834e82a6cc384ca302173bbc8e8deb8", size = 1090938, upload-time = "2026-06-14T22:36:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/cdaa50bba29d7dc9eb19212755b09bb96f56596e75957c3717c6b85454de/ujson-5.13.0-cp314-cp314t-win32.whl", hash = "sha256:74f3dd61aeb01b7b2a6754e400224e819279041b3867935a55ccf57fb86a43b2", size = 41802, upload-time = "2026-06-14T22:36:25.418Z" }, + { url = "https://files.pythonhosted.org/packages/bd/66/a6e669e90083febdf6c0600d3807f6017fd4d3962d5bd6ddc605c73a06e5/ujson-5.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c31317d5e4504dae8f98795358b6082fc0ef96e7394806db0a76a4a8717f446", size = 42790, upload-time = "2026-06-14T22:36:26.614Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5f/fcc6c6a9d711fd8b020ca8ff65148212f0a712c809d173cd949e58de68c6/ujson-5.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aefd3c9c95f9b62348956396ff7b31818476f8f54dc4a4e64cbd4f0491db6fca", size = 40708, upload-time = "2026-06-14T22:36:27.721Z" }, + { url = "https://files.pythonhosted.org/packages/30/70/dbdd277d64bd3a149532567ceb082fe26f4ead58c39e0a97566ccbdf14a3/ujson-5.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3e074a1f7778d58aa3b3056bab7b6251aabb3f381808018ca2b7fb8dbdeef7ab", size = 58393, upload-time = "2026-06-14T22:36:28.702Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/592c70af94a67cafacd9c840ae2980f27d511dde2732a4c0dfac8f176ae8/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bb53ef95d35875262b8d0aa28506ca612ddd07058bee2a90f609938e69dc801", size = 54447, upload-time = "2026-06-14T22:36:29.802Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9d/2bb91e1e25a8584cb3b63544b9bd26f621173535c77ac6cae13bad8e7904/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb296a0aa480ab88d895ddaa90372604c08ccc72323f02590612c775426ab413", size = 56066, upload-time = "2026-06-14T22:36:30.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/ec/8e3802fc4a4e31e817b972bbb0e704a484d8c75ec349b3feb45fa9fb54c4/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2862f81af44b3a7e74c5d80caa118d736be1991ce6f1d5c723716fa403060cc6", size = 54938, upload-time = "2026-06-14T22:36:32.051Z" }, + { url = "https://files.pythonhosted.org/packages/4a/48/d0e3e511039b86fd1ecfe2bf761c800552d273ef8f19e71de93bf38a909e/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c16e07581172f08585b409246f4535dab13ee85af0e3d3cfa8684b653ca44fa8", size = 56115, upload-time = "2026-06-14T22:36:33.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/689613037fe691d18eae075cd141089f3a3156146be14512df92d8a9ae8f/ujson-5.13.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:9bd0f2dd05937c3b089af316884de18c6f6182ddb8ffce597d2e7c7a9ba9f447", size = 41802, upload-time = "2026-06-14T22:36:34.523Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/276f830bef4d530d50ff4d8f8c568002e7ebed9f64c06747b1d1c4325f02/ujson-5.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:96e7e019b097b4b25fccddadb369d13f412c13695fcf0680b6bf906376156151", size = 52479, upload-time = "2026-06-14T22:36:35.627Z" }, + { url = "https://files.pythonhosted.org/packages/fc/50/99b05555ef42a2ab2e26aa369c46bde6a64f8aeeb687628102e98cc78bed/ujson-5.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7892ea6dd85ede6d30fbbd22af1239b9d81dabdf9b7a8f10ca6d4464d4d9b8ab", size = 49953, upload-time = "2026-06-14T22:36:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/78/15/399766e8ba002bd8e5e2e45828e0e12a5dbeb4145d6a604ba3787db5eec2/ujson-5.13.0-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e1842e10adc8f0db0d3e8aa3a1f8b05ce0456b39e180c8553d7f36dd0bf24b6b", size = 55716, upload-time = "2026-06-14T22:36:37.833Z" }, + { url = "https://files.pythonhosted.org/packages/61/a6/825d92bce42cd442b57d89b4c20afc1737246321d3433c6194e5c35c6a11/ujson-5.13.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8bbb1f5ab810954fb307b6c5e68af58210c31da8569f9e6498a3958c1859e72", size = 48648, upload-time = "2026-06-14T22:36:38.878Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/7ea7a32f35457560806375193dbc4f0d8ffd8c8adae42f86686392a76c41/ujson-5.13.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f6d55c68985654a84a9b47ac51f2655adac4fd264e4189f832960c2270dcf70", size = 49947, upload-time = "2026-06-14T22:36:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/af/b4/0bd6449ae35b6026d94f4d1a32dbc9f40c969ed1d6e36a7e26ccf56371be/ujson-5.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b266f182d4bce74a6a9e1f86988485e8cd422efdcc7c3f537e00cc956f52678", size = 51149, upload-time = "2026-06-14T22:36:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/39da479a8461d1d78d533940a8426a84e23708a6a7a426f2178e04443252/ujson-5.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfaa8302eb9bb7f5e231f256caf83040760585e32751d19155c0f0c0225f8de1", size = 52456, upload-time = "2026-06-14T22:36:42.266Z" }, + { url = "https://files.pythonhosted.org/packages/12/cc/91ff81c50b85a158878f06d6e9153227bbc04209db291a152c286a0ee4a8/ujson-5.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:43dee3081b00fe447c5b9e2fe7ebfd57f7bcc5dd25b8a439c26c8c174dd581be", size = 41155, upload-time = "2026-06-14T22:36:43.455Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/fa/19a665278931fca142cf1c21927b18b36cffb5fd137bf5228f937a977f84/uv-0.12.3.tar.gz", hash = "sha256:1eb3fea456aea47489d92e10451c9129b7dd9fd8854c4eb17020ba68489d5d9a", size = 5877698, upload-time = "2026-08-07T16:33:32.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/b1/eabb1f57339a63630a9b195f8bcdbcca101f4f5cbc5556e6f592d6049d80/uv-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:0c0561cd369002a5968e138ea477e86507a337b3ede44f441c90d85ed2f54714", size = 21777249, upload-time = "2026-08-07T16:32:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/7d23602e140bdd5bb25ffa8bf3943a86688e8cd558a9894dfbb83728e943/uv-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0550669163cc67d5a7dc8e1702bc011e4a075a4ae52ad50891be654fc6635e0d", size = 20081726, upload-time = "2026-08-07T16:32:23.182Z" }, + { url = "https://files.pythonhosted.org/packages/e4/66/ba257c91d69921d773f523c9e4c3ffb04e233694c415517f5ef797403a4f/uv-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c99f2524fe4b11d74dec85cef9b4d8b725d142dec04cd237f1c06fbdae1ee54", size = 18426262, upload-time = "2026-08-07T16:32:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/7e93226f4f33c3cc25dc942056f46be9c48e846338f8644a700123726dc8/uv-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ac21bea426ddf95fa76d8dc1f67350faed7b4a81951825cf2aaef99fc4144815", size = 21156889, upload-time = "2026-08-07T16:32:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/2504476931b7102cc6bfec5289efefe1279fe03964cb8ad7113691372605/uv-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1e569258e17a536c1bd90a68935dc8250b0ecc81ba80efd814ed169c33de0f93", size = 21319192, upload-time = "2026-08-07T16:32:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/e3/83/e9a93bf4d737bf00d7fa4e2a82ed0ed23bc30d44262127f5f8b9a4dd9150/uv-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8e5a2d2ce4ea511293f919357c427dab0f12104ebfdd6602727f5c38958c22", size = 21334826, upload-time = "2026-08-07T16:32:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ef/19215f66a02451ca2698062e98690d5f2a7e65574104ba04c188c4b59c70/uv-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:115e96e176fa3525ffb999572ec6041b915f15189403613ccefcc1f128f0ea0c", size = 22014926, upload-time = "2026-08-07T16:32:44.159Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a3/38526a97a5d376c59955025e54740ae35a4c7d8d80c6dfbee13fae588964/uv-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:913dc068e906f459df892cac789e6e9e10ac9d6af0bbb3c36fcc09347b0c986c", size = 23248637, upload-time = "2026-08-07T16:32:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a7a98d075383669b6a3ff4ca410cb9c18b6d0921df1c524bd0859a8474fe/uv-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77fa7f501baa7c4d097b0c424914d7924db8e54f882c4f40056228ecf56feb98", size = 22929428, upload-time = "2026-08-07T16:32:52.452Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c4/97fdd4fca11d06633bb500849f70e4e6b201bcba3833894732e709be2d60/uv-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1482d1462b1aecd18ee33627363fe1c63d6a194f12d40d37efc446d9e0d800a1", size = 22346263, upload-time = "2026-08-07T16:32:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/ab0906c8c3b6eeaf2412ab5af6077a38930db90e2c99c1b82e066f240a81/uv-0.12.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:248b6b282f96f98d79dddecd1b2acd1893efd84667321f26703feffff6433211", size = 21288225, upload-time = "2026-08-07T16:33:00.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/f40530b0b336212fff1bc50887d263a9f03d8859e6c1c5210e11d104df10/uv-0.12.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:9201c7a1edadb07f64b695f17eac9db2d264eeb4b888c195aa11e7908d0ebf41", size = 21981757, upload-time = "2026-08-07T16:33:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/0a/cf/240a3bebd15be490f867e155aa59522889d8d6310985421902f87d1fad99/uv-0.12.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6114afd294411995a761c4cde0b721d550ff6d2fc2312046e8403eca6adacda5", size = 22117547, upload-time = "2026-08-07T16:33:09.124Z" }, + { url = "https://files.pythonhosted.org/packages/2f/da/fb143759f86b260f20634e11a27b9312eb6ff9c43c6756102e82875cd82d/uv-0.12.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ba034f32abf966a101cf2434455faaf8d54dfacfff0d4b4c70d673f36e985728", size = 21255639, upload-time = "2026-08-07T16:33:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/c1e0b626919792bf66847429d0c0bbf580a5f148a37268c10d9132f4871a/uv-0.12.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:13535c7d40faa7821c3763f5b6c605eef8556ea16277dc1a07a21203fd3c19e4", size = 22560342, upload-time = "2026-08-07T16:33:17.484Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bf/246d088b4baf0239c8a06afa0ce155ce460942d08a9ac505fac5d0f99fe1/uv-0.12.3-py3-none-win32.whl", hash = "sha256:67b639a56dd36193b55a3bcea10f9dffcab37ed3c1eb26e964e52896df0bb205", size = 19420018, upload-time = "2026-08-07T16:33:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/3f816f972357578f1a21afdff8af9c93e87416afd0e150fc60be7eb280ab/uv-0.12.3-py3-none-win_amd64.whl", hash = "sha256:aeafd6e02b9a8d8beb447040bfc57ab172bdf244208f7147b4f7bbc327135797", size = 20215028, upload-time = "2026-08-07T16:33:25.751Z" }, + { url = "https://files.pythonhosted.org/packages/ff/63/0b08bf418b4d00e911465ad24a5f09040cc51247c362ffe56f859173e99f/uv-0.12.3-py3-none-win_arm64.whl", hash = "sha256:59121ef7217567adf4af41f7d3b04e42abb0c6a11bc87c930b838f9354f9c651", size = 19120228, upload-time = "2026-08-07T16:33:29.609Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" }, + { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "zensical" -version = "0.0.53" +version = "0.0.57" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1015,18 +2020,18 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/8b/d916d8226738421a847f039f71278fd07789744c32e9b40abcfa8b849ad8/zensical-0.0.53.tar.gz", hash = "sha256:61672d3e6389822b5738e099816dbc07416ea84db67c2b1cb7e6ea977d2e04d7", size = 3988318, upload-time = "2026-08-04T14:08:54.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/53/5db8c8e5a257db9a5fff0b77c8e05783283d6aaf42c05e34577f6b59f5d0/zensical-0.0.53-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:292cf9c7c323a50c6e3515d334ca08d9dcc517ce6d9d8ad1cd94d22befab1f56", size = 12835291, upload-time = "2026-08-04T14:08:16.746Z" }, - { url = "https://files.pythonhosted.org/packages/33/73/49a64c2c44aec251336a1cedcccbec7ba3d3eba9dd75d52ed24c09217d86/zensical-0.0.53-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0f4c1219c534d3cccc0b86093748dc009e0e9d80d4dad8d65e2150c846aa1123", size = 12719959, upload-time = "2026-08-04T14:08:20.279Z" }, - { url = "https://files.pythonhosted.org/packages/d8/3a/2c08429f7c725d1a40d158b84d6aca4b5c4320d09a0a313e875d7dd3bfe5/zensical-0.0.53-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ca63b952b4961461b4376d61603adc2bf9d81b4df4946b2f27e20b2726f881f", size = 13169416, upload-time = "2026-08-04T14:08:23.474Z" }, - { url = "https://files.pythonhosted.org/packages/41/bc/ed057082989645d5ad3245bdf0b14c30334a315f866552c794c2413cf92f/zensical-0.0.53-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f41b7f37a0430a1378ac13d9a72513fcc53db676c124378cf63cc6f6e22713", size = 13099720, upload-time = "2026-08-04T14:08:26.521Z" }, - { url = "https://files.pythonhosted.org/packages/a4/54/859cf2267ef853ff20eee2af37d898071f821bf30ec3df7d73061b391c78/zensical-0.0.53-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30ed22e9fcedda71888d9fe84f4fdb1aadd3b66cdb0223716f1eecce9ae22b07", size = 13482295, upload-time = "2026-08-04T14:08:29.618Z" }, - { url = "https://files.pythonhosted.org/packages/35/94/f73744d9f4b6107e2740aad58214285b84d4cf0997cde36bced43089b3d0/zensical-0.0.53-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b0cb72861b14bd985bc5ad0203c35b1da7a19c87c194df3189fab7a910db04", size = 13140985, upload-time = "2026-08-04T14:08:32.731Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ef/7557d859e25e4a74214d718a1528f2a123ff9d84823b49b32df9bc41ef17/zensical-0.0.53-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:15e9813f0f59db6cf1316414301139d030f1690b68af645f1bf68d78bc3defe0", size = 13344554, upload-time = "2026-08-04T14:08:35.924Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d9/3a1011bd4390e85a6f602afca6ff8b862454415a800e7b41471dadd9e6b1/zensical-0.0.53-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c203493598d6cad890d7cb48f9d75693f648fe0d2347b2f147406a99fd7bb101", size = 13373180, upload-time = "2026-08-04T14:08:39.384Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ee/f4faf3d66d1e854afa43fa5354c1e0c8414af3fc5c563233a3ca7f10e494/zensical-0.0.53-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ec34844b3bc1855f5c10b99efbeebd27abcd983a9144dbad965609e65915c050", size = 13531133, upload-time = "2026-08-04T14:08:42.679Z" }, - { url = "https://files.pythonhosted.org/packages/5e/98/4a0272bb79bdd326714e552f685d58f31a501ffd49bdc17ae187e92b2581/zensical-0.0.53-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e016062c3299c84be811848d1e81ad0f3f711615f0bed87bb0a1b47f6968a5a4", size = 13480141, upload-time = "2026-08-04T14:08:45.97Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ab/8cbceea1e7f4d6d2ac078a0c34ccd06f1419401248d22f6d6ded4ac9a443/zensical-0.0.53-cp310-abi3-win32.whl", hash = "sha256:abb0af33bb646f15224045baa6c4118b59a2c9c3f80d7cd48edd66ee961c1985", size = 12410234, upload-time = "2026-08-04T14:08:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ac/65f0ced38274b6c1073a4b1c52ea41b8b43e7e972f5e3979c2f2aca5cc46/zensical-0.0.53-cp310-abi3-win_amd64.whl", hash = "sha256:8b609bc89717b6f276774651ea3a41df21b4813929d2a206ee161a294dc28cd1", size = 12646224, upload-time = "2026-08-04T14:08:51.945Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/83/f4/fa40086c46a2e59e3d9239031f76623622e60e0d79f3df1282df2797a5c4/zensical-0.0.57.tar.gz", hash = "sha256:25fcbdf89a57153cc3ad1108a89d17c7226da5d3c551a8839c69cbd9c472a9d8", size = 4000458, upload-time = "2026-08-21T20:43:49.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/b9/49c37dc65105d1ca4a8b600a02c84ece00218d2293b2630611c620185ca3/zensical-0.0.57-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98867d1a6ea2c57f1ebcf4902f61601f427350f2df0c04e30cfac8ba6163cd29", size = 12888507, upload-time = "2026-08-21T20:43:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/54539984418de11387bbace39a744195555d32c98c95bf4d112b432548f5/zensical-0.0.57-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0d7935d77d73a279545052e05d89d31960f30c1f33f53933f4c101fa271aee74", size = 12778169, upload-time = "2026-08-21T20:43:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/74aa60aa4cfecd5bd31ce60cb6a092cb56f1bc1aaadcc173463861ea4eb5/zensical-0.0.57-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7046d433511d97aa603915f0f6792d15b7f839793abc2b66ab7b7ff753ecff5", size = 13230823, upload-time = "2026-08-21T20:43:25.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d1/742d2487dd65dd18277daebcd37db56d5bd4a2408df02bde703ef8fb7b64/zensical-0.0.57-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab85c5066b95e3a877cf8971e4ce30abb1ca1459fbfcc631f0a5a2bab56351a4", size = 13170523, upload-time = "2026-08-21T20:43:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/56/6f/12b570775d344f1a3d77e26d4ae0160bcac9e41ca38f7135352ccdf9b2c8/zensical-0.0.57-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f13d1b57ad3c8b8634933a93ea870ebac11245fe0c968d27fd2a059ee1c6311", size = 13549941, upload-time = "2026-08-21T20:43:29.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4e/436e6fc76674244c084ef7f6f17dc5ff85c76b15aef77c48b703fd0a2dda/zensical-0.0.57-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:021dd8fb70d1816cd012684fcf45d32b8f88a0cd28b7cbe71e5f8564f6d5764d", size = 13210086, upload-time = "2026-08-21T20:43:32.098Z" }, + { url = "https://files.pythonhosted.org/packages/ef/52/20f3aeda9af1090f24241670a5cc20fff7494545fea9f5fa094c82f3dbdf/zensical-0.0.57-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7e10f3c27fdc3eac3a9ae6ddcd87f3f00edc9f332050923313c95537961bfadd", size = 13408253, upload-time = "2026-08-21T20:43:34.258Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f2/2b18ba2f19674dbfcf745f3b66e005cc8efa66a1bcaba5e1b4f79467868a/zensical-0.0.57-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:78c85fee55c5aac3bdf8157e980c56397dca835167a5577c5429b5eb24ed990c", size = 13446689, upload-time = "2026-08-21T20:43:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/05/ba/68cdba447a9097e5f97742eef046020c6fa42d82972849b3a46a0718e890/zensical-0.0.57-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:478d252e1924f3876e72cf7806967cb62e50d86eddb3da04bf43e882b532fa1b", size = 13598580, upload-time = "2026-08-21T20:43:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/89/6358a4df272328bed5bea90b04d43e73758bc45ff058c5cb2665e1147314/zensical-0.0.57-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66a9ca6b5f625b2a2b215eec2f3c72843a92d5d512042045ac6351d5dee9b339", size = 13557609, upload-time = "2026-08-21T20:43:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/77/e1/8831301a24f736743e3788f09ea048918b0bdcea4aaa90f7770a433d6eec/zensical-0.0.57-cp310-abi3-win32.whl", hash = "sha256:f0fe3dc27ca7dc4e168eddd0fe5b0f4d44e311fd4e0019241e289819e445203c", size = 12446805, upload-time = "2026-08-21T20:43:43.097Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3f/5d0ecd77d9ce962fdfde22dec036f4257a43ef6dbd55fb5c05fd294985ad/zensical-0.0.57-cp310-abi3-win_amd64.whl", hash = "sha256:a756834025c1c54e806e943be6d8df1048d0f8bcf6086e958568407a070a2572", size = 12716781, upload-time = "2026-08-21T20:43:45.273Z" }, ]